Sprint 2: Giftköder-Alarm (poison.py + poison.js)

Backend: vollständiges CRUD (list/report/confirm/resolve/photo),
Haversine-Radius-Filter, Auto-Expiry 7 Tage, Foto-Upload.
Frontend: Leaflet-Karte + Meldungsliste + GPS-Formular.
main.py: /media StaticFiles-Mount für Foto-Serving (auch Diary).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
rene 2026-04-12 17:52:21 +02:00
parent 44b1451966
commit cc36ead720
3 changed files with 780 additions and 3 deletions

View file

@ -84,6 +84,11 @@ app.mount("/css", StaticFiles(directory=f"{STATIC_DIR}/css"), name="css")
app.mount("/js", StaticFiles(directory=f"{STATIC_DIR}/js"), name="js") app.mount("/js", StaticFiles(directory=f"{STATIC_DIR}/js"), name="js")
app.mount("/icons", StaticFiles(directory=f"{STATIC_DIR}/icons"), name="icons") app.mount("/icons", StaticFiles(directory=f"{STATIC_DIR}/icons"), name="icons")
# User-generierte Medien (Fotos aus Tagebuch, Giftköder-Alarm, etc.)
MEDIA_DIR = os.getenv("MEDIA_DIR", "/data/media")
os.makedirs(MEDIA_DIR, exist_ok=True)
app.mount("/media", StaticFiles(directory=MEDIA_DIR), name="media")
@app.get("/manifest.json") @app.get("/manifest.json")
async def manifest(): async def manifest():
return FileResponse(f"{STATIC_DIR}/manifest.json") return FileResponse(f"{STATIC_DIR}/manifest.json")

View file

@ -1,3 +1,161 @@
"""BAN YARO — poison Routes (Stub, wird ausgebaut)""" """BAN YARO — Giftköder-Alarm Routes"""
from fastapi import APIRouter
router = APIRouter() import os, uuid, math
from datetime import datetime, timedelta
from fastapi import APIRouter, Depends, HTTPException, UploadFile, File
from pydantic import BaseModel
from typing import Optional
from database import db
from auth import get_current_user
router = APIRouter()
MEDIA_DIR = os.getenv("MEDIA_DIR", "/data/media")
# ------------------------------------------------------------------
# Haversine-Distanz in Metern
# ------------------------------------------------------------------
def _haversine(lat1: float, lon1: float, lat2: float, lon2: float) -> float:
R = 6_371_000
p1 = math.radians(lat1)
p2 = math.radians(lat2)
dp = math.radians(lat2 - lat1)
dl = math.radians(lon2 - lon1)
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))
# ------------------------------------------------------------------
# Schemas
# ------------------------------------------------------------------
class PoisonCreate(BaseModel):
lat: float
lon: float
beschreibung: Optional[str] = None
typ: str = "unbekannt"
# ------------------------------------------------------------------
# GET /api/poison — aktive Meldungen im Umkreis (kein Login nötig)
# ------------------------------------------------------------------
@router.get("")
async def list_poison(lat: float, lon: float, radius: int = 5000):
now = datetime.utcnow().isoformat()
with db() as conn:
rows = conn.execute(
"""SELECT p.*, u.name AS melder_name
FROM poison p
LEFT JOIN users u ON u.id = p.user_id
WHERE p.geloest = 0
AND p.expires_at > ?
ORDER BY p.created_at DESC""",
(now,)
).fetchall()
results = []
for r in rows:
entry = dict(r)
dist = _haversine(lat, lon, entry["lat"], entry["lon"])
if dist <= radius:
entry["distanz_m"] = round(dist)
results.append(entry)
results.sort(key=lambda x: x["distanz_m"])
return results
# ------------------------------------------------------------------
# POST /api/poison — neue Meldung (Login erforderlich)
# ------------------------------------------------------------------
@router.post("", status_code=201)
async def report_poison(data: PoisonCreate, user=Depends(get_current_user)):
expires_at = (datetime.utcnow() + timedelta(days=7)).isoformat()
with db() as conn:
conn.execute(
"""INSERT INTO poison (user_id, lat, lon, beschreibung, typ, expires_at)
VALUES (?, ?, ?, ?, ?, ?)""",
(user["id"], data.lat, data.lon, data.beschreibung, data.typ, expires_at)
)
row = conn.execute(
"SELECT * FROM poison WHERE user_id=? ORDER BY id DESC LIMIT 1",
(user["id"],)
).fetchone()
return dict(row)
# ------------------------------------------------------------------
# POST /api/poison/{id}/confirm — Community-Bestätigung
# Verlängert die Laufzeit um 7 weitere Tage ab jetzt
# ------------------------------------------------------------------
@router.post("/{poison_id}/confirm")
async def confirm_poison(poison_id: int, user=Depends(get_current_user)):
with db() as conn:
entry = conn.execute(
"SELECT * FROM poison WHERE id=?", (poison_id,)
).fetchone()
if not entry:
raise HTTPException(404, "Meldung nicht gefunden.")
if dict(entry)["user_id"] == user["id"]:
raise HTTPException(400, "Eigene Meldung kann nicht bestätigt werden.")
new_expires = (datetime.utcnow() + timedelta(days=7)).isoformat()
conn.execute(
"""UPDATE poison
SET bestaetigt=1, bestaetigt_von=?, expires_at=?
WHERE id=?""",
(user["id"], new_expires, poison_id)
)
row = conn.execute("SELECT * FROM poison WHERE id=?", (poison_id,)).fetchone()
return dict(row)
# ------------------------------------------------------------------
# POST /api/poison/{id}/resolve — als erledigt markieren
# Nur der Melder selbst oder ein Admin
# ------------------------------------------------------------------
@router.post("/{poison_id}/resolve")
async def resolve_poison(poison_id: int, user=Depends(get_current_user)):
with db() as conn:
entry = conn.execute(
"SELECT * FROM poison WHERE id=?", (poison_id,)
).fetchone()
if not entry:
raise HTTPException(404, "Meldung nicht gefunden.")
e = dict(entry)
if e["user_id"] != user["id"] and user.get("rolle") != "admin":
raise HTTPException(403, "Keine Berechtigung.")
conn.execute("UPDATE poison SET geloest=1 WHERE id=?", (poison_id,))
return {"ok": True}
# ------------------------------------------------------------------
# POST /api/poison/{id}/photo — Foto nachträglich anhängen
# Nur vom Melder selbst
# ------------------------------------------------------------------
@router.post("/{poison_id}/photo")
async def upload_photo(
poison_id: int,
file: UploadFile = File(...),
user=Depends(get_current_user),
):
with db() as conn:
entry = conn.execute(
"SELECT id FROM poison WHERE id=? AND user_id=?",
(poison_id, user["id"])
).fetchone()
if not entry:
raise HTTPException(404, "Meldung nicht gefunden oder keine Berechtigung.")
ext = os.path.splitext(file.filename or "")[1] or ".jpg"
filename = f"poison_{poison_id}_{uuid.uuid4().hex[:8]}{ext}"
path = os.path.join(MEDIA_DIR, "poison", filename)
os.makedirs(os.path.dirname(path), exist_ok=True)
with open(path, "wb") as f:
f.write(await file.read())
foto_url = f"/media/poison/{filename}"
with db() as conn:
conn.execute("UPDATE poison SET foto_url=? WHERE id=?", (foto_url, poison_id))
return {"foto_url": foto_url}

View file

@ -0,0 +1,614 @@
/* ============================================================
BAN YARO Giftköder-Alarm (Sprint 2)
Seiten-Modul: Leaflet-Karte + Meldungsliste + Melden-Formular.
============================================================ */
window.Page_poison = (() => {
// ----------------------------------------------------------
// MODUL-STATE
// ----------------------------------------------------------
let _container = null;
let _appState = null;
let _map = null;
let _markers = [];
let _userMarker = null;
let _reports = [];
let _userPos = null;
let _leafletLoaded = false;
const TYPEN = {
unbekannt: { label: 'Unbekannt', icon: '❓', color: '#e67e22' },
koeoder: { label: 'Köder', icon: '🎣', color: '#e74c3c' },
vergiftet: { label: 'Vergiftetes Tier', icon: '☠️', color: '#8e44ad' },
chemikalie: { label: 'Chemikalie', icon: '⚗️', color: '#c0392b' },
andere: { label: 'Andere Gefahr', icon: '⚠️', 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">📍 Mein Standort</button>
<button class="btn btn-danger" id="poison-btn-report"> Giftköder melden</button>
</div>
<div id="poison-map"
style="height:280px;border-radius:var(--radius-md);overflow:hidden;
margin-bottom:var(--space-4);background:var(--c-surface-2)">
</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);
await _loadLeaflet();
_initMap();
await _locateAndLoad();
}
// ----------------------------------------------------------
// LEAFLET DYNAMISCH LADEN
// ----------------------------------------------------------
async function _loadLeaflet() {
if (_leafletLoaded || window.L) { _leafletLoaded = true; return; }
if (!document.querySelector('link[href*="leaflet"]')) {
const link = document.createElement('link');
link.rel = 'stylesheet';
link.href = 'https://unpkg.com/leaflet@1.9.4/dist/leaflet.css';
document.head.appendChild(link);
}
await new Promise((resolve, reject) => {
if (document.querySelector('script[src*="leaflet"]')) { resolve(); return; }
const s = document.createElement('script');
s.src = 'https://unpkg.com/leaflet@1.9.4/dist/leaflet.js';
s.onload = resolve;
s.onerror = reject;
document.head.appendChild(s);
});
_leafletLoaded = true;
}
// ----------------------------------------------------------
// KARTE INITIALISIEREN
// ----------------------------------------------------------
function _initMap() {
const mapEl = document.getElementById('poison-map');
if (!mapEl || !window.L || _map) return;
_map = L.map('poison-map', { zoomControl: true })
.setView([51.1657, 10.4515], 6); // Deutschland-Mitte
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
attribution : '© <a href="https://openstreetmap.org">OpenStreetMap</a>',
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);
_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 {
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 icon = L.divIcon({
className : '',
html : `<div style="
background:${typ.color};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">${typ.icon}</div>`,
iconSize : [34, 34],
iconAnchor : [17, 17],
});
const distStr = 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>${typ.icon} ${typ.label}</b><br>
${r.beschreibung ? _escape(r.beschreibung.slice(0, 80)) + '<br>' : ''}
<small>📍 ${distStr} entfernt</small><br>
<small>📅 ${_fmtDate(r.created_at)}</small>
${r.bestaetigt ? '<br><small>✅ 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 : '✅',
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);
});
});
}
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="font-size:2rem;line-height:1;flex-shrink:0">${typ.icon}</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 class="badge"
style="background:${typ.color};color:#fff">${typ.label}</span>
${r.bestaetigt
? '<span class="badge badge-success">✅ 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)">
${_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)} ·
läuft ab ${_fmtDate(r.expires_at)}
</div>
</div>
</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">
${typ.icon} ${typ.label}
</span>
${r.bestaetigt ? '<span class="badge badge-success">✅ Bestätigt</span>' : ''}
</div>
${r.beschreibung
? `<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)}</div>
<div>📅 Gemeldet: ${_fmtDate(r.created_at)}</div>
<div> Läuft ab: ${_fmtDate(r.expires_at)}</div>
${r.melder_name ? `<div>👤 Gemeldet von: ${_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">✅ 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: `${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', async () => {
const ok = await UI.modal.confirm({
title : 'Meldung als erledigt markieren?',
message: 'Das Problem wurde beseitigt oder die Meldung war fehlerhaft.',
confirmText: 'Erledigt markieren',
});
if (!ok) return;
try {
await API.poison.resolve(r.id);
_reports = _reports.filter(x => x.id !== r.id);
_markers.splice(_reports.length, 1); // cleanup (wird bei _renderMarkers neu gesetzt)
_renderMarkers();
_renderList();
_updateBadge(_reports.length);
UI.toast.success('Meldung als erledigt markiert.');
} catch (err) {
UI.toast.error(err.message || 'Fehler.');
}
});
}
// ----------------------------------------------------------
// 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, { icon, label }]) =>
`<option value="${val}">${icon} ${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 style="display:flex;gap:var(--space-2);align-items:center">
<input class="form-control" type="text" id="pf-lat-disp"
placeholder="Breite" readonly style="flex:1">
<input class="form-control" type="text" id="pf-lon-disp"
placeholder="Länge" readonly style="flex:1">
<button type="button" class="btn btn-secondary" id="pf-gps-btn"
title="GPS-Standort ermitteln">📍</button>
</div>
<input type="hidden" name="lat" id="pf-lat">
<input type="hidden" name="lon" id="pf-lon">
<small id="pf-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">
Beschreibung
<span style="color:var(--c-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 style="color:var(--c-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>
<div style="display:flex;gap:var(--space-2);margin-top:var(--space-4)">
<button type="button" class="btn btn-secondary flex-1"
id="pf-cancel">Abbrechen</button>
<button type="submit" class="btn btn-danger flex-1">
Meldung abschicken
</button>
</div>
</form>
`;
UI.modal.open({ title: '⚠️ Giftköder melden', body });
// Standort vorausfüllen wenn bekannt
if (_userPos) {
document.getElementById('pf-lat').value = _userPos.lat;
document.getElementById('pf-lon').value = _userPos.lon;
document.getElementById('pf-lat-disp').value = _userPos.lat.toFixed(6);
document.getElementById('pf-lon-disp').value = _userPos.lon.toFixed(6);
}
// GPS-Button
document.getElementById('pf-gps-btn')?.addEventListener('click', async () => {
const btn = document.getElementById('pf-gps-btn');
UI.setLoading(btn, true);
try {
const pos = await API.getLocation({ timeout: 10000, enableHighAccuracy: true });
document.getElementById('pf-lat').value = pos.lat;
document.getElementById('pf-lon').value = pos.lon;
document.getElementById('pf-lat-disp').value = pos.lat.toFixed(6);
document.getElementById('pf-lon-disp').value = pos.lon.toFixed(6);
document.getElementById('pf-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('#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 = 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;
}
await UI.asyncButton(submitBtn, async () => {
const payload = {
lat : parseFloat(fd.lat),
lon : parseFloat(fd.lon),
typ : fd.typ,
beschreibung : fd.beschreibung || null,
};
const created = await API.poison.report(payload);
// Foto hochladen
if (photoInput?.files[0]) {
try {
const formData = new FormData();
formData.append('file', photoInput.files[0]);
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 (für sofortige Anzeige)
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('Giftköder gemeldet! Danke für die Warnung.');
UI.modal.close();
});
});
}
// ----------------------------------------------------------
// 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));
}
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'
});
}
function _escape(str) {
if (!str) return '';
return str
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
}
// ----------------------------------------------------------
// PUBLIC
// ----------------------------------------------------------
return { init, refresh, openNew };
})();