banyaro/backend/static/js/pages/lost.js

699 lines
26 KiB
JavaScript

/* ============================================================
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 = `
<div style="display:flex;gap:var(--space-2);margin-bottom:var(--space-3);flex-wrap:wrap">
<button class="btn btn-secondary" id="lost-btn-locate">${UI.icon('map-pin')} Mein Standort</button>
<button class="btn btn-primary" id="lost-btn-report">${UI.icon('magnifying-glass')} Hund vermisst melden</button>
</div>
<div id="lost-map"
style="height:280px;border-radius:var(--radius-md);overflow:hidden;
background:var(--c-surface-2)">
</div>
<div style="font-size:10px;color:var(--c-text-secondary);
text-align:right;margin-bottom:var(--space-4);
padding:2px var(--space-2) 0">
© OpenStreetMap-Mitwirkende
</div>
<p id="lost-info"
style="font-size:var(--text-sm);color:var(--c-text-secondary);
margin-bottom:var(--space-3)">
Standort wird ermittelt…
</p>
<div id="lost-held"></div>
<div id="lost-list"></div>
`;
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('<b>Du bist hier</b>');
_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 : `<div style="
background:#e74c3c;color:#fff;border-radius:50%;
width:34px;height:34px;
display:flex;align-items:center;justify-content:center;
font-size:17px;box-shadow:0 2px 6px rgba(0,0,0,.35);
border:2px solid #fff">🐕</div>`,
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(`
<b>🔍 ${_escape(r.name)}</b><br>
${r.rasse ? _escape(r.rasse) + '<br>' : ''}
${distStr ? `<small>📍 ${distStr} entfernt</small><br>` : ''}
<small>📅 ${_fmtDate(r.created_at)}</small>
`);
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.',
`<button class="btn btn-primary" id="lost-empty-report">Vermissten melden</button>`
);
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);
});
});
}
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 `
<div class="card" data-lost-id="${r.id}"
style="cursor:pointer;margin-bottom:var(--space-3);
border-left:4px solid #e74c3c">
<div style="display:flex;gap:var(--space-3);align-items:flex-start">
${r.foto_url
? `<img src="${r.foto_url}" alt="Foto"
loading="lazy"
style="width:72px;height:72px;object-fit:cover;
border-radius:var(--radius-md);flex-shrink:0">`
: `<div style="width:72px;height:72px;background:var(--c-surface-2);
border-radius:var(--radius-md);flex-shrink:0;
display:flex;align-items:center;justify-content:center;
font-size:2rem">🐕</div>`}
<div style="flex:1;min-width:0">
<div style="display:flex;align-items:center;gap:var(--space-2);
margin-bottom:var(--space-1);flex-wrap:wrap">
<span style="font-weight:var(--weight-semibold);font-size:var(--text-base)">
${_escape(r.name)}
</span>
${r.rasse
? `<span class="badge">${_escape(r.rasse)}</span>`
: ''}
${isOwn
? '<span class="badge badge-warning">Meine Meldung</span>'
: ''}
${distStr
? `<span style="margin-left:auto;color:var(--c-text-secondary);
font-size:var(--text-sm);white-space:nowrap">
📍 ${distStr}
</span>`
: ''}
</div>
<p style="margin:0 0 var(--space-1);font-size:var(--text-sm);
color:var(--c-text)">
${_escape(r.beschreibung.slice(0, 120))}${r.beschreibung.length > 120 ? '…' : ''}
</p>
<div style="font-size:var(--text-xs);color:var(--c-text-secondary)">
Gemeldet ${_fmtDate(r.created_at)}
${r.melder_name ? '· ' + _escape(r.melder_name.split(' ')[0]) : ''}
</div>
</div>
</div>
</div>
`;
}
// ----------------------------------------------------------
// 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
? `<img src="${r.foto_url}" alt="Foto"
style="width:100%;border-radius:var(--radius-md);margin-bottom:var(--space-4)">`
: ''}
<div style="display:flex;gap:var(--space-2);flex-wrap:wrap;margin-bottom:var(--space-3)">
<span class="badge badge-danger">🐕 ${_escape(r.name)}</span>
${r.rasse ? `<span class="badge">${_escape(r.rasse)}</span>` : ''}
</div>
<p style="white-space:pre-wrap;margin-bottom:var(--space-3)">
${_escape(r.beschreibung)}
</p>
<div style="font-size:var(--text-sm);color:var(--c-text-secondary);
margin-bottom:var(--space-4);line-height:1.8">
<div>📍 ${r.lat.toFixed(5)}, ${r.lon.toFixed(5)}${distStr ? ' (' + distStr + ' entfernt)' : ''}</div>
<div>📅 Gemeldet: ${_fmtDate(r.created_at)}</div>
${r.melder_name ? `<div>👤 Gemeldet von: ${_escape(r.melder_name.split(' ')[0])}</div>` : ''}
</div>
<div style="display:flex;gap:var(--space-2);flex-wrap:wrap">
<button class="btn btn-secondary flex-1" id="detail-lost-map">🗺️ Auf Karte</button>
${isOwn || isAdmin
? `<button class="btn btn-nature flex-1" id="detail-lost-found">🎉 Gefunden!</button>`
: ''}
${isOwn || isAdmin
? `<button class="btn btn-danger flex-1" id="detail-lost-delete">🗑 Löschen</button>`
: ''}
</div>
`;
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: `
<p style="color:var(--c-text-secondary);margin-bottom:var(--space-4)">
Wurde ${_escape(r.name)} wiedergefunden? Die Meldung wird als
abgeschlossen markiert und aus der Liste entfernt.
</p>
`,
footer: `
<button class="btn btn-secondary" id="found-cancel">Abbrechen</button>
<button class="btn btn-nature" id="found-confirm">🎉 Ja, gefunden!</button>
`,
});
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
? `<option value="">— kein registrierter Hund —</option>` +
dogs.map(d => `<option value="${d.id}">${_escape(d.name)}${d.rasse ? ' (' + _escape(d.rasse) + ')' : ''}</option>`).join('')
: '';
const body = `
<form id="lost-form" autocomplete="off">
${dogs.length > 0 ? `
<div class="form-group">
<label class="form-label">
Registrierter Hund
<span style="color:var(--c-text-secondary)">(optional)</span>
</label>
<select class="form-control" name="dog_id" id="lf-dog-select">
${dogOpts}
</select>
</div>` : ''}
<div class="form-group">
<label class="form-label">Name des Hundes *</label>
<input class="form-control" type="text" name="name" id="lf-name"
placeholder="z. B. Bello" required>
</div>
<div class="form-group">
<label class="form-label">
Rasse
<span style="color:var(--c-text-secondary)">(optional)</span>
</label>
<input class="form-control" type="text" name="rasse"
placeholder="z. B. Labrador">
</div>
<div class="form-group">
<label class="form-label">Beschreibung *</label>
<textarea class="form-control" name="beschreibung" rows="3"
placeholder="Farbe, Merkmale, wo zuletzt gesehen, Halsband, …"
required></textarea>
</div>
<div class="form-group">
<label class="form-label">Standort (letzter bekannter Ort)</label>
<div style="display:flex;gap:var(--space-2);align-items:center">
<input class="form-control" type="text" id="lf-lat-disp"
placeholder="Breite" readonly style="flex:1">
<input class="form-control" type="text" id="lf-lon-disp"
placeholder="Länge" readonly style="flex:1">
<button type="button" class="btn btn-secondary" id="lf-gps-btn"
title="GPS-Standort ermitteln">📍</button>
</div>
<input type="hidden" name="lat" id="lf-lat">
<input type="hidden" name="lon" id="lf-lon">
<small id="lf-gps-hint" style="color:var(--c-text-secondary)">
${_userPos
? '✅ Aktueller Standort vorausgefüllt'
: 'GPS-Button drücken um Standort zu ermitteln'}
</small>
</div>
<div class="form-group">
<label class="form-label">
Foto
<span style="color:var(--c-text-secondary)">(optional)</span>
</label>
<input class="form-control" type="file" name="photo"
accept="image/*" capture="environment">
<img id="lf-photo-preview"
style="display:none;width:100%;max-height:200px;object-fit:cover;
border-radius:var(--radius-md);margin-top:var(--space-2)">
</div>
</form>
`;
const footer = `
<button type="button" class="btn btn-secondary flex-1" id="lf-cancel">Abbrechen</button>
<button type="submit" form="lost-form" class="btn btn-primary flex-1">
🔍 Meldung abschicken
</button>
`;
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, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
}
function _emptyState(icon, title, text, cta = '') {
return `<div class="empty-state">
<svg class="ph-icon empty-state-icon" aria-hidden="true">
<use href="/icons/phosphor.svg#${icon}"></use>
</svg>
<div class="empty-state-title">${title}</div>
${text ? `<p class="empty-state-text">${text}</p>` : ''}
${cta ? `<div class="empty-state-cta">${cta}</div>` : ''}
</div>`;
}
// ----------------------------------------------------------
// PUBLIC
// ----------------------------------------------------------
return { init, refresh, openNew };
})();