/* ============================================================ BAN YARO — Giftköder-Alarm (Sprint 2) Seiten-Modul: Leaflet-Karte + Meldungsliste + Melden-Formular. ============================================================ */ window.Page_poison = (() => { // ---------------------------------------------------------- // MODUL-STATE // ---------------------------------------------------------- let _container = null; let _appState = null; let _map = null; let _markers = []; let _userMarker = null; let _reports = []; let _userPos = null; let _leafletLoaded = false; const TYPEN = { unbekannt: { label: 'Unbekannt', icon: '❓', color: '#e67e22' }, koeoder: { label: 'Köder', icon: '🎣', color: '#e74c3c' }, vergiftet: { label: 'Vergiftetes Tier', icon: '☠️', color: '#8e44ad' }, chemikalie: { label: 'Chemikalie', icon: '⚗️', color: '#c0392b' }, andere: { label: 'Andere Gefahr', icon: '⚠️', 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

Standort wird ermittelt…

`; document.getElementById('poison-btn-locate') ?.addEventListener('click', _locateUser); document.getElementById('poison-btn-report') ?.addEventListener('click', _showReportForm); await _loadLeaflet(); _initMap(); // Leaflet muss nach CSS-Load die Container-Größe neu berechnen setTimeout(() => _map?.invalidateSize(), 100); await _locateAndLoad(); } // ---------------------------------------------------------- // LEAFLET DYNAMISCH LADEN // ---------------------------------------------------------- async function _loadLeaflet() { if (_leafletLoaded || window.L) { _leafletLoaded = true; return; } // CSS lokal (kein CDN) await new Promise(resolve => { if (document.querySelector('link[href*="leaflet"]')) { resolve(); return; } const link = document.createElement('link'); link.rel = 'stylesheet'; link.href = '/css/leaflet.css'; link.onload = resolve; link.onerror = resolve; document.head.appendChild(link); }); // JS lokal (kein CDN) await new Promise((resolve, reject) => { if (document.querySelector('script[src*="leaflet"]')) { resolve(); return; } const s = document.createElement('script'); s.src = '/js/leaflet.js'; s.onload = resolve; s.onerror = reject; document.head.appendChild(s); }); _leafletLoaded = true; } // ---------------------------------------------------------- // 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); _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 { 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 icon = L.divIcon({ className : '', html : `
${typ.icon}
`, iconSize : [34, 34], iconAnchor : [17, 17], }); const distStr = r.distanz_m < 1000 ? `${r.distanz_m} m` : `${(r.distanz_m / 1000).toFixed(1)} km`; const marker = L.marker([r.lat, r.lon], { icon }) .addTo(_map) .bindPopup(` ${typ.icon} ${typ.label}
${r.beschreibung ? _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 : '✅', 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); }); }); } 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 `
${typ.icon}
${typ.label} ${r.bestaetigt ? '✅ Bestätigt' : ''} ${distStr}
${r.beschreibung ? `

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

` : ''}
Gemeldet ${_fmtDate(r.created_at)} · läuft ab ${_fmtDate(r.expires_at)}
${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` : ''}
${typ.icon} ${typ.label} ${r.bestaetigt ? '✅ Bestätigt' : ''}
${r.beschreibung ? `

${_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: ${_escape(r.melder_name)}
` : ''}
${!r.bestaetigt && _appState.user && !isOwnEntry ? `` : ''} ${isOwnEntry || isAdmin ? `` : ''}
`; UI.modal.open({ title: `${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', async () => { const ok = await UI.modal.confirm({ title : 'Meldung als erledigt markieren?', message: 'Das Problem wurde beseitigt oder die Meldung war fehlerhaft.', confirmText: 'Erledigt markieren', }); if (!ok) return; try { await API.poison.resolve(r.id); _reports = _reports.filter(x => x.id !== r.id); _markers.splice(_reports.length, 1); // cleanup (wird bei _renderMarkers neu gesetzt) _renderMarkers(); _renderList(); _updateBadge(_reports.length); UI.toast.success('Meldung als erledigt markiert.'); } catch (err) { UI.toast.error(err.message || 'Fehler.'); } }); } // ---------------------------------------------------------- // 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, { icon, label }]) => ``) .join(''); const body = `
${_userPos ? '✅ Aktueller Standort vorausgefüllt' : 'GPS-Button drücken um Standort zu ermitteln'}
`; UI.modal.open({ title: '⚠️ Giftköder melden', body }); // Standort vorausfüllen wenn bekannt if (_userPos) { document.getElementById('pf-lat').value = _userPos.lat; document.getElementById('pf-lon').value = _userPos.lon; document.getElementById('pf-lat-disp').value = _userPos.lat.toFixed(6); document.getElementById('pf-lon-disp').value = _userPos.lon.toFixed(6); } // GPS-Button document.getElementById('pf-gps-btn')?.addEventListener('click', async () => { const btn = document.getElementById('pf-gps-btn'); UI.setLoading(btn, true); try { const pos = await API.getLocation({ timeout: 10000, enableHighAccuracy: true }); document.getElementById('pf-lat').value = pos.lat; document.getElementById('pf-lon').value = pos.lon; document.getElementById('pf-lat-disp').value = pos.lat.toFixed(6); document.getElementById('pf-lon-disp').value = pos.lon.toFixed(6); document.getElementById('pf-gps-hint').textContent = '✅ Standort aktualisiert'; _userPos = pos; } catch { UI.toast.error('GPS-Standort konnte nicht ermittelt werden.'); } UI.setLoading(btn, false); }); // 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 = e.target.querySelector('[type="submit"]'); const fd = UI.formData(e.target); if (!fd.lat || !fd.lon) { UI.toast.warning('Bitte zuerst den GPS-Standort ermitteln (📍).'); return; } await UI.asyncButton(submitBtn, async () => { const payload = { lat : parseFloat(fd.lat), lon : parseFloat(fd.lon), typ : fd.typ, beschreibung : fd.beschreibung || null, }; const created = await API.poison.report(payload); // 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 (für sofortige Anzeige) created.distanz_m = _userPos ? Math.round(_haversine(_userPos.lat, _userPos.lon, created.lat, created.lon)) : 0; _reports.unshift(created); _renderMarkers(); _renderList(); _updateBadge(_reports.length); UI.toast.success('Giftköder gemeldet! Danke für die Warnung.'); UI.modal.close(); }); }); } // ---------------------------------------------------------- // 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)); } 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' }); } function _escape(str) { if (!str) return ''; return str .replace(/&/g, '&') .replace(//g, '>') .replace(/"/g, '"'); } // ---------------------------------------------------------- // PUBLIC // ---------------------------------------------------------- return { init, refresh, openNew }; })();