diff --git a/backend/routes/walks.py b/backend/routes/walks.py index c14064f..5210b0d 100644 --- a/backend/routes/walks.py +++ b/backend/routes/walks.py @@ -1,6 +1,7 @@ """BAN YARO — Gassi-Treffen""" import math +import httpx from datetime import date from fastapi import APIRouter, Depends, HTTPException from pydantic import BaseModel @@ -20,6 +21,10 @@ def _haversine(lat1, lon1, lat2, lon2): return 2 * R * math.asin(math.sqrt(a)) +def _haversine_km(lat1, lon1, lat2, lon2): + return _haversine(lat1, lon1, lat2, lon2) / 1000 + + # ------------------------------------------------------------------ # Schemas # ------------------------------------------------------------------ @@ -103,6 +108,79 @@ async def create_walk(data: WalkCreate, user=Depends(get_current_user)): return {**dict(row), 'teilnehmer_count': 0} +# ------------------------------------------------------------------ +# GET /api/walks/nearby — POI-Suche für Treffpunkt-Autocomplete +# WICHTIG: Muss VOR /{walk_id} stehen (FastAPI Route-Reihenfolge) +# ------------------------------------------------------------------ +@router.get("/nearby") +async def nearby_places(lat: float, lon: float, user=Depends(get_current_user)): + results = [] + + with db() as conn: + # 1. User-eigene Places + places = conn.execute( + "SELECT name, typ, lat, lon FROM places WHERE lat IS NOT NULL", + ).fetchall() + for p in places: + km = _haversine_km(lat, lon, p["lat"], p["lon"]) + if km <= 5: + results.append({"name": p["name"], "type": p["typ"] or "place", + "lat": p["lat"], "lon": p["lon"], + "distance_m": int(km * 1000), "source": "places"}) + + # 2. Gecachte OSM-POIs + osm = conn.execute( + "SELECT name, type, lat, lon FROM osm_pois WHERE name IS NOT NULL AND name != ''" + ).fetchall() + for p in osm: + km = _haversine_km(lat, lon, p["lat"], p["lon"]) + if km <= 2: + results.append({"name": p["name"], "type": p["type"], + "lat": p["lat"], "lon": p["lon"], + "distance_m": int(km * 1000), "source": "osm"}) + + # 3. Overpass: benannte POIs in 1000m + try: + async with httpx.AsyncClient(timeout=6) as client: + q = ( + f'[out:json][timeout:6];' + f'(node["name"]["leisure"](around:1000,{lat},{lon});' + f' node["name"]["amenity"](around:1000,{lat},{lon});' + f' node["name"]["tourism"](around:1000,{lat},{lon});' + f' way["name"]["leisure"](around:1000,{lat},{lon});' + f');out center;' + ) + r = await client.post("https://overpass-api.de/api/interpreter", + data={"data": q}) + if r.status_code == 200: + for el in r.json().get("elements", []): + name = el.get("tags", {}).get("name") + if not name: + continue + elat = el.get("lat") or el.get("center", {}).get("lat") + elon = el.get("lon") or el.get("center", {}).get("lon") + if elat is None or elon is None: + continue + km = _haversine_km(lat, lon, elat, elon) + if km <= 1: + results.append({"name": name, "type": "osm", + "lat": elat, "lon": elon, + "distance_m": int(km * 1000), "source": "osm"}) + except Exception: + pass + + # Deduplizieren nach Name + Sortieren nach Distanz + seen = set() + unique = [] + for r in sorted(results, key=lambda x: x["distance_m"]): + key = r["name"].lower() + if key not in seen: + seen.add(key) + unique.append(r) + + return unique[:20] + + # ------------------------------------------------------------------ # GET /api/walks/{id} — Detail mit Teilnehmerliste # ------------------------------------------------------------------ diff --git a/backend/static/js/api.js b/backend/static/js/api.js index 25021d3..6e568a5 100644 --- a/backend/static/js/api.js +++ b/backend/static/js/api.js @@ -223,12 +223,13 @@ const API = (() => { if (lat !== null) { params.set('lat', lat); params.set('lon', lon); } return get(`/walks?${params}`); }, - get(id) { return get(`/walks/${id}`); }, - create(data) { return post('/walks', data); }, - update(id, data) { return patch(`/walks/${id}`, data); }, - cancel(id) { return del(`/walks/${id}`); }, - join(id, dogIds) { return post(`/walks/${id}/join`, { dog_ids: dogIds }); }, - leave(id) { return del(`/walks/${id}/join`); }, + get(id) { return get(`/walks/${id}`); }, + create(data) { return post('/walks', data); }, + update(id, data) { return patch(`/walks/${id}`, data); }, + cancel(id) { return del(`/walks/${id}`); }, + join(id, dogIds) { return post(`/walks/${id}/join`, { dog_ids: dogIds }); }, + leave(id) { return del(`/walks/${id}/join`); }, + nearby(lat, lon) { return get(`/walks/nearby?lat=${lat}&lon=${lon}`); }, }; // ---------------------------------------------------------- diff --git a/backend/static/js/pages/walks.js b/backend/static/js/pages/walks.js index 6ff0e77..172513e 100644 --- a/backend/static/js/pages/walks.js +++ b/backend/static/js/pages/walks.js @@ -40,6 +40,12 @@ window.Page_walks = (() => { return iso < new Date().toISOString().slice(0, 10); } + function _sourceIcon(source) { + if (source === 'places') return 'star'; + if (source === 'osm') return 'map-pin'; + return 'map-trifold'; + } + // ---------------------------------------------------------- // INIT // ---------------------------------------------------------- @@ -112,8 +118,8 @@ window.Page_walks = (() => { if (view === 'karte') { _loadLeaflet().then(() => { _initMap(); - setTimeout(() => _map?.invalidateSize(), 50); - setTimeout(() => _map?.invalidateSize(), 300); + setTimeout(() => _map?.invalidateSize(), 150); + setTimeout(() => _map?.invalidateSize(), 400); }); } } @@ -207,17 +213,26 @@ window.Page_walks = (() => { // ---------------------------------------------------------- // Leaflet + Karte // ---------------------------------------------------------- - async function _loadLeaflet() { - if (_leafletLoaded || window.L) { _leafletLoaded = true; return; } - const link = document.createElement('link'); - link.rel = 'stylesheet'; link.href = '/css/leaflet.css'; - document.head.appendChild(link); - await new Promise(resolve => { - const s = document.createElement('script'); - s.src = '/js/leaflet.js'; s.onload = resolve; - document.head.appendChild(s); + function _loadLeaflet() { + if (window.L) { _leafletLoaded = true; return Promise.resolve(); } + return new Promise((resolve, reject) => { + const cssLoaded = document.querySelector('link[href*="leaflet"]') + ? Promise.resolve() + : new Promise(res => { + const link = document.createElement('link'); + link.rel = 'stylesheet'; link.href = '/css/leaflet.css'; + link.onload = res; link.onerror = res; + document.head.appendChild(link); + }); + cssLoaded.then(() => { + if (document.querySelector('script[src*="leaflet.js"]')) { _leafletLoaded = true; resolve(); return; } + const s = document.createElement('script'); + s.src = '/js/leaflet.js'; + s.onload = () => { _leafletLoaded = true; resolve(); }; + s.onerror = reject; + document.head.appendChild(s); + }); }); - _leafletLoaded = true; } function _initMap() { @@ -235,6 +250,7 @@ window.Page_walks = (() => { _markers.forEach(m => m.remove()); _markers = []; _data.forEach(w => { + if (!w.lat || !w.lon) return; const isFull = w.status === 'voll' || w.teilnehmer_count >= w.max_teilnehmer; const color = _isToday(w.datum) ? 'var(--c-primary)' : (isFull ? '#6B7280' : '#22C55E'); const icon = L.divIcon({ @@ -271,6 +287,7 @@ window.Page_walks = (() => { const isPast = _isPast(walk.datum); const spots = walk.max_teilnehmer - walk.teilnehmer_count; + // Teilnehmerliste const teilnehmerHTML = walk.teilnehmer?.length ? walk.teilnehmer.map(t => `
Veranstaltet von ${_esc(walk.veranstalter_name || 'Unbekannt')}
+ + ${isOwn && !isPast ? ` +Keine Orte in der Nähe gefunden.
'; + } else { + sugEl.innerHTML = suggestions.map(s => ` + `).join(''); + sugEl.querySelectorAll('.diary-location-suggestion').forEach(el => { + el.addEventListener('click', () => { + const slat = parseFloat(el.dataset.lat); + const slon = parseFloat(el.dataset.lon); + _setCoords(slat, slon); + _setName(el.dataset.name); + if (_miniMap) { + _miniMap.setView([slat, slon], 16); + _placeMarker(slat, slon); + if (_miniMarker) _miniMarker.dragging.disable(); + } + }); + }); + } + sugEl.style.display = ''; + } catch (err) { + UI.toast.error(err?.message?.includes('GPS') || _locLat == null + ? 'GPS nicht verfügbar.' : 'Ortssuche fehlgeschlagen.'); + } finally { + UI.setLoading(btn, false); + } + } + + document.getElementById('wf-location-btn')?.addEventListener('click', _showSuggestions); + + // Formular absenden document.getElementById('walk-form')?.addEventListener('submit', async e => { e.preventDefault(); const btn = document.querySelector('[form="walk-form"][type="submit"]') || e.target.querySelector('[type="submit"]'); const fd = UI.formData(e.target); - if (!fd.lat || !fd.lon) { - UI.toast.warning('Bitte GPS-Position ermitteln.'); + // Koordinaten aus State lesen (nicht aus fd, da hidden) + const lat = _locLat; + const lon = _locLon; + + if (!lat || !lon) { + UI.toast.warning('Bitte einen Treffpunkt auf der Karte wählen oder GPS nutzen.'); return; } @@ -551,9 +778,9 @@ window.Page_walks = (() => { titel: fd.titel?.trim(), datum: fd.datum, uhrzeit: fd.uhrzeit, - lat: parseFloat(fd.lat), - lon: parseFloat(fd.lon), - ort_name: fd.ort_name || null, + lat: parseFloat(lat), + lon: parseFloat(lon), + ort_name: _locName || null, max_teilnehmer: parseInt(fd.max_teilnehmer) || 10, beschreibung: fd.beschreibung || null, }; @@ -566,8 +793,6 @@ window.Page_walks = (() => { } else { const created = await API.walks.create(payload); _data.unshift({ ...created, teilnehmer_count: 0 }); - // Beim eigenen neuen Treffen gleich beitreten? - // Nein — Veranstalter ist automatisch dabei (für Teilnehmer-Sicht) UI.toast.success('Treffen geplant! 🎉'); } diff --git a/backend/static/sw.js b/backend/static/sw.js index 770545e..64d26b1 100644 --- a/backend/static/sw.js +++ b/backend/static/sw.js @@ -3,7 +3,7 @@ Offline-Cache + Push Notifications + Tile-Cache ============================================================ */ -const CACHE_VERSION = 'by-v202'; +const CACHE_VERSION = 'by-v203'; const CACHE_STATIC = `${CACHE_VERSION}-static`; const CACHE_TILES = 'ban-yaro-tiles-v1'; // bleibt über SW-Updates erhalten