banyaro/backend/static/js/pages/events.js

673 lines
27 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 — Events (Hundeveranstaltungen)
Liste/Karte · Filter · Erstellen/Bearbeiten
============================================================ */
window.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 _quellFilter = 'alle'; // 'alle' | 'vdh' | 'nutzer'
let _view = 'liste'; // liste | karte
let _map = null;
let _markers = [];
let _clusterGroup = null;
let _myRsvp = {}; // { [event_id]: 'going'|'maybe'|null }
// ----------------------------------------------------------
// Phosphor-Icon-Helper
// ----------------------------------------------------------
function _icon(name) {
return `<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#${name}"></use></svg>`;
}
function _emptyState(icon, title, text, cta = '') {
return `<div class="empty-state">
<svg class="ph-icon empty-state-icon" aria-hidden="true">
<use href="/icons/phosphor.svg#${icon}"></use>
</svg>
<div class="empty-state-title">${title}</div>
${text ? `<p class="empty-state-text">${text}</p>` : ''}
${cta ? `<div class="empty-state-cta">${cta}</div>` : ''}
</div>`;
}
// ----------------------------------------------------------
// 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="by-toolbar">
<div class="events-view-toggle">
<button class="events-view-btn active" data-ev-view="liste">${UI.icon('list')} Liste</button>
<button class="events-view-btn" data-ev-view="karte">${UI.icon('map-trifold')} Karte</button>
</div>
<div style="flex:1"></div>
${_state.user ? `<button class="btn btn-primary btn-sm" id="ev-new-btn">${UI.icon('plus')} Event</button>` : ''}
</div>
<div class="events-filter-bar by-tabs" id="ev-filter-bar">
${TYPEN.map(t => `
<button class="by-tab ${t.id === 'alle' ? 'active' : ''}" data-ev-typ="${t.id}">
${t.icon} ${t.label}
</button>
`).join('')}
</div>
<div class="events-source-bar by-tabs" id="ev-source-bar">
<button class="by-tab active" data-ev-quelle="alle">Alle Quellen</button>
<button class="by-tab" data-ev-quelle="vdh">
<span class="ev-vdh-badge">VDH</span> VDH-Events
</button>
<button class="by-tab" data-ev-quelle="nutzer">Von Nutzern</button>
</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');
}
}
// ----------------------------------------------------------
// Gefilterte Events ermitteln
// ----------------------------------------------------------
function _filtered() {
let evs = _filter === 'alle' ? _events : _events.filter(e => e.typ === _filter);
if (_quellFilter !== 'alle') {
evs = evs.filter(e => (e.quelle || 'nutzer') === _quellFilter);
}
return evs;
}
// ----------------------------------------------------------
// Liste rendern
// ----------------------------------------------------------
function _renderList() {
const listEl = document.getElementById('ev-list');
if (!listEl) return;
const filtered = _filtered();
if (!filtered.length) {
listEl.innerHTML = _emptyState(
'calendar-blank',
'Keine Events in der Nähe',
'Hier erscheinen Hundeveranstaltungen, Treffen und Aktivitäten in deiner Umgebung.'
);
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;
}
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;
const isVdh = ev.quelle === 'vdh';
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.escape(ev.titel)}
${isVdh ? `<span class="ev-vdh-badge" title="Vom VDH importiert">VDH</span>` : ''}
</div>
<div class="events-card-meta">
<span class="events-badge" style="background:${color}20;color:${color}">${typ.icon} ${typ.label}</span>
${ev.uhrzeit ? `· ${_icon('clock')} ${ev.uhrzeit} Uhr` : ''}
${ev.ort_name ? `· ${_icon('map-pin')} ${UI.escape(ev.ort_name)}` : ''}
</div>
${ev.rsvp_count ? `<span class="event-attendees" data-ev-attendees="${ev.id}">${_icon('users')} ${ev.rsvp_count} nehmen teil</span>` : ''}
${ev.link ? `<div class="events-card-actions">
<a class="btn btn-ghost btn-xs ev-ext-link" href="${UI.escape(ev.link)}" target="_blank" rel="noopener" onclick="event.stopPropagation()">
${_icon('arrow-square-out')} Details
</a>
</div>` : ''}
</div>
${isOwn ? `<button class="btn-icon" data-ev-edit="${ev.id}" title="Bearbeiten">${_icon('pencil-simple')}</button>` : ''}
</div>
`;
}
// ----------------------------------------------------------
// Karte
// ----------------------------------------------------------
async function _renderMap(filtered) {
const mapEl = document.getElementById('ev-map');
if (!mapEl) return;
await _loadLeaflet();
await _loadMarkerCluster();
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);
}
// Cluster-Gruppe aufräumen und neu befüllen
if (_clusterGroup) {
_map.removeLayer(_clusterGroup);
}
_clusterGroup = L.markerClusterGroup();
_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 d = new Date(ev.datum + 'T00:00:00');
const datum = d.toLocaleDateString('de-DE', { day: 'numeric', month: 'short', year: 'numeric' });
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 popup = `
<div style="min-width:180px">
<strong>${UI.escape(ev.titel)}</strong><br>
<span style="color:#666;font-size:12px">${datum}</span><br>
${ev.ort_name ? `<span style="font-size:12px">📍 ${UI.escape(ev.ort_name)}</span><br>` : ''}
${ev.beschreibung ? `<span style="font-size:12px">${UI.escape(ev.beschreibung.slice(0, 80))}${ev.beschreibung.length > 80 ? '…' : ''}</span><br>` : ''}
<a href="#" onclick="event.preventDefault();Page_events._openDetail(${ev.id})"
style="font-size:12px;color:var(--c-primary,#2563eb)">Details</a>
</div>
`;
const m = L.marker([ev.lat, ev.lon], { icon }).bindPopup(popup);
_clusterGroup.addLayer(m);
_markers.push(m);
bounds.push([ev.lat, ev.lon]);
}
_map.addLayer(_clusterGroup);
if (bounds.length) {
_map.fitBounds(bounds, { padding: [40, 40], maxZoom: 12 });
} else {
// Versuche Nutzerstandort, sonst Deutschland-Übersicht
try {
const pos = await API.getLocation({ timeout: 5000 });
_map.setView([pos.lat, pos.lon], 10);
} catch {
_map.setView([51.1657, 10.4515], 6);
}
}
_map.invalidateSize();
setTimeout(() => _map.invalidateSize(), 100);
}
function _loadLeaflet() {
if (window.L) return Promise.resolve();
return new Promise((resolve, reject) => {
const cssLoaded = document.querySelector('link[href*="leaflet"]')
? Promise.resolve()
: new Promise(res => {
const link = document.createElement('link');
link.rel = 'stylesheet';
link.href = '/css/leaflet.css';
link.onload = res;
link.onerror = res;
document.head.appendChild(link);
});
cssLoaded.then(() => {
if (document.querySelector('script[src*="leaflet.js"]')) { resolve(); return; }
const s = document.createElement('script');
s.src = '/js/leaflet.js';
s.onload = resolve;
s.onerror = reject;
document.head.appendChild(s);
});
});
}
function _loadMarkerCluster() {
if (window.L && L.markerClusterGroup) return Promise.resolve();
return new Promise((resolve, reject) => {
const cssLoaded = document.querySelector('link[href*="MarkerCluster"]')
? Promise.resolve()
: new Promise(res => {
const link = document.createElement('link');
link.rel = 'stylesheet';
link.href = '/css/MarkerCluster.css';
link.onload = res;
link.onerror = res;
document.head.appendChild(link);
const link2 = document.createElement('link');
link2.rel = 'stylesheet';
link2.href = '/css/MarkerCluster.Default.css';
link2.onload = res;
link2.onerror = res;
document.head.appendChild(link2);
});
cssLoaded.then(() => {
if (document.querySelector('script[src*="markercluster"]') ||
document.querySelector('script[src*="MarkerCluster"]')) { resolve(); return; }
const s = document.createElement('script');
s.src = '/js/leaflet.markercluster.js';
s.onload = resolve;
s.onerror = resolve; // Cluster ist optional — graceful degradation
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 isVdh = ev.quelle === 'vdh';
const myRsvp = _myRsvp[id] ?? null;
// RSVP-Bar (nur für eingeloggte User)
const rsvpBar = _state.user ? `
<div class="event-rsvp-bar" id="ev-rsvp-bar-${id}">
<button class="btn event-rsvp-btn ${myRsvp === 'going' ? 'active' : ''}" data-rsvp-id="${id}" data-rsvp-status="going">
${_icon('check-circle')} Ich komme
</button>
<button class="btn event-rsvp-btn ${myRsvp === 'maybe' ? 'active' : ''}" data-rsvp-id="${id}" data-rsvp-status="maybe">
${_icon('question')} Vielleicht
</button>
${ev.rsvp_count ? `<span class="event-attendees" id="ev-attendees-${id}" data-ev-attendees="${id}" style="margin-left:auto">${_icon('users')} ${ev.rsvp_count} nehmen teil</span>` : `<span class="event-attendees" id="ev-attendees-${id}" data-ev-attendees="${id}" style="margin-left:auto;display:none">${_icon('users')} 0 nehmen teil</span>`}
</div>
` : (ev.rsvp_count ? `<div class="event-rsvp-bar"><span class="event-attendees" data-ev-attendees="${id}">${_icon('users')} ${ev.rsvp_count} nehmen teil</span></div>` : '');
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>
${isVdh ? `<span class="ev-vdh-badge">VDH</span>` : ''}
</div>
<div class="events-detail-row">${_icon('calendar-dots')} ${datum}${ev.uhrzeit ? ' · ' + ev.uhrzeit + ' Uhr' : ''}</div>
${ev.ort_name ? `<div class="events-detail-row">${_icon('map-pin')} ${UI.escape(ev.ort_name)}</div>` : ''}
${ev.beschreibung ? `<div class="events-detail-desc">${UI.escape(ev.beschreibung)}</div>` : ''}
${ev.link ? `<div class="events-detail-row">
${_icon('arrow-square-out')}
<a href="${UI.escape(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)">
${_icon('user')} Veranstalter: ${UI.escape(ev.veranstalter_name || '')}
</div>
${rsvpBar}
<div id="ev-attendees-panel-${id}"></div>
`;
const footer = isOwn ? `
<button class="btn btn-secondary" id="ev-detail-edit">${_icon('pencil-simple')} Bearbeiten</button>
<button class="btn btn-danger" id="ev-detail-del">${_icon('trash')} Löschen</button>
` : (ev.link ? `
<a class="btn btn-primary" href="${UI.escape(ev.link)}" target="_blank" rel="noopener">
${_icon('arrow-square-out')} Zur Veranstaltung
</a>
` : '');
UI.modal.open({ title: UI.escape(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));
// RSVP-Buttons
document.querySelectorAll(`[data-rsvp-id="${id}"]`).forEach(btn => {
btn.addEventListener('click', () => _handleRsvp(id, btn.dataset.rsvpStatus));
});
}
async function _handleRsvp(eventId, status) {
const current = _myRsvp[eventId] ?? null;
try {
if (current === status) {
// Toggle off → absagen
await API.events.cancelRsvp(eventId);
_myRsvp[eventId] = null;
} else {
const res = await API.events.rsvp(eventId, status);
_myRsvp[eventId] = status;
// Teilnehmerzähler aktualisieren
_updateAttendeeCount(eventId, res.rsvp_count);
}
// Button-Styles aktualisieren
document.querySelectorAll(`[data-rsvp-id="${eventId}"]`).forEach(btn => {
btn.classList.toggle('active', btn.dataset.rsvpStatus === (_myRsvp[eventId] ?? ''));
});
// Bei Absage Zähler neu laden
if (current === status) {
const attendees = await API.events.listRsvp(eventId);
const goingCount = attendees.filter(a => a.status === 'going').length;
_updateAttendeeCount(eventId, goingCount);
}
} catch (e) { UI.toast(e.message, 'error'); }
}
function _updateAttendeeCount(eventId, count) {
// Im Modal
const span = document.getElementById(`ev-attendees-${eventId}`);
if (span) {
if (count > 0) {
span.innerHTML = `${_icon('users')} ${count} nehmen teil`;
span.style.display = '';
} else {
span.style.display = 'none';
}
}
// In der Listenansicht (Event-Objekt aktualisieren)
const ev = _events.find(x => x.id === eventId);
if (ev) {
ev.rsvp_count = count;
// Karte neu rendern falls sichtbar
const card = document.querySelector(`[data-ev-id="${eventId}"]`);
if (card) card.outerHTML = _cardHTML(ev);
}
}
async function _showAttendees(eventId) {
const panel = document.getElementById(`ev-attendees-panel-${eventId}`);
if (!panel) return;
if (panel.dataset.loaded) { panel.innerHTML = ''; delete panel.dataset.loaded; return; }
try {
const attendees = await API.events.listRsvp(eventId);
if (!attendees.length) { panel.innerHTML = '<p style="font-size:var(--text-sm);color:var(--c-text-muted);margin-top:var(--space-2)">Noch keine Zusagen.</p>'; }
else {
panel.innerHTML = `
<div class="ev-attendees-list">
${attendees.map(a => `
<span class="ev-attendee-chip">
${a.status === 'going' ? _icon('check-circle') : _icon('question')}
${UI.escape(a.name)}
</span>
`).join('')}
</div>`;
}
panel.dataset.loaded = '1';
} catch { /* ignore */ }
}
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.escape(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">${_icon('map-pin')} 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) {
// Quelle-Filter
const sourceBtn = e.target.closest('[data-ev-quelle]');
if (sourceBtn) {
_quellFilter = sourceBtn.dataset.evQuelle;
document.querySelectorAll('[data-ev-quelle]').forEach(b => b.classList.toggle('active', b.dataset.evQuelle === _quellFilter));
if (_view === 'karte') { _renderMap(_filtered()); } else { _renderList(); }
return;
}
// Typ-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));
if (_view === 'karte') { _renderMap(_filtered()); } else { _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';
// Erst div sichtbar machen, dann Karte initialisieren
_renderMap(_filtered());
} else {
// Karte sauber entfernen
if (_map) {
_map.remove();
_map = null;
_clusterGroup = null;
_markers = [];
}
mapEl.style.display = 'none';
listEl.style.display = '';
_renderList();
}
return;
}
// Neu-Button
if (e.target.closest('#ev-new-btn')) { openNew(); return; }
// Externer Link — nicht als Karten-Klick behandeln
if (e.target.closest('.ev-ext-link')) 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;
}
// Teilnehmer-Liste anzeigen (Karten-Ansicht oder Modal)
const attendeesBtn = e.target.closest('[data-ev-attendees]');
if (attendeesBtn) {
e.stopPropagation();
_showAttendees(parseInt(attendeesBtn.dataset.evAttendees));
return;
}
// Karten-Klick → Detail
const card = e.target.closest('[data-ev-id]');
if (card) { _showDetail(parseInt(card.dataset.evId)); }
}
return { init, refresh, openNew, _openDetail: _showDetail };
})();