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).
738 lines
29 KiB
JavaScript
738 lines
29 KiB
JavaScript
/* ============================================================
|
||
BAN YARO — Giftköder-Alarm (Sprint 2)
|
||
Seiten-Modul: Leaflet-Karte + Meldungsliste + Melden-Formular.
|
||
============================================================ */
|
||
|
||
window.Page_poison = (() => {
|
||
|
||
const _CACHE_KEY = 'by_poison_cache';
|
||
|
||
// ----------------------------------------------------------
|
||
// MODUL-STATE
|
||
// ----------------------------------------------------------
|
||
let _container = null;
|
||
let _appState = null;
|
||
let _map = null;
|
||
let _markers = [];
|
||
let _userMarker = null;
|
||
let _reports = [];
|
||
let _userPos = null;
|
||
|
||
const TYPEN = {
|
||
unbekannt: { label: 'Unbekannt', icon: 'question', color: '#e67e22' },
|
||
koeoder: { label: 'Köder', icon: 'fish', color: '#e74c3c' },
|
||
vergiftet: { label: 'Vergiftetes Tier', icon: 'skull', color: '#8e44ad' },
|
||
chemikalie: { label: 'Chemikalie', icon: 'flask', color: '#c0392b' },
|
||
andere: { label: 'Andere Gefahr', icon: 'warning', color: '#d35400' },
|
||
};
|
||
|
||
// ----------------------------------------------------------
|
||
// 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="poison-btn-locate">${UI.icon('map-pin')} Mein Standort</button>
|
||
<button class="btn btn-danger" id="poison-btn-report">${UI.icon('warning-octagon')} Giftköder melden</button>
|
||
</div>
|
||
|
||
<div id="poison-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>
|
||
|
||
<div style="display:flex;gap:var(--space-2);flex-wrap:wrap;margin-bottom:var(--space-3)">
|
||
<a href="tel:110" class="btn btn-secondary" style="flex:1;text-align:center;text-decoration:none">
|
||
${UI.icon('phone')} <strong>110</strong> Polizei
|
||
</a>
|
||
<button class="btn btn-secondary" id="poison-btn-erstehilfe" class="flex-1">
|
||
${UI.icon('first-aid')} Erste Hilfe & Tiergift
|
||
</button>
|
||
</div>
|
||
|
||
<p id="poison-info"
|
||
style="font-size:var(--text-sm);color:var(--c-text-secondary);
|
||
margin-bottom:var(--space-3)">
|
||
Standort wird ermittelt…
|
||
</p>
|
||
|
||
<div id="poison-list"></div>
|
||
`;
|
||
|
||
document.getElementById('poison-btn-locate')
|
||
?.addEventListener('click', _locateUser);
|
||
document.getElementById('poison-btn-report')
|
||
?.addEventListener('click', _showReportForm);
|
||
document.getElementById('poison-btn-erstehilfe')
|
||
?.addEventListener('click', () => App.navigate('erste-hilfe', true, { tab: 'lebensgefahr' }));
|
||
|
||
await UI.loadLeaflet();
|
||
_initMap();
|
||
// Leaflet muss nach CSS-Load die Container-Größe neu berechnen
|
||
setTimeout(() => _map?.invalidateSize(), 100);
|
||
await _locateAndLoad();
|
||
}
|
||
|
||
// ----------------------------------------------------------
|
||
// KARTE INITIALISIEREN
|
||
// ----------------------------------------------------------
|
||
function _initMap() {
|
||
const mapEl = document.getElementById('poison-map');
|
||
if (!mapEl || !window.L || _map) return;
|
||
|
||
_map = L.map('poison-map', { zoomControl: true, attributionControl: false })
|
||
.setView([51.1657, 10.4515], 6); // Deutschland-Mitte
|
||
|
||
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('poison-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('poison-info');
|
||
|
||
if (!_userPos) {
|
||
_reports = [];
|
||
_renderList();
|
||
if (infoEl) infoEl.textContent =
|
||
'Standort unbekannt — bitte Standort freigeben (📍 Mein Standort).';
|
||
return;
|
||
}
|
||
|
||
try {
|
||
_reports = await API.poison.listNearby(_userPos.lat, _userPos.lon, 10000);
|
||
try { localStorage.setItem(_CACHE_KEY, JSON.stringify({ ts: Date.now(), data: _reports })); } catch {}
|
||
_renderMarkers();
|
||
_renderList();
|
||
_updateBadge(_reports.length);
|
||
if (infoEl) {
|
||
infoEl.textContent = _reports.length > 0
|
||
? `${_reports.length} aktive Meldung${_reports.length !== 1 ? 'en' : ''} im Umkreis von 10 km`
|
||
: 'Keine aktiven Giftköder-Meldungen in deiner Nähe (10 km Radius).';
|
||
}
|
||
} catch {
|
||
try {
|
||
const raw = localStorage.getItem(_CACHE_KEY);
|
||
if (raw) {
|
||
_reports = JSON.parse(raw).data || [];
|
||
_renderMarkers();
|
||
_renderList();
|
||
_updateBadge(_reports.length);
|
||
if (infoEl) infoEl.textContent = `${_reports.length} gecachte Meldung${_reports.length !== 1 ? 'en' : ''} (Offline)`;
|
||
UI.toast.info('Offline — zeige zuletzt geladene Daten.');
|
||
return;
|
||
}
|
||
} 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 typ = TYPEN[r.typ] || TYPEN.unbekannt;
|
||
|
||
const distStr = r.distanz_m < 1000
|
||
? `${r.distanz_m} m`
|
||
: `${(r.distanz_m / 1000).toFixed(1)} km`;
|
||
|
||
const marker = UI.leafletMarker({ lat: r.lat, lon: r.lon, color: typ.color, icon: UI.icon(typ.icon), size: 34 })
|
||
.addTo(_map)
|
||
.bindPopup(`
|
||
<b>${UI.icon(typ.icon)} ${typ.label}</b><br>
|
||
${r.beschreibung ? UI.escape(r.beschreibung.slice(0, 80)) + '<br>' : ''}
|
||
<small>📍 ${distStr} entfernt</small><br>
|
||
<small>📅 ${_fmtDate(r.created_at)}</small>
|
||
${r.bestaetigt ? '<br><small><svg class="ph-icon" aria-hidden="true" class="text-success"><use href="/icons/phosphor.svg#check-circle"></use></svg> Bestätigt</small>' : ''}
|
||
`);
|
||
|
||
marker.on('click', () => _openDetail(r));
|
||
_markers.push(marker);
|
||
});
|
||
}
|
||
|
||
// ----------------------------------------------------------
|
||
// LISTE
|
||
// ----------------------------------------------------------
|
||
function _renderList() {
|
||
const listEl = document.getElementById('poison-list');
|
||
if (!listEl) return;
|
||
|
||
if (_reports.length === 0) {
|
||
listEl.innerHTML = UI.emptyState({
|
||
icon : UI.icon('check-circle'),
|
||
title : 'Alles sicher',
|
||
text : 'In deiner Nähe (10 km) gibt es aktuell keine Giftköder-Meldungen.',
|
||
action: `<button class="btn btn-danger" id="poison-empty-report">⚠️ Trotzdem melden</button>`,
|
||
});
|
||
listEl.querySelector('#poison-empty-report')
|
||
?.addEventListener('click', _showReportForm);
|
||
return;
|
||
}
|
||
|
||
listEl.innerHTML = _reports.map(r => _reportCard(r)).join('');
|
||
listEl.querySelectorAll('[data-poison-id]').forEach(card => {
|
||
card.addEventListener('click', () => {
|
||
const r = _reports.find(x => x.id === parseInt(card.dataset.poisonId));
|
||
if (r) _openDetail(r);
|
||
});
|
||
});
|
||
listEl.querySelectorAll('.poison-note-btn').forEach(btn => {
|
||
btn.addEventListener('click', e => {
|
||
e.stopPropagation();
|
||
const id = parseInt(btn.dataset.poisonNoteId);
|
||
_openNoteModal('poison', id, 'Giftköder-Meldung ' + id, null);
|
||
});
|
||
});
|
||
}
|
||
|
||
function _reportCard(r) {
|
||
const typ = TYPEN[r.typ] || TYPEN.unbekannt;
|
||
const distStr = r.distanz_m < 1000
|
||
? `${r.distanz_m} m`
|
||
: `${(r.distanz_m / 1000).toFixed(1)} km`;
|
||
|
||
return `
|
||
<div class="card" data-poison-id="${r.id}"
|
||
style="cursor:pointer;margin-bottom:var(--space-3);
|
||
border-left:4px solid ${typ.color}">
|
||
<div style="display:flex;gap:var(--space-3);align-items:flex-start">
|
||
<div style="width:40px;height:40px;flex-shrink:0;color:${typ.color};display:flex;align-items:center;justify-content:center">${UI.icon(typ.icon)}</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 class="badge"
|
||
style="background:${typ.color};color:#fff">${typ.label}</span>
|
||
${r.bestaetigt
|
||
? '<span class="badge badge-success"><svg class="ph-icon" aria-hidden="true" class="text-success"><use href="/icons/phosphor.svg#check-circle"></use></svg> Bestätigt</span>'
|
||
: ''}
|
||
<span style="margin-left:auto;color:var(--c-text-secondary);
|
||
font-size:var(--text-sm);white-space:nowrap">
|
||
${distStr}
|
||
</span>
|
||
</div>
|
||
${r.beschreibung
|
||
? `<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)} ·
|
||
läuft ab ${_fmtDate(r.expires_at)}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
${_appState.user ? `<div style="margin-top:var(--space-2);text-align:right">
|
||
<button class="btn btn-ghost btn-xs poison-note-btn"
|
||
data-poison-note-id="${r.id}"
|
||
title="Notiz" onclick="event.stopPropagation()">
|
||
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#note-pencil"></use></svg> Notiz
|
||
</button>
|
||
</div>` : ''}
|
||
${r.foto_url
|
||
? `<img src="${r.foto_url}" alt="Foto"
|
||
loading="lazy"
|
||
style="width:100%;max-height:160px;object-fit:cover;
|
||
border-radius:var(--radius-sm);margin-top:var(--space-2)">`
|
||
: ''}
|
||
</div>
|
||
`;
|
||
}
|
||
|
||
// ----------------------------------------------------------
|
||
// DETAIL-MODAL
|
||
// ----------------------------------------------------------
|
||
function _openDetail(r) {
|
||
const typ = TYPEN[r.typ] || TYPEN.unbekannt;
|
||
const isOwnEntry = _appState.user && _appState.user.id === r.user_id;
|
||
const isAdmin = _appState.user?.rolle === 'admin';
|
||
|
||
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" style="background:${typ.color};color:#fff">
|
||
${UI.icon(typ.icon)} ${typ.label}
|
||
</span>
|
||
${r.bestaetigt ? '<span class="badge badge-success"><svg class="ph-icon" aria-hidden="true" class="text-success"><use href="/icons/phosphor.svg#check-circle"></use></svg> Bestätigt</span>' : ''}
|
||
</div>
|
||
|
||
${r.beschreibung
|
||
? `<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)}</div>
|
||
<div>📅 Gemeldet: ${_fmtDate(r.created_at)}</div>
|
||
<div>⏰ Läuft ab: ${_fmtDate(r.expires_at)}</div>
|
||
${r.melder_name ? `<div>👤 Gemeldet von: ${UI.escape(r.melder_name)}</div>` : ''}
|
||
</div>
|
||
|
||
<div style="display:flex;gap:var(--space-2);flex-wrap:wrap">
|
||
${!r.bestaetigt && _appState.user && !isOwnEntry
|
||
? `<button class="btn btn-secondary flex-1" id="detail-confirm"><svg class="ph-icon" aria-hidden="true" class="text-success"><use href="/icons/phosphor.svg#check-circle"></use></svg> Bestätigen</button>`
|
||
: ''}
|
||
<button class="btn btn-secondary flex-1" id="detail-show-map">🗺️ Auf Karte</button>
|
||
${isOwnEntry || isAdmin
|
||
? `<button class="btn btn-nature flex-1" id="detail-resolve">✔ Erledigt</button>`
|
||
: ''}
|
||
</div>
|
||
`;
|
||
|
||
UI.modal.open({ title: `${UI.icon(typ.icon)} Giftköder-Meldung`, body });
|
||
|
||
document.getElementById('detail-confirm')?.addEventListener('click', async () => {
|
||
try {
|
||
const updated = await API.poison.confirm(r.id);
|
||
const idx = _reports.findIndex(x => x.id === r.id);
|
||
if (idx !== -1) _reports[idx] = { ..._reports[idx], ...updated };
|
||
UI.toast.success('Meldung bestätigt. Danke!');
|
||
UI.modal.close();
|
||
_renderMarkers();
|
||
_renderList();
|
||
} catch (err) {
|
||
UI.toast.error(err.message || 'Fehler beim Bestätigen.');
|
||
}
|
||
});
|
||
|
||
document.getElementById('detail-show-map')?.addEventListener('click', () => {
|
||
UI.modal.close();
|
||
if (_map) {
|
||
_map.setView([r.lat, r.lon], 16);
|
||
document.getElementById('poison-map')
|
||
?.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
||
// Popup des Markers öffnen
|
||
const marker = _markers[_reports.findIndex(x => x.id === r.id)];
|
||
marker?.openPopup();
|
||
}
|
||
});
|
||
|
||
document.getElementById('detail-resolve')?.addEventListener('click', () => {
|
||
_showResolveDialog(r);
|
||
});
|
||
}
|
||
|
||
// ----------------------------------------------------------
|
||
// ERLEDIGT-DIALOG — mit Grundauswahl für KI-Analyse
|
||
// ----------------------------------------------------------
|
||
function _showResolveDialog(r) {
|
||
UI.modal.open({
|
||
title: '✔ Meldung als erledigt markieren',
|
||
body: `
|
||
<p style="color:var(--c-text-secondary);margin-bottom:var(--space-4)">
|
||
Die Meldung wird inaktiv gesetzt. Die Daten bleiben für spätere
|
||
Musteranalysen gespeichert.
|
||
</p>
|
||
<div class="form-group">
|
||
<label class="form-label">Grund</label>
|
||
<select class="form-control" id="resolve-grund">
|
||
<option value="beseitigt">Gefahr wurde beseitigt</option>
|
||
<option value="fehlerhaft">❌ Meldung war fehlerhaft</option>
|
||
<option value="anderes">💬 Anderer Grund</option>
|
||
</select>
|
||
</div>
|
||
`,
|
||
footer: `
|
||
<button class="btn btn-secondary" id="resolve-cancel">Abbrechen</button>
|
||
<button class="btn btn-nature" id="resolve-confirm">Erledigt markieren</button>
|
||
`,
|
||
});
|
||
|
||
document.getElementById('resolve-cancel')
|
||
?.addEventListener('click', UI.modal.close);
|
||
|
||
document.getElementById('resolve-confirm')
|
||
?.addEventListener('click', async () => {
|
||
const grund = document.getElementById('resolve-grund')?.value || 'beseitigt';
|
||
const btn = document.getElementById('resolve-confirm');
|
||
await UI.asyncButton(btn, async () => {
|
||
await API.poison.resolve(r.id, { grund });
|
||
_reports = _reports.filter(x => x.id !== r.id);
|
||
_renderMarkers();
|
||
_renderList();
|
||
_updateBadge(_reports.length);
|
||
App.checkNearbyAlerts();
|
||
UI.modal.close();
|
||
UI.toast.success('Meldung als erledigt markiert.');
|
||
});
|
||
});
|
||
}
|
||
|
||
// ----------------------------------------------------------
|
||
// MELDE-FORMULAR
|
||
// ----------------------------------------------------------
|
||
function _showReportForm() {
|
||
if (!_appState.user) {
|
||
UI.toast.warning('Bitte zuerst anmelden, um eine Meldung abzuschicken.');
|
||
App.navigate('settings');
|
||
return;
|
||
}
|
||
|
||
const typOpts = Object.entries(TYPEN)
|
||
.map(([val, { label }]) =>
|
||
`<option value="${val}">${label}</option>`)
|
||
.join('');
|
||
|
||
const body = `
|
||
<form id="poison-form" autocomplete="off">
|
||
|
||
<div class="form-group">
|
||
<label class="form-label">Art des Fundes</label>
|
||
<select class="form-control" name="typ">${typOpts}</select>
|
||
</div>
|
||
|
||
<div class="form-group">
|
||
<label class="form-label">Standort</label>
|
||
<div id="poison-location-picker"></div>
|
||
</div>
|
||
|
||
<div class="form-group">
|
||
<label class="form-label">
|
||
Beschreibung
|
||
<span class="text-secondary">(optional)</span>
|
||
</label>
|
||
<textarea class="form-control" name="beschreibung" rows="3"
|
||
placeholder="z. B. Wurstköder mit Nadeln, liegt beim Eingang Hundeparkplatz, linke Seite…"></textarea>
|
||
</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="pf-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="pf-cancel">Abbrechen</button>
|
||
<button type="submit" form="poison-form" class="btn btn-danger">
|
||
Absenden
|
||
</button>
|
||
`;
|
||
|
||
UI.modal.open({ title: '⚠️ Giftköder melden', body, footer });
|
||
|
||
// Location-Picker initialisieren + ggf. bekannten Standort vorausfüllen
|
||
const _picker = UI.locationPicker({ containerId: 'poison-location-picker' });
|
||
if (_userPos) {
|
||
_picker.setValue(_userPos.lat, _userPos.lon, null);
|
||
}
|
||
|
||
// Foto-Vorschau
|
||
const photoInput = document.querySelector('#poison-form [name="photo"]');
|
||
const photoPreview = document.getElementById('pf-photo-preview');
|
||
if (photoInput && photoPreview) {
|
||
UI.setupPhotoPreview(photoInput, photoPreview);
|
||
photoInput.addEventListener('change', () => {
|
||
photoPreview.style.display = photoInput.files[0] ? 'block' : 'none';
|
||
});
|
||
}
|
||
|
||
document.getElementById('pf-cancel')
|
||
?.addEventListener('click', UI.modal.close);
|
||
|
||
// Formular absenden
|
||
document.getElementById('poison-form')?.addEventListener('submit', async e => {
|
||
e.preventDefault();
|
||
const submitBtn = document.querySelector('[form="poison-form"][type="submit"]') || e.target.querySelector('[type="submit"]');
|
||
const fd = UI.formData(e.target);
|
||
const loc = _picker.getValue();
|
||
|
||
if (!loc.lat || !loc.lon) {
|
||
UI.toast.warning('Bitte zuerst den GPS-Standort ermitteln (📍).');
|
||
return;
|
||
}
|
||
|
||
await UI.asyncButton(submitBtn, async () => {
|
||
const payload = {
|
||
lat : loc.lat,
|
||
lon : loc.lon,
|
||
typ : fd.typ,
|
||
beschreibung : fd.beschreibung || null,
|
||
};
|
||
|
||
const created = await API.poison.report(payload);
|
||
|
||
// SW hat Request in Queue gelegt (offline)
|
||
if (created?._queued) {
|
||
_showPoisonThanks(true);
|
||
return;
|
||
}
|
||
|
||
// 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.poison.uploadPhoto(created.id, formData);
|
||
created.foto_url = media.foto_url;
|
||
} catch {
|
||
UI.toast.warning('Meldung erstellt — Foto konnte nicht hochgeladen werden.');
|
||
}
|
||
}
|
||
|
||
// Distanz client-seitig berechnen
|
||
if (loc.lat && loc.lon) _userPos = { lat: loc.lat, lon: loc.lon };
|
||
created.distanz_m = _userPos
|
||
? Math.round(_haversine(_userPos.lat, _userPos.lon, created.lat, created.lon))
|
||
: 0;
|
||
|
||
_reports.unshift(created);
|
||
_renderMarkers();
|
||
_renderList();
|
||
_updateBadge(_reports.length);
|
||
App.checkNearbyAlerts();
|
||
App.callModule('map', 'refresh');
|
||
_showPoisonThanks(false);
|
||
});
|
||
});
|
||
}
|
||
|
||
// ----------------------------------------------------------
|
||
// DANKE-OVERLAY nach Giftköder-Meldung
|
||
// ----------------------------------------------------------
|
||
function _showPoisonThanks(isQueued) {
|
||
const offlineNote = isQueued
|
||
? `<p style="font-size:var(--text-xs);color:var(--c-text-muted);margin:var(--space-3) 0 0;display:flex;align-items:center;justify-content:center;gap:var(--space-2)">
|
||
<svg class="ph-icon" aria-hidden="true" style="flex-shrink:0"><use href="/icons/phosphor.svg#wifi-slash"></use></svg>
|
||
Wird synchronisiert sobald du wieder online bist.
|
||
</p>`
|
||
: '';
|
||
UI.modal.open({
|
||
title: 'Danke für deine Meldung!',
|
||
body: `
|
||
<div style="text-align:center;padding:var(--space-2) 0 var(--space-4)">
|
||
<div class="mb-4">
|
||
<svg class="ph-icon" aria-hidden="true" style="width:48px;height:48px;color:var(--c-danger)"><use href="/icons/phosphor.svg#siren"></use></svg>
|
||
</div>
|
||
<p style="color:var(--c-text);font-size:var(--text-base);line-height:1.7;margin:0">
|
||
Wir kümmern uns darum und melden es den anderen Nutzern in der Umgebung.
|
||
</p>
|
||
<p style="color:var(--c-text-secondary);font-size:var(--text-sm);
|
||
margin:var(--space-2) 0 0;line-height:1.5;display:flex;align-items:center;justify-content:center;gap:var(--space-2)">
|
||
<svg class="ph-icon" aria-hidden="true" style="flex-shrink:0;color:var(--c-primary)"><use href="/icons/phosphor.svg#paw-print"></use></svg>
|
||
Vielen Dank, dass du die Community schützt!
|
||
</p>
|
||
${offlineNote}
|
||
</div>
|
||
`,
|
||
footer: `<button class="btn btn-primary flex-1" id="poison-thanks-ok">OK</button>`,
|
||
});
|
||
document.getElementById('poison-thanks-ok')?.addEventListener('click', UI.modal.close);
|
||
setTimeout(() => UI.modal.close(), 5000);
|
||
}
|
||
|
||
// ----------------------------------------------------------
|
||
// BADGE (Sidebar + Bottom-Nav)
|
||
// ----------------------------------------------------------
|
||
function _updateBadge(count) {
|
||
const b1 = document.getElementById('poison-badge');
|
||
const b2 = document.getElementById('poison-nav-badge');
|
||
if (b1) { b1.textContent = count; b1.style.display = count > 0 ? '' : 'none'; }
|
||
if (b2) { b2.textContent = count; b2.classList.toggle('hidden', count === 0); }
|
||
}
|
||
|
||
// ----------------------------------------------------------
|
||
// HELPER
|
||
// ----------------------------------------------------------
|
||
|
||
// Haversine client-seitig (für frisch gemeldete Einträge)
|
||
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));
|
||
}
|
||
|
||
// _fmtDate: bewusst lokal behalten — UI.time.format() liefert langen Monats-Namen
|
||
// und behandelt kein SQLite-Leerzeichen-Format ("2026-04-12 00:00:00")
|
||
function _fmtDate(isoStr) {
|
||
if (!isoStr) return '';
|
||
// SQLite speichert "2026-04-12T00:00:00" oder "2026-04-12 00:00:00"
|
||
const d = new Date(isoStr.replace(' ', 'T'));
|
||
return d.toLocaleDateString('de-DE', {
|
||
day: '2-digit', month: '2-digit', year: 'numeric'
|
||
});
|
||
}
|
||
|
||
// ----------------------------------------------------------
|
||
// 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">${UI.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, openDetail: _openDetail };
|
||
|
||
})();
|