/* ============================================================ BAN YARO — Giftköder-Alarm (Sprint 2) Seiten-Modul: Leaflet-Karte + Meldungsliste + Melden-Formular. ============================================================ */ window.Page_poison = (() => { const _CACHE_KEY = 'by_poison_cache'; // ---------------------------------------------------------- // MODUL-STATE // ---------------------------------------------------------- let _container = null; let _appState = null; let _map = null; let _markers = []; let _userMarker = null; let _reports = []; let _userPos = null; const TYPEN = { unbekannt: { label: 'Unbekannt', icon: 'question', color: '#e67e22' }, koeoder: { label: 'Köder', icon: 'fish', color: '#e74c3c' }, vergiftet: { label: 'Vergiftetes Tier', icon: 'skull', color: '#8e44ad' }, chemikalie: { label: 'Chemikalie', icon: 'flask', color: '#c0392b' }, andere: { label: 'Andere Gefahr', icon: 'warning', color: '#d35400' }, }; // ---------------------------------------------------------- // INIT // ---------------------------------------------------------- async function init(container, appState) { _container = container; _appState = appState; await _render(); } // ---------------------------------------------------------- // REFRESH — Navigation zur bereits geladenen Seite // ---------------------------------------------------------- async function refresh() { if (_userPos) await _loadReports(); } // ---------------------------------------------------------- // OPEN NEW — vom + Button // ---------------------------------------------------------- function openNew() { _showReportForm(); } // ---------------------------------------------------------- // RENDER — Grundstruktur aufbauen // ---------------------------------------------------------- async function _render() { _container.innerHTML = `
© OpenStreetMap-Mitwirkende
${UI.icon('phone')} 110 Polizei

Standort wird ermittelt…

`; document.getElementById('poison-btn-locate') ?.addEventListener('click', _locateUser); document.getElementById('poison-btn-report') ?.addEventListener('click', _showReportForm); document.getElementById('poison-btn-erstehilfe') ?.addEventListener('click', () => App.navigate('erste-hilfe', true, { tab: 'lebensgefahr' })); await UI.loadLeaflet(); _initMap(); // Leaflet muss nach CSS-Load die Container-Größe neu berechnen setTimeout(() => _map?.invalidateSize(), 100); await _locateAndLoad(); } // ---------------------------------------------------------- // KARTE INITIALISIEREN // ---------------------------------------------------------- function _initMap() { const mapEl = document.getElementById('poison-map'); if (!mapEl || !window.L || _map) return; _map = L.map('poison-map', { zoomControl: true, attributionControl: false }) .setView([51.1657, 10.4515], 6); // Deutschland-Mitte L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', { maxZoom: 19, }).addTo(_map); } // ---------------------------------------------------------- // STANDORT ERMITTELN + LADEN // ---------------------------------------------------------- async function _locateAndLoad() { try { _userPos = await API.getLocation({ timeout: 8000 }); _showUserOnMap(); } catch { _userPos = null; } await _loadReports(); } async function _locateUser() { const btn = document.getElementById('poison-btn-locate'); UI.setLoading(btn, true); try { _userPos = await API.getLocation({ timeout: 8000 }); _showUserOnMap(); if (_map) _map.setView([_userPos.lat, _userPos.lon], 13); await _loadReports(); } catch { UI.toast.warning('Standort konnte nicht ermittelt werden.'); } UI.setLoading(btn, false); } function _showUserOnMap() { if (!_map || !window.L || !_userPos) return; if (_userMarker) _map.removeLayer(_userMarker); _userMarker = L.circleMarker([_userPos.lat, _userPos.lon], { radius : 9, fillColor : '#3498db', color : '#fff', weight : 2, fillOpacity : 0.9, }).addTo(_map).bindPopup('Du bist hier'); _map.setView([_userPos.lat, _userPos.lon], 13); } // ---------------------------------------------------------- // MELDUNGEN LADEN // ---------------------------------------------------------- async function _loadReports() { const infoEl = document.getElementById('poison-info'); if (!_userPos) { _reports = []; _renderList(); if (infoEl) infoEl.textContent = 'Standort unbekannt — bitte Standort freigeben (📍 Mein Standort).'; return; } try { _reports = await API.poison.listNearby(_userPos.lat, _userPos.lon, 10000); try { localStorage.setItem(_CACHE_KEY, JSON.stringify({ ts: Date.now(), data: _reports })); } catch {} _renderMarkers(); _renderList(); _updateBadge(_reports.length); if (infoEl) { infoEl.textContent = _reports.length > 0 ? `${_reports.length} aktive Meldung${_reports.length !== 1 ? 'en' : ''} im Umkreis von 10 km` : 'Keine aktiven Giftköder-Meldungen in deiner Nähe (10 km Radius).'; } } catch { try { const raw = localStorage.getItem(_CACHE_KEY); if (raw) { _reports = JSON.parse(raw).data || []; _renderMarkers(); _renderList(); _updateBadge(_reports.length); if (infoEl) infoEl.textContent = `${_reports.length} gecachte Meldung${_reports.length !== 1 ? 'en' : ''} (Offline)`; UI.toast.info('Offline — zeige zuletzt geladene Daten.'); return; } } catch {} UI.toast.error('Meldungen konnten nicht geladen werden.'); } } // ---------------------------------------------------------- // KARTEN-MARKER // ---------------------------------------------------------- function _renderMarkers() { if (!_map || !window.L) return; _markers.forEach(m => _map.removeLayer(m)); _markers = []; _reports.forEach(r => { const typ = TYPEN[r.typ] || TYPEN.unbekannt; const distStr = r.distanz_m < 1000 ? `${r.distanz_m} m` : `${(r.distanz_m / 1000).toFixed(1)} km`; const marker = UI.leafletMarker({ lat: r.lat, lon: r.lon, color: typ.color, icon: UI.icon(typ.icon), size: 34 }) .addTo(_map) .bindPopup(` ${UI.icon(typ.icon)} ${typ.label}
${r.beschreibung ? UI.escape(r.beschreibung.slice(0, 80)) + '
' : ''} 📍 ${distStr} entfernt
📅 ${_fmtDate(r.created_at)} ${r.bestaetigt ? '
Bestätigt' : ''} `); marker.on('click', () => _openDetail(r)); _markers.push(marker); }); } // ---------------------------------------------------------- // LISTE // ---------------------------------------------------------- function _renderList() { const listEl = document.getElementById('poison-list'); if (!listEl) return; if (_reports.length === 0) { listEl.innerHTML = UI.emptyState({ icon : UI.icon('check-circle'), title : 'Alles sicher', text : 'In deiner Nähe (10 km) gibt es aktuell keine Giftköder-Meldungen.', action: ``, }); listEl.querySelector('#poison-empty-report') ?.addEventListener('click', _showReportForm); return; } listEl.innerHTML = _reports.map(r => _reportCard(r)).join(''); listEl.querySelectorAll('[data-poison-id]').forEach(card => { card.addEventListener('click', () => { const r = _reports.find(x => x.id === parseInt(card.dataset.poisonId)); if (r) _openDetail(r); }); }); listEl.querySelectorAll('.poison-note-btn').forEach(btn => { btn.addEventListener('click', e => { e.stopPropagation(); const id = parseInt(btn.dataset.poisonNoteId); _openNoteModal('poison', id, 'Giftköder-Meldung ' + id, null); }); }); } function _reportCard(r) { const typ = TYPEN[r.typ] || TYPEN.unbekannt; const distStr = r.distanz_m < 1000 ? `${r.distanz_m} m` : `${(r.distanz_m / 1000).toFixed(1)} km`; return `
${UI.icon(typ.icon)}
${typ.label} ${r.bestaetigt ? ' Bestätigt' : ''} ${distStr}
${r.beschreibung ? `

${UI.escape(r.beschreibung.slice(0, 120))}${r.beschreibung.length > 120 ? '…' : ''}

` : ''}
Gemeldet ${_fmtDate(r.created_at)} · läuft ab ${_fmtDate(r.expires_at)}
${_appState.user ? `
` : ''} ${r.foto_url ? `Foto` : ''}
`; } // ---------------------------------------------------------- // DETAIL-MODAL // ---------------------------------------------------------- function _openDetail(r) { const typ = TYPEN[r.typ] || TYPEN.unbekannt; const isOwnEntry = _appState.user && _appState.user.id === r.user_id; const isAdmin = _appState.user?.rolle === 'admin'; const body = ` ${r.foto_url ? `Foto` : ''}
${UI.icon(typ.icon)} ${typ.label} ${r.bestaetigt ? ' Bestätigt' : ''}
${r.beschreibung ? `

${UI.escape(r.beschreibung)}

` : ''}
📍 ${r.lat.toFixed(5)}, ${r.lon.toFixed(5)}
📅 Gemeldet: ${_fmtDate(r.created_at)}
⏰ Läuft ab: ${_fmtDate(r.expires_at)}
${r.melder_name ? `
👤 Gemeldet von: ${UI.escape(r.melder_name)}
` : ''}
${!r.bestaetigt && _appState.user && !isOwnEntry ? `` : ''} ${isOwnEntry || isAdmin ? `` : ''}
`; UI.modal.open({ title: `${UI.icon(typ.icon)} Giftköder-Meldung`, body }); document.getElementById('detail-confirm')?.addEventListener('click', async () => { try { const updated = await API.poison.confirm(r.id); const idx = _reports.findIndex(x => x.id === r.id); if (idx !== -1) _reports[idx] = { ..._reports[idx], ...updated }; UI.toast.success('Meldung bestätigt. Danke!'); UI.modal.close(); _renderMarkers(); _renderList(); } catch (err) { UI.toast.error(err.message || 'Fehler beim Bestätigen.'); } }); document.getElementById('detail-show-map')?.addEventListener('click', () => { UI.modal.close(); if (_map) { _map.setView([r.lat, r.lon], 16); document.getElementById('poison-map') ?.scrollIntoView({ behavior: 'smooth', block: 'start' }); // Popup des Markers öffnen const marker = _markers[_reports.findIndex(x => x.id === r.id)]; marker?.openPopup(); } }); document.getElementById('detail-resolve')?.addEventListener('click', () => { _showResolveDialog(r); }); } // ---------------------------------------------------------- // ERLEDIGT-DIALOG — mit Grundauswahl für KI-Analyse // ---------------------------------------------------------- function _showResolveDialog(r) { UI.modal.open({ title: '✔ Meldung als erledigt markieren', body: `

Die Meldung wird inaktiv gesetzt. Die Daten bleiben für spätere Musteranalysen gespeichert.

`, footer: ` `, }); document.getElementById('resolve-cancel') ?.addEventListener('click', UI.modal.close); document.getElementById('resolve-confirm') ?.addEventListener('click', async () => { const grund = document.getElementById('resolve-grund')?.value || 'beseitigt'; const btn = document.getElementById('resolve-confirm'); await UI.asyncButton(btn, async () => { await API.poison.resolve(r.id, { grund }); _reports = _reports.filter(x => x.id !== r.id); _renderMarkers(); _renderList(); _updateBadge(_reports.length); App.checkNearbyAlerts(); UI.modal.close(); UI.toast.success('Meldung als erledigt markiert.'); }); }); } // ---------------------------------------------------------- // MELDE-FORMULAR // ---------------------------------------------------------- function _showReportForm() { if (!_appState.user) { UI.toast.warning('Bitte zuerst anmelden, um eine Meldung abzuschicken.'); App.navigate('settings'); return; } const typOpts = Object.entries(TYPEN) .map(([val, { label }]) => ``) .join(''); const body = `
`; const footer = ` `; UI.modal.open({ title: '⚠️ Giftköder melden', body, footer }); // Location-Picker initialisieren + ggf. bekannten Standort vorausfüllen const _picker = UI.locationPicker({ containerId: 'poison-location-picker' }); if (_userPos) { _picker.setValue(_userPos.lat, _userPos.lon, null); } // Foto-Vorschau const photoInput = document.querySelector('#poison-form [name="photo"]'); const photoPreview = document.getElementById('pf-photo-preview'); if (photoInput && photoPreview) { UI.setupPhotoPreview(photoInput, photoPreview); photoInput.addEventListener('change', () => { photoPreview.style.display = photoInput.files[0] ? 'block' : 'none'; }); } document.getElementById('pf-cancel') ?.addEventListener('click', UI.modal.close); // Formular absenden document.getElementById('poison-form')?.addEventListener('submit', async e => { e.preventDefault(); const submitBtn = document.querySelector('[form="poison-form"][type="submit"]') || e.target.querySelector('[type="submit"]'); const fd = UI.formData(e.target); const loc = _picker.getValue(); if (!loc.lat || !loc.lon) { UI.toast.warning('Bitte zuerst den GPS-Standort ermitteln (📍).'); return; } await UI.asyncButton(submitBtn, async () => { const payload = { lat : loc.lat, lon : loc.lon, typ : fd.typ, beschreibung : fd.beschreibung || null, }; const created = await API.poison.report(payload); // SW hat Request in Queue gelegt (offline) if (created?._queued) { _showPoisonThanks(true); return; } // Foto hochladen if (photoInput?.files[0]) { try { const formData = new FormData(); formData.append('file', photoInput.files[0]); const media = await API.poison.uploadPhoto(created.id, formData); created.foto_url = media.foto_url; } catch { UI.toast.warning('Meldung erstellt — Foto konnte nicht hochgeladen werden.'); } } // Distanz client-seitig berechnen if (loc.lat && loc.lon) _userPos = { lat: loc.lat, lon: loc.lon }; created.distanz_m = _userPos ? Math.round(_haversine(_userPos.lat, _userPos.lon, created.lat, created.lon)) : 0; _reports.unshift(created); _renderMarkers(); _renderList(); _updateBadge(_reports.length); App.checkNearbyAlerts(); App.callModule('map', 'refresh'); _showPoisonThanks(false); }); }); } // ---------------------------------------------------------- // DANKE-OVERLAY nach Giftköder-Meldung // ---------------------------------------------------------- function _showPoisonThanks(isQueued) { const offlineNote = isQueued ? `

Wird synchronisiert sobald du wieder online bist.

` : ''; UI.modal.open({ title: 'Danke für deine Meldung!', body: `

Wir kümmern uns darum und melden es den anderen Nutzern in der Umgebung.

Vielen Dank, dass du die Community schützt!

${offlineNote}
`, footer: ``, }); document.getElementById('poison-thanks-ok')?.addEventListener('click', UI.modal.close); setTimeout(() => UI.modal.close(), 5000); } // ---------------------------------------------------------- // BADGE (Sidebar + Bottom-Nav) // ---------------------------------------------------------- function _updateBadge(count) { const b1 = document.getElementById('poison-badge'); const b2 = document.getElementById('poison-nav-badge'); if (b1) { b1.textContent = count; b1.style.display = count > 0 ? '' : 'none'; } if (b2) { b2.textContent = count; b2.classList.toggle('hidden', count === 0); } } // ---------------------------------------------------------- // HELPER // ---------------------------------------------------------- // Haversine client-seitig (für frisch gemeldete Einträge) function _haversine(lat1, lon1, lat2, lon2) { const R = 6_371_000; const p1 = lat1 * Math.PI / 180; const p2 = lat2 * Math.PI / 180; const dp = (lat2 - lat1) * Math.PI / 180; const dl = (lon2 - lon1) * Math.PI / 180; const a = Math.sin(dp / 2) ** 2 + Math.cos(p1) * Math.cos(p2) * Math.sin(dl / 2) ** 2; return 2 * R * Math.asin(Math.sqrt(a)); } // _fmtDate: bewusst lokal behalten — UI.time.format() liefert langen Monats-Namen // und behandelt kein SQLite-Leerzeichen-Format ("2026-04-12 00:00:00") function _fmtDate(isoStr) { if (!isoStr) return ''; // SQLite speichert "2026-04-12T00:00:00" oder "2026-04-12 00:00:00" const d = new Date(isoStr.replace(' ', 'T')); return d.toLocaleDateString('de-DE', { day: '2-digit', month: '2-digit', year: 'numeric' }); } // ---------------------------------------------------------- // NOTIZ-MODAL // ---------------------------------------------------------- async function _openNoteModal(parentType, parentId, parentLabel, locationName) { document.getElementById('by-note-modal')?.remove(); const overlay = document.createElement('div'); overlay.id = 'by-note-modal'; overlay.style.cssText = 'position:fixed;inset:0;z-index:2000;background:rgba(0,0,0,.55);display:flex;align-items:flex-end;justify-content:center'; overlay.innerHTML = `
Notiz
${UI.escape(parentLabel)}
`; document.body.appendChild(overlay); const textarea = document.getElementById('by-note-text'); const saveBtn = document.getElementById('by-note-save'); const cancelBtn = document.getElementById('by-note-cancel'); const closeBtn = document.getElementById('by-note-close'); let existingNoteId = null; try { const existing = await API.notes.get(parentType, String(parentId)); if (existing?.id) { existingNoteId = existing.id; textarea.value = existing.text || ''; } } catch (_) { /* keine Notiz vorhanden — ok */ } setTimeout(() => textarea.focus(), 100); const _close = () => overlay.remove(); closeBtn.addEventListener('click', _close); cancelBtn.addEventListener('click', _close); overlay.addEventListener('click', e => { if (e.target === overlay) _close(); }); document.getElementById('by-note-form').addEventListener('submit', async e => { e.preventDefault(); const text = textarea.value.trim(); UI.setLoading(saveBtn, true); try { const payload = { text, parent_label: parentLabel, location_name: locationName }; if (existingNoteId) { await API.notes.update(existingNoteId, 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.'); UI.setLoading(saveBtn, false); } }); } // ---------------------------------------------------------- // PUBLIC // ---------------------------------------------------------- return { init, refresh, openNew, openDetail: _openDetail }; })();