banyaro/backend/static/js/pages/lost.js
rene 459cd425f2 Design-System Sprint A: utilities.css + 948 Inline-Styles → Utility-Klassen, SW by-v1102
PHASE 1 — Sofort-Cleanup ohne Risiko:
- Neue Datei utilities.css mit ~25 Klassen für häufige Kombinationen:
  * text-xs-muted, text-xs-secondary, text-sm-muted, text-sm-secondary
  * flex-gap-2/3, flex-col-gap-2/3/4, flex-center-gap-1/2/3
  * flex-between, flex-1-min, mb-1/3, mt-1/3
  * icon-xs/sm/md/lg, label-block, caption
- index.html bindet utilities.css ein
- mb-3/mt-3 ergänzt (waren in design-system.css unvollständig)

PHASE 2 — .by-tab Modifier für Vereinheitlichung:
- .by-tabs.grid (mit --tab-cols Variable für Admin/Health/etc.)
- .by-tabs.sticky (Desktop vertikale Tabs für Admin)
- .by-tabs.wrap (Zuchthunde, flex-wrap statt scroll)
- .by-tabs.separated (Sitting, mit eigenem Hintergrund + Border)

PHASE 3 — Inline-Style → Klassen-Migration (Python-Script):
- 948 Inline-Styles entfernt (5101 → 4153, -18%)
- 962 Migrationen über 47 Page-Dateien
- Top-Treffer: admin.js (180), health.js (67), dog-profile.js (67),
  litters.js (62), settings.js (61), zuchthunde.js (51)
- Patterns: text-muted, text-secondary, text-danger, text-xs-muted,
  text-sm-muted, grid-2 (Duplikat-Bug behoben!), flex-col-gap-3,
  p-3/4, mb-2/3/4, hidden, w-full, flex-1, ...
- Bewahrt bestehende class-Attribute (mergt korrekt)

Alle 19 Tests grün. Kein visueller Diff erwartet (gleiche Property-Werte).
2026-05-27 07:11:27 +02:00

935 lines
36 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/* ============================================================
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;
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() {
_injectStyles();
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 {
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 || !window.L) 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 icon = L.divIcon({
className : '',
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>`,
iconSize : [34, 34],
iconAnchor : [17, 17],
});
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 = 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>` : ''}
${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 || '';
_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 `
<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)">
${_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 class="text-xs-secondary">
Gemeldet ${_fmtDate(r.created_at)}
${r.melder_name ? '· ' + _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}"
onclick="event.stopPropagation()"
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="${_escape(r.name)}"
title="Notiz" onclick="event.stopPropagation()">
<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">🐕 ${_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 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 _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>`;
}
// ----------------------------------------------------------
// 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 = `
<div style="background:var(--c-surface);border-radius:var(--radius-xl) var(--radius-xl) 0 0;
width:100%;max-width:640px;max-height:90vh;display:flex;flex-direction:column;
padding-bottom:env(safe-area-inset-bottom,0px)">
<div style="padding:var(--space-4) var(--space-5);border-bottom:1px solid var(--c-border);
display:flex;align-items:center;justify-content:space-between;flex-shrink:0">
<div>
<div style="font-weight:var(--weight-semibold);font-size:var(--text-base)"><svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#note-pencil"></use></svg> Notiz</div>
<div style="font-size:var(--text-xs);color:var(--c-text-muted);margin-top:2px">${_escape(parentLabel)}</div>
</div>
<button id="by-note-close" style="background:none;border:none;cursor:pointer;font-size:1.4rem;color:var(--c-text-muted);padding:4px 8px" aria-label="Schließen">×</button>
</div>
<div style="padding:var(--space-4) var(--space-5);flex:1;overflow-y:auto">
<form id="by-note-form">
<textarea id="by-note-text" class="form-control" rows="5"
placeholder="Notiz eingeben…"
style="width:100%;resize:vertical"></textarea>
</form>
</div>
<div style="padding:var(--space-3) var(--space-5);border-top:1px solid var(--c-border);
display:flex;gap:var(--space-2);flex-shrink:0">
<button type="button" id="by-note-cancel" class="btn btn-secondary flex-1">Abbrechen</button>
<button type="submit" form="by-note-form" id="by-note-save" class="btn btn-primary flex-1">Speichern</button>
</div>
</div>
`;
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 };
})();