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:
parent
44b1451966
commit
cc36ead720
3 changed files with 780 additions and 3 deletions
|
|
@ -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")
|
||||||
|
|
|
||||||
|
|
@ -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}
|
||||||
|
|
|
||||||
614
backend/static/js/pages/poison.js
Normal file
614
backend/static/js/pages/poison.js
Normal 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, '&')
|
||||||
|
.replace(/</g, '<')
|
||||||
|
.replace(/>/g, '>')
|
||||||
|
.replace(/"/g, '"');
|
||||||
|
}
|
||||||
|
|
||||||
|
// ----------------------------------------------------------
|
||||||
|
// PUBLIC
|
||||||
|
// ----------------------------------------------------------
|
||||||
|
return { init, refresh, openNew };
|
||||||
|
|
||||||
|
})();
|
||||||
Loading…
Add table
Add a link
Reference in a new issue