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
717 lines
30 KiB
JavaScript
717 lines
30 KiB
JavaScript
/* ============================================================
|
||
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 };
|
||
|
||
})();
|