banyaro/backend/static/js/pages/events.js
rene 553e9e7854 Sprint 12+13: Tagebuch Day-One-Redesign, Notiz-Feature, Icon-Fixes, SW by-v405
Tagebuch:
- Day-One-Listenansicht: Wochentag + Tageszahl + Meta-Zeile (Zeit/Ort/Wetter)
- 4 Ansichten: Liste, Medien-Mosaik, Kalender (mit Sprungbuttons), Karte (GPS-Marker)
- Detail-Ansicht inline im Content-Bereich (kein Fullscreen-Overlay mehr)
- Hero-Bild vollständig sichtbar (object-fit:contain), Lightbox mit Safe-Area
- 2-Spalten-Layout Desktop: Text + Leaflet-Karte + POI-Liste
- EXIF-GPS-Extraktion bei Foto-Upload, historisches Wetter via Archive-API
- NoteStation-Import: Fotos in diary_media (80 Einträge migriert, 94 Medien)
- Stats-Endpoints: /diary/stats, /diary/calendar, /diary/locations

Notiz-Feature:
- Generische notes-Tabelle (parent_type + parent_id + meta_json)
- 📝-Button in 8 Bereichen, Notizblock-Seite mit KI-Analyse
- KI-Toggle in Einstellungen, notes_ki_enabled in User-Profil

Icons & Design:
- fill:currentColor Fix für welcome/onboarding/friends.js
- --c-icon Variable, --c-text-muted Dark Mode aufgehellt
- 15+ neue Phosphor-Icons aus lokaler Kopie
- CSS Network-First im SW, Cache-Control-Middleware

Infrastruktur:
- Wiki-Anreicherungs-Scheduler-Jobs entfernt (abgeschlossen)
- auth.py: notes_ki_enabled + is_social_media im User-Response
2026-04-25 20:44:46 +02:00

717 lines
30 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 _search = '';
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>`;
}
// _emptyState ersetzt durch UI.emptyState()
// ----------------------------------------------------------
// 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="diary-search-wrap" style="margin:var(--space-2) var(--space-3) 0" id="ev-search-wrap">
<svg class="ph-icon diary-search-icon" aria-hidden="true"><use href="/icons/phosphor.svg#magnifying-glass"></use></svg>
<input type="search" class="diary-search-input" id="ev-search"
placeholder="Events durchsuchen…" autocomplete="off">
</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);
// Suche mit Debounce
let _searchTimer = null;
document.getElementById('ev-search')?.addEventListener('input', e => {
clearTimeout(_searchTimer);
_searchTimer = setTimeout(() => {
_search = e.target.value.trim().toLowerCase();
if (_view === 'karte') { _renderMap(_filtered()); } else { _renderList(); }
}, 300);
});
}
// ----------------------------------------------------------
// 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);
}
if (_search) {
const q = _search;
evs = evs.filter(e =>
(e.titel || '').toLowerCase().includes(q) ||
(e.ort_name || '').toLowerCase().includes(q) ||
(e.beschreibung|| '').toLowerCase().includes(q)
);
}
return evs;
}
// ----------------------------------------------------------
// Liste rendern
// ----------------------------------------------------------
function _renderList() {
const listEl = document.getElementById('ev-list');
if (!listEl) return;
const filtered = _filtered();
if (!filtered.length) {
listEl.innerHTML = UI.emptyState({
icon: UI.icon('calendar-blank'),
title: _search ? 'Keine Events gefunden' : 'Keine Events in der Nähe',
text: _search
? `Keine Events passen zu „${UI.escape(_search)}".`
: '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>
<div style="display:flex;flex-direction:column;align-items:flex-end;gap:var(--space-1)">
${isOwn ? `<button class="btn-icon" data-ev-edit="${ev.id}" title="Bearbeiten" onclick="event.stopPropagation()">${_icon('pencil-simple')}</button>` : ''}
${_state.user ? `<button class="btn-icon ev-note-btn" data-ev-note-id="${ev.id}"
data-ev-note-label="${UI.escape(ev.titel + ' ' + ev.datum)}"
data-ev-note-ort="${UI.escape(ev.ort_name || '')}"
title="Notiz" style="color:var(--c-text-muted)" onclick="event.stopPropagation()">
${_icon('note-pencil')}</button>` : ''}
</div>
</div>
`;
}
// ----------------------------------------------------------
// Karte
// ----------------------------------------------------------
async function _renderMap(filtered) {
const mapEl = document.getElementById('ev-map');
if (!mapEl) return;
await UI.loadLeaflet(true); // true = mit MarkerCluster
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' });
// Events nutzen rotierten Diamant-Marker (nicht Kreis) — UI.leafletMarker() nicht anwendbar
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:var(--c-text-muted);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);
}
// _loadLeaflet und _loadMarkerCluster ersetzt durch UI.loadLeaflet(true)
// ----------------------------------------------------------
// 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-group">
<label class="form-label">GPS-Position</label>
<div id="ev-location-picker"></div>
</div>
<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 = `
<div style="display:flex;flex-direction:column;gap:var(--space-2);width:100%">
<button class="btn btn-primary" type="submit" form="${id}" id="ev-submit-btn" style="width:100%">
${isEdit ? 'Speichern' : 'Event erstellen'}
</button>
<div style="display:flex;gap:var(--space-2)">
${isEdit ? `<button type="button" class="btn btn-danger" id="ev-form-delete">Löschen</button>` : ''}
<button class="btn btn-secondary flex-1" onclick="UI.modal.close()">Abbrechen</button>
</div>
</div>
`;
UI.modal.open({ title: isEdit ? 'Event bearbeiten' : 'Neues Event', body, footer });
document.getElementById('ev-form-delete')?.addEventListener('click', async () => {
const ok = await UI.modal.confirm({
title: 'Event löschen?', message: 'Nicht rückgängig.', confirmText: 'Löschen', danger: true,
});
if (ok) await _deleteEvent(ev);
});
// Location-Picker initialisieren
const _picker = UI.locationPicker({
containerId: 'ev-location-picker',
});
if (ev?.lat && ev?.lon) {
_picker.setValue(ev.lat, ev.lon, ev.ort_name || null);
}
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 loc = _picker.getValue();
const data = {
titel: fd.get('titel'),
datum: fd.get('datum'),
uhrzeit: fd.get('uhrzeit') || null,
typ: fd.get('typ'),
ort_name: loc.name || fd.get('ort_name') || null,
lat: loc.lat || null,
lon: loc.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;
}
// Notiz-Button
const noteBtn = e.target.closest('.ev-note-btn');
if (noteBtn) {
e.stopPropagation();
_openNoteModal(
'event',
parseInt(noteBtn.dataset.evNoteId),
noteBtn.dataset.evNoteLabel,
noteBtn.dataset.evNoteOrt || null
);
return;
}
// Karten-Klick → Detail
const card = e.target.closest('[data-ev-id]');
if (card) { _showDetail(parseInt(card.dataset.evId)); }
}
// ----------------------------------------------------------
// Notiz-Modal (Custom DOM — kein UI.modal um Konflikte zu vermeiden)
// ----------------------------------------------------------
async function _openNoteModal(parentType, parentId, parentLabel, locationName) {
let existingNote = null;
try { existingNote = await API.notes.get(parentType, String(parentId)); } catch {}
const ovl = document.createElement('div');
ovl.style.cssText = 'position:fixed;inset:0;z-index:1200;background:rgba(0,0,0,0.55);display:flex;align-items:flex-end;justify-content:center';
ovl.innerHTML = `
<div style="width:100%;max-width:600px;background:var(--c-surface);border-radius:var(--radius-lg) var(--radius-lg) 0 0;
padding:var(--space-4);box-sizing:border-box;max-height:80vh;display:flex;flex-direction:column">
<div style="display:flex;align-items:center;gap:var(--space-3);margin-bottom:var(--space-3)">
<svg class="ph-icon" aria-hidden="true" style="color:var(--c-primary)"><use href="/icons/phosphor.svg#note-pencil"></use></svg>
<span style="font-weight:600;flex:1"><svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#note-pencil"></use></svg> Notiz — ${UI.escape(parentLabel)}</span>
<button id="ev-note-close" style="background:none;border:none;cursor:pointer;color:var(--c-text-muted);padding:4px">
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#x"></use></svg>
</button>
</div>
<textarea id="ev-note-text" rows="5"
style="width:100%;box-sizing:border-box;padding:var(--space-3);
border:1.5px solid var(--c-border);border-radius:var(--radius-md);
font-size:var(--text-sm);font-family:inherit;
background:var(--c-bg);color:var(--c-text);resize:vertical;flex:1"
placeholder="Deine Notiz zu diesem Event…">${UI.escape(existingNote?.text || '')}</textarea>
<div style="display:flex;gap:var(--space-2);margin-top:var(--space-3)">
<button id="ev-note-cancel" class="btn btn-secondary flex-1">Abbrechen</button>
<button id="ev-note-save" class="btn btn-primary flex-1">Speichern</button>
</div>
</div>
`;
document.body.appendChild(ovl);
const close = () => ovl.remove();
ovl.querySelector('#ev-note-close')?.addEventListener('click', close);
ovl.querySelector('#ev-note-cancel')?.addEventListener('click', close);
ovl.addEventListener('click', e => { if (e.target === ovl) close(); });
ovl.querySelector('#ev-note-save')?.addEventListener('click', async () => {
const text = ovl.querySelector('#ev-note-text')?.value?.trim() || '';
const payload = { text, parent_label: parentLabel, location_name: locationName || null };
try {
if (existingNote?.id) {
await API.notes.update(existingNote.id, payload);
} else {
await API.notes.create(parentType, String(parentId), payload);
}
UI.toast.success('Notiz gespeichert.');
close();
} catch (err) { UI.toast.error(err.message || 'Fehler beim Speichern.'); }
});
}
return { init, refresh, openNew, _openDetail: _showDetail };
})();