Punkt 6: MapLibre rendert die Compact-Attribution offen (maplibregl-compact-show + open) → voller Text '© OpenStreetMap contributors' immer sichtbar. Neuer Helper MapGLStyle.collapseAttribution() entfernt die Klasse/open nach dem Hinzufügen → nur noch das ⓘ, der Text erscheint erst auf Klick (rechtlich nach ODbL ausreichend). In map-gl-mini.js (Seitenkarten) + map.js (zentrale Karte) verdrahtet. Punkt 7: poison.js + lost.js hatten UNTER der Karte zusätzlich ein hartkodiertes '© OpenStreetMap-Mitwirkende' — doppelt zum Karten-ⓘ. Entfernt (+ ungenutzte .lost-map-attribution CSS-Klasse). Verifiziert: osmTextLeafCount 2-3 → 1, compactShown true → false.
810 lines
31 KiB
JavaScript
810 lines
31 KiB
JavaScript
/* ============================================================
|
|
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 = `
|
|
<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;
|
|
margin-bottom:var(--space-4);
|
|
background:var(--c-surface-2)">
|
|
</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 _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('<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 {
|
|
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),
|
|
}));
|
|
try {
|
|
const raw = localStorage.getItem(_CACHE_KEY);
|
|
if (raw) {
|
|
const cached = JSON.parse(raw).data || [];
|
|
_reports = [...offline_pending, ...cached];
|
|
_renderMarkers();
|
|
_renderHeld();
|
|
_renderList();
|
|
_updateBadge(_reports.length);
|
|
if (infoEl) infoEl.textContent = 'Offline — zeige zuletzt geladene Meldungen.';
|
|
return;
|
|
}
|
|
} catch {}
|
|
_reports = offline_pending;
|
|
if (offline_pending.length) {
|
|
_renderMarkers();
|
|
_renderHeld();
|
|
_renderList();
|
|
_updateBadge(_reports.length);
|
|
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 = `<div style="background:${dotColor};color:#fff;border-radius:50%;
|
|
width:34px;height:34px;
|
|
display:flex;align-items:center;justify-content:center;
|
|
font-size:17px;border:2px solid #fff;
|
|
animation:${anim} 1.8s ease-in-out infinite">🐕</div>`;
|
|
|
|
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(`
|
|
<b>🔍 ${UI.escape(r.name)}</b><br>
|
|
${r.rasse ? UI.escape(r.rasse) + '<br>' : ''}
|
|
${distStr ? `<small>📍 ${distStr} entfernt</small><br>` : ''}
|
|
${r._isPending ? '<small>⏳ Sync ausstehend</small><br>' : ''}
|
|
<small>📅 ${_fmtDate(r.created_at)}</small>
|
|
`);
|
|
|
|
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.',
|
|
`<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 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 `
|
|
<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 class="flex-1-min">
|
|
<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)">
|
|
${UI.escape(r.name)}
|
|
</span>
|
|
${r.rasse
|
|
? `<span class="badge">${UI.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)">
|
|
${UI.escape(r.beschreibung.slice(0, 120))}${r.beschreibung.length > 120 ? '…' : ''}
|
|
</p>
|
|
<div class="text-xs-secondary">
|
|
Gemeldet ${_fmtDate(r.created_at)}
|
|
${r.melder_name ? '· ' + UI.escape(r.melder_name.split(' ')[0]) : ''}
|
|
</div>
|
|
${r._isPending
|
|
? `<div style="margin-top:var(--space-2);display:flex;align-items:center;gap:var(--space-2);flex-wrap:wrap">
|
|
<span style="font-size:10px;color:var(--c-warning,#d97706);font-weight:600">⏳ Sync ausstehend</span>
|
|
<button class="btn btn-ghost btn-xs lost-discard-btn"
|
|
data-pending-id="${r.id}"
|
|
style="color:var(--c-danger,#dc2626)">
|
|
🗑 Verwerfen
|
|
</button>
|
|
</div>`
|
|
: (_appState.user ? `<div class="mt-2">
|
|
<button class="btn btn-ghost btn-xs lost-note-btn"
|
|
data-lost-note-id="${r.id}"
|
|
data-lost-note-name="${UI.escape(r.name)}"
|
|
title="Notiz">
|
|
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#note-pencil"></use></svg> Notiz
|
|
</button>
|
|
</div>` : '')}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
`;
|
|
}
|
|
|
|
// ----------------------------------------------------------
|
|
// 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
|
|
? `<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">🐕 ${UI.escape(r.name)}</span>
|
|
${r.rasse ? `<span class="badge">${UI.escape(r.rasse)}</span>` : ''}
|
|
</div>
|
|
|
|
<p style="white-space:pre-wrap;margin-bottom:var(--space-3)">
|
|
${UI.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: ${UI.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: `🔍 ${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: `
|
|
<p style="color:var(--c-text-secondary);margin-bottom:var(--space-4)">
|
|
Wurde ${UI.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}">${UI.escape(d.name)}${d.rasse ? ' (' + UI.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 class="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 class="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;flex-wrap:wrap">
|
|
<input class="form-control" type="text" id="lf-lat-disp"
|
|
placeholder="Breite" readonly style="flex:1;min-width:80px">
|
|
<input class="form-control" type="text" id="lf-lon-disp"
|
|
placeholder="Länge" readonly style="flex:1;min-width:80px">
|
|
<button type="button" class="btn btn-secondary" id="lf-gps-btn"
|
|
style="white-space:nowrap">
|
|
${UI.icon('map-pin')} Standort
|
|
</button>
|
|
</div>
|
|
<input type="hidden" name="lat" id="lf-lat">
|
|
<input type="hidden" name="lon" id="lf-lon">
|
|
<small id="lf-gps-hint" class="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 class="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,
|
|
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 `<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>`;
|
|
}
|
|
|
|
// ----------------------------------------------------------
|
|
// NOTIZ-MODAL
|
|
// ----------------------------------------------------------
|
|
|
|
// ----------------------------------------------------------
|
|
// PUBLIC
|
|
// ----------------------------------------------------------
|
|
function _destroy() { try { _map && _map.remove(); } catch (e) {} _map = null; _markers = []; _userMarker = null; }
|
|
|
|
return { init, refresh, openNew, destroy: _destroy };
|
|
|
|
})();
|