/* ============================================================
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;
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
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);
_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 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
? `

`
: ''}
`;
}
// ----------------------------------------------------------
// 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
? `
`
: ''}
${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);
// 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)
// _userPos aktualisieren falls Picker neuen Standort geliefert hat
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');
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));
}
// _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 };
})();