/* ============================================================ BAN YARO — Verlorener Hund (Sprint 11) Seiten-Modul: Leaflet-Karte + Meldungsliste + Melden-Formular. ============================================================ */ window.Page_lost = (() => { // ---------------------------------------------------------- // MODUL-STATE // ---------------------------------------------------------- let _container = null; let _appState = null; let _map = null; let _markers = []; let _userMarker = null; let _reports = []; let _userPos = null; let _leafletLoaded = false; // ---------------------------------------------------------- // 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('lost-btn-locate') ?.addEventListener('click', _locateUser); document.getElementById('lost-btn-report') ?.addEventListener('click', _showReportForm); await _loadLeaflet(); _initMap(); setTimeout(() => _map?.invalidateSize(), 100); await _locateAndLoad(); } // ---------------------------------------------------------- // LEAFLET DYNAMISCH LADEN // ---------------------------------------------------------- async function _loadLeaflet() { if (_leafletLoaded || window.L) { _leafletLoaded = true; return; } 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); }); 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('lost-map'); if (!mapEl || !window.L || _map) return; _map = L.map('lost-map', { zoomControl: true, attributionControl: false }) .setView([51.1657, 10.4515], 6); 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('lost-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('lost-info'); if (!_userPos) { _reports = []; _renderHeld(); _renderList(); if (infoEl) infoEl.textContent = 'Standort unbekannt — bitte Standort freigeben (📍 Mein Standort).'; return; } try { _reports = await API.lost.list(_userPos.lat, _userPos.lon, 25); _renderMarkers(); _renderHeld(); _renderList(); _updateBadge(_reports.length); if (infoEl) { infoEl.textContent = _reports.length > 0 ? `${_reports.length} vermisste${_reports.length !== 1 ? 'r Hund' : 'r Hund'} im Umkreis von 25 km` : 'Keine vermissten Hunde in deiner Nähe (25 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 icon = L.divIcon({ className : '', html : `
🐕
`, iconSize : [34, 34], iconAnchor : [17, 17], }); const distStr = r.distanz_m !== undefined ? (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(` 🔍 ${_escape(r.name)}
${r.rasse ? _escape(r.rasse) + '
' : ''} ${distStr ? `📍 ${distStr} entfernt
` : ''} 📅 ${_fmtDate(r.created_at)} `); marker.on('click', () => _openDetail(r)); _markers.push(marker); }); } // ---------------------------------------------------------- // HELD DES TAGES // ---------------------------------------------------------- function _renderHeld() { const heldEl = document.getElementById('lost-held'); if (!heldEl) return; // Letzter gefundener Hund (is_active=0, gefunden_at gesetzt) — wir laden // sie nicht separat, daher nutzen wir die aktiven; für "Held" einen eigenen // API-Call wäre übertrieben. Stattdessen zeigen wir es nur wenn die Liste // kommt und wir einen kürzlich-gefundenen kennen. Wir überspringen hier // den separaten Endpunkt und blenden die Sektion aus wenn leer. heldEl.innerHTML = ''; } // ---------------------------------------------------------- // LISTE // ---------------------------------------------------------- function _renderList() { const listEl = document.getElementById('lost-list'); if (!listEl) return; if (_reports.length === 0) { listEl.innerHTML = _emptyState( 'magnifying-glass', 'Aktuell kein vermisster Hund gemeldet', 'Wenn ein Hund vermisst wird, erscheint die Meldung hier. Du kannst auch selbst eine Meldung erstellen.', `` ); listEl.querySelector('#lost-empty-report') ?.addEventListener('click', _showReportForm); return; } listEl.innerHTML = _reports.map(r => _reportCard(r)).join(''); listEl.querySelectorAll('[data-lost-id]').forEach(card => { card.addEventListener('click', () => { const r = _reports.find(x => x.id === parseInt(card.dataset.lostId)); if (r) _openDetail(r); }); }); listEl.querySelectorAll('.lost-note-btn').forEach(btn => { btn.addEventListener('click', e => { e.stopPropagation(); const id = parseInt(btn.dataset.lostNoteId); const name = btn.dataset.lostNoteName || ''; _openNoteModal('lost', id, name, null); }); }); } function _reportCard(r) { const isOwn = _appState.user && _appState.user.id === r.user_id; const distStr = r.distanz_m !== undefined ? (r.distanz_m < 1000 ? `${r.distanz_m} m` : `${(r.distanz_m / 1000).toFixed(1)} km`) : ''; return `
${r.foto_url ? `Foto` : `
🐕
`}
${_escape(r.name)} ${r.rasse ? `${_escape(r.rasse)}` : ''} ${isOwn ? 'Meine Meldung' : ''} ${distStr ? ` 📍 ${distStr} ` : ''}

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

Gemeldet ${_fmtDate(r.created_at)} ${r.melder_name ? '· ' + _escape(r.melder_name.split(' ')[0]) : ''}
${_appState.user ? `
` : ''}
`; } // ---------------------------------------------------------- // DETAIL-MODAL // ---------------------------------------------------------- function _openDetail(r) { const isOwn = _appState.user && _appState.user.id === r.user_id; const isAdmin = _appState.user?.rolle === 'admin'; const distStr = r.distanz_m !== undefined ? (r.distanz_m < 1000 ? `${r.distanz_m} m` : `${(r.distanz_m / 1000).toFixed(1)} km`) : ''; const body = ` ${r.foto_url ? `Foto` : ''}
🐕 ${_escape(r.name)} ${r.rasse ? `${_escape(r.rasse)}` : ''}

${_escape(r.beschreibung)}

📍 ${r.lat.toFixed(5)}, ${r.lon.toFixed(5)}${distStr ? ' (' + distStr + ' entfernt)' : ''}
📅 Gemeldet: ${_fmtDate(r.created_at)}
${r.melder_name ? `
👤 Gemeldet von: ${_escape(r.melder_name.split(' ')[0])}
` : ''}
${isOwn || isAdmin ? `` : ''} ${isOwn || isAdmin ? `` : ''}
`; UI.modal.open({ title: `🔍 ${_escape(r.name)} wird vermisst`, body }); document.getElementById('detail-lost-map')?.addEventListener('click', () => { UI.modal.close(); if (_map) { _map.setView([r.lat, r.lon], 16); document.getElementById('lost-map') ?.scrollIntoView({ behavior: 'smooth', block: 'start' }); const marker = _markers[_reports.findIndex(x => x.id === r.id)]; marker?.openPopup(); } }); document.getElementById('detail-lost-found')?.addEventListener('click', () => { _showFoundDialog(r); }); document.getElementById('detail-lost-delete')?.addEventListener('click', async () => { if (!confirm(`Meldung für ${r.name} wirklich löschen?`)) return; try { await API.lost.delete(r.id); _reports = _reports.filter(x => x.id !== r.id); _renderMarkers(); _renderList(); _updateBadge(_reports.length); UI.modal.close(); UI.toast.success('Meldung gelöscht.'); } catch (err) { UI.toast.error(err.message || 'Fehler beim Löschen.'); } }); } // ---------------------------------------------------------- // GEFUNDEN-DIALOG // ---------------------------------------------------------- function _showFoundDialog(r) { UI.modal.open({ title: `🎉 ${_escape(r.name)} gefunden?`, body: `

Wurde ${_escape(r.name)} wiedergefunden? Die Meldung wird als abgeschlossen markiert und aus der Liste entfernt.

`, footer: ` `, }); document.getElementById('found-cancel') ?.addEventListener('click', UI.modal.close); document.getElementById('found-confirm')?.addEventListener('click', async () => { const btn = document.getElementById('found-confirm'); await UI.asyncButton(btn, async () => { await API.lost.markFound(r.id); _reports = _reports.filter(x => x.id !== r.id); _renderMarkers(); _renderList(); _updateBadge(_reports.length); UI.modal.close(); UI.toast.success(`${r.name} ist wieder da! 🎉`); }); }); } // ---------------------------------------------------------- // MELDE-FORMULAR // ---------------------------------------------------------- function _showReportForm() { if (!_appState.user) { UI.toast.warning('Bitte zuerst anmelden, um eine Meldung abzuschicken.'); App.navigate('settings'); return; } // Eigene registrierte Hunde für Dropdown const dogs = _appState.dogs || []; const dogOpts = dogs.length > 0 ? `` + dogs.map(d => ``).join('') : ''; const body = `
${dogs.length > 0 ? `
` : ''}
${_userPos ? '✅ Aktueller Standort vorausgefüllt' : 'GPS-Button drücken um Standort zu ermitteln'}
`; const footer = ` `; UI.modal.open({ title: '🔍 Hund vermisst melden', body, footer }); // Standort vorausfüllen if (_userPos) { document.getElementById('lf-lat').value = _userPos.lat; document.getElementById('lf-lon').value = _userPos.lon; document.getElementById('lf-lat-disp').value = _userPos.lat.toFixed(6); document.getElementById('lf-lon-disp').value = _userPos.lon.toFixed(6); } // Wenn registrierter Hund gewählt → Name+Rasse vorausfüllen document.getElementById('lf-dog-select')?.addEventListener('change', e => { const dogId = parseInt(e.target.value); const dog = dogs.find(d => d.id === dogId); if (dog) { document.getElementById('lf-name').value = dog.name; const rasseInput = document.querySelector('#lost-form [name="rasse"]'); if (rasseInput && dog.rasse) rasseInput.value = dog.rasse; } }); // GPS-Button document.getElementById('lf-gps-btn')?.addEventListener('click', async () => { const btn = document.getElementById('lf-gps-btn'); UI.setLoading(btn, true); try { const pos = await API.getLocation({ timeout: 10000, enableHighAccuracy: true }); document.getElementById('lf-lat').value = pos.lat; document.getElementById('lf-lon').value = pos.lon; document.getElementById('lf-lat-disp').value = pos.lat.toFixed(6); document.getElementById('lf-lon-disp').value = pos.lon.toFixed(6); document.getElementById('lf-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('#lost-form [name="photo"]'); const photoPreview = document.getElementById('lf-photo-preview'); if (photoInput && photoPreview) { UI.setupPhotoPreview(photoInput, photoPreview); photoInput.addEventListener('change', () => { photoPreview.style.display = photoInput.files[0] ? 'block' : 'none'; }); } document.getElementById('lf-cancel') ?.addEventListener('click', UI.modal.close); // Formular absenden document.getElementById('lost-form')?.addEventListener('submit', async e => { e.preventDefault(); const submitBtn = document.querySelector('[form="lost-form"][type="submit"]') || 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; } if (!fd.name?.trim()) { UI.toast.warning('Bitte den Namen des Hundes eingeben.'); return; } await UI.asyncButton(submitBtn, async () => { const payload = { name : fd.name.trim(), rasse : fd.rasse?.trim() || null, beschreibung : fd.beschreibung?.trim() || '', lat : parseFloat(fd.lat), lon : parseFloat(fd.lon), dog_id : fd.dog_id ? parseInt(fd.dog_id) : null, }; const created = await API.lost.report(payload); // Foto hochladen if (photoInput?.files[0]) { try { const formData = new FormData(); formData.append('file', photoInput.files[0]); const media = await API.lost.uploadFoto(created.id, formData); created.foto_url = media.foto_url; } catch { UI.toast.warning('Meldung erstellt — Foto konnte nicht hochgeladen werden.'); } } // Distanz client-seitig berechnen 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('Hund als vermisst gemeldet. Wir drücken die Daumen!'); UI.modal.close(); }); }); } // ---------------------------------------------------------- // BADGE // ---------------------------------------------------------- function _updateBadge(count) { const b = document.getElementById('lost-badge'); if (b) { b.textContent = count; b.style.display = count > 0 ? '' : 'none'; } } // ---------------------------------------------------------- // HELPER // ---------------------------------------------------------- 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 ''; 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 String(str) .replace(/&/g, '&') .replace(//g, '>') .replace(/"/g, '"'); } function _emptyState(icon, title, text, cta = '') { return `
${title}
${text ? `

${text}

` : ''} ${cta ? `
${cta}
` : ''}
`; } // ---------------------------------------------------------- // 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
${_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 }; })();