/* ============================================================ BAN YARO — Verlorener Hund (Sprint 11) Seiten-Modul: Leaflet-Karte + Meldungsliste + Melden-Formular. ============================================================ */ window.Page_lost = (() => { // ---------------------------------------------------------- // OFFLINE-CACHE // ---------------------------------------------------------- const _CACHE_KEY = 'by_lost_cache'; const _PENDING_KEY = 'by_lost_pending'; function _getPending() { try { return JSON.parse(localStorage.getItem(_PENDING_KEY) || '[]'); } catch { return []; } } function _setPending(list) { try { localStorage.setItem(_PENDING_KEY, JSON.stringify(list)); } catch {} } function _addPending(data) { const list = _getPending(); const entry = { ...data, id: `pending_${Date.now()}`, _isPending: true, created_at: new Date().toISOString() }; list.push(entry); _setPending(list); return entry; } async function _syncPending() { if (!navigator.onLine) return; const list = _getPending(); if (!list.length) return; let ok = 0; for (const item of [...list]) { try { const { id: _pid, _isPending, ...payload } = item; await API.lost.report(payload); _setPending(_getPending().filter(x => x.id !== item.id)); ok++; } catch {} } if (ok > 0) { UI.toast.success(`${ok} Meldung(en) synchronisiert.`); _loadReports(); } } window.addEventListener('online', _syncPending); // ---------------------------------------------------------- // MODUL-STATE // ---------------------------------------------------------- let _container = null; let _appState = null; let _map = null; let _markers = []; let _userMarker = null; let _reports = []; let _userPos = null; let _leafletLoaded = false; let _stylesInjected = false; function _injectStyles() { if (_stylesInjected) return; _stylesInjected = true; const s = document.createElement('style'); s.textContent = ` @keyframes by-lost-pulse-r { 0%,100% { box-shadow: 0 0 0 0 rgba(231,76,60,.55), 0 2px 6px rgba(0,0,0,.3); } 50% { box-shadow: 0 0 0 11px rgba(231,76,60,0), 0 2px 6px rgba(0,0,0,.3); } } @keyframes by-lost-pulse-p { 0%,100% { box-shadow: 0 0 0 0 rgba(217,119,6,.55), 0 2px 6px rgba(0,0,0,.3); } 50% { box-shadow: 0 0 0 11px rgba(217,119,6,0), 0 2px 6px rgba(0,0,0,.3); } } `; document.head.appendChild(s); } // ---------------------------------------------------------- // 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 = `

Standort wird ermittelt…

`; document.getElementById('lost-btn-locate') ?.addEventListener('click', _locateUser); document.getElementById('lost-btn-report') ?.addEventListener('click', _showReportForm); await _initMap(); setTimeout(() => _map?.invalidateSize(), 100); await _locateAndLoad(); } // ---------------------------------------------------------- // KARTE INITIALISIEREN (lädt Leaflet via UI.map.create) // ---------------------------------------------------------- async function _initMap() { _injectStyles(); const mapEl = document.getElementById('lost-map'); if (!mapEl || _map) return; _map = await UI.map.create('lost-map', { center: [51.1657, 10.4515], zoom: 6, zoomControl: true, attributionControl: false, }); _leafletLoaded = true; } // ---------------------------------------------------------- // 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 || !_userPos) return; if (_userMarker) _map.removeLayer(_userMarker); _userMarker = UI.map.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 { const fetched = await API.lost.list(_userPos.lat, _userPos.lon, 25); try { localStorage.setItem(_CACHE_KEY, JSON.stringify({ ts: Date.now(), data: fetched })); } catch {} // Remove pending items already on the server (race: sync completed during fetch) const rawPending = _getPending(); const dedupedPending = rawPending.filter(p => !fetched.some(f => f.name === p.name && Math.abs(f.lat - p.lat) < 0.0001 && Math.abs(f.lon - p.lon) < 0.0001) ); if (dedupedPending.length < rawPending.length) _setPending(dedupedPending); const pending = dedupedPending.map(p => ({ ...p, distanz_m: _haversine(_userPos.lat, _userPos.lon, p.lat, p.lon), })); _reports = [...pending, ...fetched]; _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 { const offline_pending = _getPending().map(p => ({ ...p, distanz_m: _haversine(_userPos.lat, _userPos.lon, p.lat, p.lon), })); // Offline-Region-Snapshot (Offline-Karten speichern vermisste Hunde mit) dazu mergen — // deckt vorab gespeicherte Gegenden ab, die der localStorage-Stand nicht kennt. let regionLost = []; try { if (window.MapOffline?.alerts) { regionLost = await MapOffline.alerts('lost', { south: _userPos.lat - 0.3, north: _userPos.lat + 0.3, west: _userPos.lon - 0.45, east: _userPos.lon + 0.45, }); } } catch {} try { const raw = localStorage.getItem(_CACHE_KEY); if (raw) { const cached = JSON.parse(raw).data || []; const seen = new Set(cached.map(r => r.id)); regionLost.filter(r => !seen.has(r.id)).forEach(r => cached.push({ ...r, distanz_m: _haversine(_userPos.lat, _userPos.lon, r.lat, r.lon), })); _reports = [...offline_pending, ...cached]; _renderMarkers(); _renderHeld(); _renderList(); _updateBadge(_reports.length); if (infoEl) infoEl.textContent = 'Offline — zeige zuletzt geladene Meldungen.'; return; } } catch {} // Kein localStorage-Stand → wenigstens Pending + Region-Snapshot zeigen _reports = [...offline_pending, ...regionLost.map(r => ({ ...r, distanz_m: _haversine(_userPos.lat, _userPos.lon, r.lat, r.lon), }))]; if (_reports.length) { _renderMarkers(); _renderHeld(); _renderList(); _updateBadge(_reports.length); if (infoEl) infoEl.textContent = 'Offline — zeige gespeicherte Meldungen.'; return; } UI.toast.error('Meldungen konnten nicht geladen werden.'); } } // ---------------------------------------------------------- // KARTEN-MARKER // ---------------------------------------------------------- function _renderMarkers() { if (!_map) return; _markers.forEach(m => _map.removeLayer(m)); _markers = []; _reports.forEach(r => { const dotColor = r._isPending ? '#d97706' : '#e74c3c'; const anim = r._isPending ? 'by-lost-pulse-p' : 'by-lost-pulse-r'; const html = `
🐕
`; const distStr = r.distanz_m !== undefined ? (r.distanz_m < 1000 ? `${Math.round(r.distanz_m)} m` : `${(r.distanz_m / 1000).toFixed(1)} km`) : ''; const marker = UI.map.svgMarker(r.lat, r.lon, html, { size: 34, anchorY: 17 }) .addTo(_map) .bindPopup(` 🔍 ${UI.escape(r.name)}
${r.rasse ? UI.escape(r.rasse) + '
' : ''} ${distStr ? `📍 ${distStr} entfernt
` : ''} ${r._isPending ? '⏳ Sync ausstehend
' : ''} 📅 ${_fmtDate(r.created_at)} `); if (!r._isPending) 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 id = card.dataset.lostId; const r = _reports.find(x => String(x.id) === id && !x._isPending); if (r) _openDetail(r); }); }); listEl.querySelectorAll('.lost-discard-btn').forEach(btn => { btn.addEventListener('click', e => { e.stopPropagation(); const pid = btn.dataset.pendingId; _setPending(_getPending().filter(x => x.id !== pid)); _loadReports(); }); }); listEl.querySelectorAll('.lost-note-btn').forEach(btn => { btn.addEventListener('click', e => { e.stopPropagation(); const id = parseInt(btn.dataset.lostNoteId); const name = btn.dataset.lostNoteName || ''; UI.noteModal('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` : `
🐕
`}
${UI.escape(r.name)} ${r.rasse ? `${UI.escape(r.rasse)}` : ''} ${isOwn ? 'Meine Meldung' : ''} ${distStr ? ` 📍 ${distStr} ` : ''}

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

Gemeldet ${_fmtDate(r.created_at)} ${r.melder_name ? '· ' + UI.escape(r.melder_name.split(' ')[0]) : ''}
${r._isPending ? `
⏳ Sync ausstehend
` : (_appState.user ? `
` : '')}
`; } // ---------------------------------------------------------- // DETAIL-MODAL // ---------------------------------------------------------- function _openDetail(r) { if (r._isPending) return; // Pending-Einträge haben keine Server-ID 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` : ''}
🐕 ${UI.escape(r.name)} ${r.rasse ? `${UI.escape(r.rasse)}` : ''}

${UI.escape(r.beschreibung)}

📍 ${r.lat.toFixed(5)}, ${r.lon.toFixed(5)}${distStr ? ' (' + distStr + ' entfernt)' : ''}
📅 Gemeldet: ${_fmtDate(r.created_at)}
${r.melder_name ? `
👤 Gemeldet von: ${UI.escape(r.melder_name.split(' ')[0])}
` : ''}
${isOwn || isAdmin ? `` : ''} ${isOwn || isAdmin ? `` : ''}
`; UI.modal.open({ title: `🔍 ${UI.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: `🎉 ${UI.escape(r.name)} gefunden?`, body: `

Wurde ${UI.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, client_time : API.clientNow(), }; let created; try { created = await API.lost.report(payload); } catch (netErr) { // Netzwerkfehler (TypeError = fetch failed) → offline speichern if (netErr instanceof TypeError || !navigator.onLine) { const pending = _addPending(payload); pending.distanz_m = _userPos ? Math.round(_haversine(_userPos.lat, _userPos.lon, pending.lat, pending.lon)) : 0; UI.modal.close(); UI.toast.success('Offline gespeichert — wird synchronisiert sobald Verbindung besteht.'); _loadReports(); return; } throw netErr; // API-Fehler (z.B. 422) → weitergeben } // Foto hochladen if (photoInput?.files[0]) { try { const toUpload = await API.compressImage(photoInput.files[0]); const formData = new FormData(); formData.append('file', toUpload); 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 _emptyState(icon, title, text, cta = '') { return `
${title}
${text ? `

${text}

` : ''} ${cta ? `
${cta}
` : ''}
`; } // ---------------------------------------------------------- // NOTIZ-MODAL // ---------------------------------------------------------- // ---------------------------------------------------------- // PUBLIC // ---------------------------------------------------------- function _destroy() { try { _map && _map.remove(); } catch (e) {} _map = null; _markers = []; _userMarker = null; } return { init, refresh, openNew, destroy: _destroy }; })();