Feature: Gassi-Treffen — Orts-Autocomplete, Modal-UX, Teilnehmerliste, Karten-Fix

- Orts-/POI-Suche mit GPS und Vorschlägen (wie Tagebuch) + Mini-Karte im Formular
- Stornieren/Austreten als Zwei-Klick-Pattern (kein UI.modal.confirm in Modals)
- Teilnehmerliste im Detail-Modal mit User-Namen und Hunden
- Leaflet invalidateSize auf 150ms (Memory-Regel), _loadLeaflet robuster
- /api/walks/nearby Backend-Endpunkt (vor /{walk_id} Route)
- SW by-v203, APP_VER 169
This commit is contained in:
rene 2026-04-18 13:52:20 +02:00
parent 80e3f0dc0d
commit e3230237a2
4 changed files with 379 additions and 75 deletions

View file

@ -1,6 +1,7 @@
"""BAN YARO — Gassi-Treffen""" """BAN YARO — Gassi-Treffen"""
import math import math
import httpx
from datetime import date from datetime import date
from fastapi import APIRouter, Depends, HTTPException from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel from pydantic import BaseModel
@ -20,6 +21,10 @@ def _haversine(lat1, lon1, lat2, lon2):
return 2 * R * math.asin(math.sqrt(a)) return 2 * R * math.asin(math.sqrt(a))
def _haversine_km(lat1, lon1, lat2, lon2):
return _haversine(lat1, lon1, lat2, lon2) / 1000
# ------------------------------------------------------------------ # ------------------------------------------------------------------
# Schemas # Schemas
# ------------------------------------------------------------------ # ------------------------------------------------------------------
@ -103,6 +108,79 @@ async def create_walk(data: WalkCreate, user=Depends(get_current_user)):
return {**dict(row), 'teilnehmer_count': 0} 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 # GET /api/walks/{id} — Detail mit Teilnehmerliste
# ------------------------------------------------------------------ # ------------------------------------------------------------------

View file

@ -223,12 +223,13 @@ const API = (() => {
if (lat !== null) { params.set('lat', lat); params.set('lon', lon); } if (lat !== null) { params.set('lat', lat); params.set('lon', lon); }
return get(`/walks?${params}`); return get(`/walks?${params}`);
}, },
get(id) { return get(`/walks/${id}`); }, get(id) { return get(`/walks/${id}`); },
create(data) { return post('/walks', data); }, create(data) { return post('/walks', data); },
update(id, data) { return patch(`/walks/${id}`, data); }, update(id, data) { return patch(`/walks/${id}`, data); },
cancel(id) { return del(`/walks/${id}`); }, cancel(id) { return del(`/walks/${id}`); },
join(id, dogIds) { return post(`/walks/${id}/join`, { dog_ids: dogIds }); }, join(id, dogIds) { return post(`/walks/${id}/join`, { dog_ids: dogIds }); },
leave(id) { return del(`/walks/${id}/join`); }, leave(id) { return del(`/walks/${id}/join`); },
nearby(lat, lon) { return get(`/walks/nearby?lat=${lat}&lon=${lon}`); },
}; };
// ---------------------------------------------------------- // ----------------------------------------------------------

View file

@ -40,6 +40,12 @@ window.Page_walks = (() => {
return iso < new Date().toISOString().slice(0, 10); 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 // INIT
// ---------------------------------------------------------- // ----------------------------------------------------------
@ -112,8 +118,8 @@ window.Page_walks = (() => {
if (view === 'karte') { if (view === 'karte') {
_loadLeaflet().then(() => { _loadLeaflet().then(() => {
_initMap(); _initMap();
setTimeout(() => _map?.invalidateSize(), 50); setTimeout(() => _map?.invalidateSize(), 150);
setTimeout(() => _map?.invalidateSize(), 300); setTimeout(() => _map?.invalidateSize(), 400);
}); });
} }
} }
@ -207,17 +213,26 @@ window.Page_walks = (() => {
// ---------------------------------------------------------- // ----------------------------------------------------------
// Leaflet + Karte // Leaflet + Karte
// ---------------------------------------------------------- // ----------------------------------------------------------
async function _loadLeaflet() { function _loadLeaflet() {
if (_leafletLoaded || window.L) { _leafletLoaded = true; return; } if (window.L) { _leafletLoaded = true; return Promise.resolve(); }
const link = document.createElement('link'); return new Promise((resolve, reject) => {
link.rel = 'stylesheet'; link.href = '/css/leaflet.css'; const cssLoaded = document.querySelector('link[href*="leaflet"]')
document.head.appendChild(link); ? Promise.resolve()
await new Promise(resolve => { : new Promise(res => {
const s = document.createElement('script'); const link = document.createElement('link');
s.src = '/js/leaflet.js'; s.onload = resolve; link.rel = 'stylesheet'; link.href = '/css/leaflet.css';
document.head.appendChild(s); 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() { function _initMap() {
@ -235,6 +250,7 @@ window.Page_walks = (() => {
_markers.forEach(m => m.remove()); _markers.forEach(m => m.remove());
_markers = []; _markers = [];
_data.forEach(w => { _data.forEach(w => {
if (!w.lat || !w.lon) return;
const isFull = w.status === 'voll' || w.teilnehmer_count >= w.max_teilnehmer; const isFull = w.status === 'voll' || w.teilnehmer_count >= w.max_teilnehmer;
const color = _isToday(w.datum) ? 'var(--c-primary)' : (isFull ? '#6B7280' : '#22C55E'); const color = _isToday(w.datum) ? 'var(--c-primary)' : (isFull ? '#6B7280' : '#22C55E');
const icon = L.divIcon({ const icon = L.divIcon({
@ -271,6 +287,7 @@ window.Page_walks = (() => {
const isPast = _isPast(walk.datum); const isPast = _isPast(walk.datum);
const spots = walk.max_teilnehmer - walk.teilnehmer_count; const spots = walk.max_teilnehmer - walk.teilnehmer_count;
// Teilnehmerliste
const teilnehmerHTML = walk.teilnehmer?.length const teilnehmerHTML = walk.teilnehmer?.length
? walk.teilnehmer.map(t => ` ? walk.teilnehmer.map(t => `
<div class="walks-participant"> <div class="walks-participant">
@ -300,20 +317,35 @@ window.Page_walks = (() => {
` : ''} ` : ''}
<div class="walks-detail-section"> <div class="walks-detail-section">
<div class="walks-detail-section-label">Teilnehmer</div> <div class="walks-detail-section-label">${UI.icon('users')} Teilnehmer (${walk.teilnehmer_count}/${walk.max_teilnehmer})</div>
${teilnehmerHTML} ${teilnehmerHTML}
</div> </div>
<p style="color:var(--c-text-muted);font-size:0.8rem;margin-top:var(--space-4)"> <p style="color:var(--c-text-muted);font-size:0.8rem;margin-top:var(--space-4)">
Veranstaltet von ${_esc(walk.veranstalter_name || 'Unbekannt')} Veranstaltet von ${_esc(walk.veranstalter_name || 'Unbekannt')}
</p> </p>
${isOwn && !isPast ? `
<div id="wd-cancel-wrap" style="margin-top:var(--space-3)">
<button type="button" class="btn btn-ghost btn-sm" id="wd-cancel-walk"
style="color:var(--c-danger);width:100%">
${UI.icon('x-circle')} Treffen stornieren
</button>
</div>` : ''}
${isJoined && !isOwn ? `
<div id="wd-leave-wrap" style="margin-top:var(--space-3)">
<button type="button" class="btn btn-ghost btn-sm" id="wd-leave"
style="color:var(--c-danger);width:100%">
${UI.icon('sign-out')} Nicht mehr teilnehmen
</button>
</div>` : ''}
`; `;
let footer; let footer;
if (isOwn) { if (isOwn) {
footer = ` footer = `
<button type="button" class="btn btn-ghost btn-sm" id="wd-cancel-walk" style="color:var(--c-danger)">Stornieren</button> <button type="button" class="btn btn-secondary flex-1" id="wd-edit">${UI.icon('pencil-simple')} Bearbeiten</button>
<button type="button" class="btn btn-secondary flex-1" id="wd-edit">Bearbeiten</button>
<button type="button" class="btn btn-primary flex-1" id="wd-close">Schließen</button> <button type="button" class="btn btn-primary flex-1" id="wd-close">Schließen</button>
`; `;
} else if (!_appState.user) { } else if (!_appState.user) {
@ -323,7 +355,6 @@ window.Page_walks = (() => {
`; `;
} else if (isJoined) { } else if (isJoined) {
footer = ` footer = `
<button type="button" class="btn btn-ghost btn-sm" id="wd-leave" style="color:var(--c-danger)">Nicht mehr teilnehmen</button>
<button type="button" class="btn btn-primary flex-1" id="wd-close">Schließen</button> <button type="button" class="btn btn-primary flex-1" id="wd-close">Schließen</button>
`; `;
} else if (isPast || isFull) { } else if (isPast || isFull) {
@ -349,13 +380,24 @@ window.Page_walks = (() => {
_showEditForm(walk); _showEditForm(walk);
}); });
// Stornieren: Zwei-Klick-Pattern (kein UI.modal.confirm im Modal)
let _cancelPending = false;
document.getElementById('wd-cancel-walk')?.addEventListener('click', async () => { document.getElementById('wd-cancel-walk')?.addEventListener('click', async () => {
const ok = await UI.modal.confirm({ const btn = document.getElementById('wd-cancel-walk');
title: 'Treffen stornieren?', if (!_cancelPending) {
message: 'Alle Teilnehmer werden benachrichtigt. Nicht rückgängig.', _cancelPending = true;
confirmText: 'Stornieren', danger: true, btn.textContent = 'Wirklich stornieren? (nochmal tippen)';
}); btn.style.fontWeight = 'var(--weight-semibold)';
if (!ok) return; setTimeout(() => {
_cancelPending = false;
if (btn) {
btn.innerHTML = `${UI.icon('x-circle')} Treffen stornieren`;
btn.style.fontWeight = '';
}
}, 3000);
return;
}
_cancelPending = false;
try { try {
await API.walks.cancel(walk.id); await API.walks.cancel(walk.id);
_data = _data.filter(w => w.id !== walk.id); _data = _data.filter(w => w.id !== walk.id);
@ -371,13 +413,24 @@ window.Page_walks = (() => {
_showJoinForm(walk); _showJoinForm(walk);
}); });
// Austreten: Zwei-Klick-Pattern
let _leavePending = false;
document.getElementById('wd-leave')?.addEventListener('click', async () => { document.getElementById('wd-leave')?.addEventListener('click', async () => {
const ok = await UI.modal.confirm({ const btn = document.getElementById('wd-leave');
title: 'Nicht mehr teilnehmen?', if (!_leavePending) {
message: `Du verlässt „${walk.titel}".`, _leavePending = true;
confirmText: 'Austreten', btn.textContent = 'Wirklich austreten? (nochmal tippen)';
}); btn.style.fontWeight = 'var(--weight-semibold)';
if (!ok) return; setTimeout(() => {
_leavePending = false;
if (btn) {
btn.innerHTML = `${UI.icon('sign-out')} Nicht mehr teilnehmen`;
btn.style.fontWeight = '';
}
}, 3000);
return;
}
_leavePending = false;
try { try {
const res = await API.walks.leave(walk.id); const res = await API.walks.leave(walk.id);
const idx = _data.findIndex(w => w.id === walk.id); const idx = _data.findIndex(w => w.id === walk.id);
@ -408,23 +461,26 @@ window.Page_walks = (() => {
${_fmtDate(walk.datum)} um ${walk.uhrzeit} Uhr<br> ${_fmtDate(walk.datum)} um ${walk.uhrzeit} Uhr<br>
${walk.ort_name ? `${UI.icon('map-pin')} ${_esc(walk.ort_name)}` : ''} ${walk.ort_name ? `${UI.icon('map-pin')} ${_esc(walk.ort_name)}` : ''}
</p> </p>
<div class="form-group"> <form id="join-form" autocomplete="off">
<label class="form-label">Mit welchen Hunden?</label> <div class="form-group">
${dogsHtml} <label class="form-label">Mit welchen Hunden?</label>
</div> ${dogsHtml}
</div>
</form>
`; `;
const footer = ` const footer = `
<button type="button" class="btn btn-secondary flex-1" id="join-cancel">Abbrechen</button> <button type="button" class="btn btn-secondary flex-1" id="join-cancel">Abbrechen</button>
<button type="button" class="btn btn-primary flex-1" id="join-confirm">${UI.icon('dog')} Mitmachen</button> <button type="submit" form="join-form" class="btn btn-primary flex-1" id="join-confirm">${UI.icon('dog')} Mitmachen</button>
`; `;
UI.modal.open({ title: `Treffen beitreten`, body, footer }); UI.modal.open({ title: `Treffen beitreten`, body, footer });
document.getElementById('join-cancel')?.addEventListener('click', UI.modal.close); document.getElementById('join-cancel')?.addEventListener('click', UI.modal.close);
document.getElementById('join-confirm')?.addEventListener('click', async () => { document.getElementById('join-form')?.addEventListener('submit', async e => {
const btn = document.getElementById('join-confirm'); e.preventDefault();
const btn = document.getElementById('join-confirm');
const checked = [...document.querySelectorAll('[name="dog"]:checked')]; const checked = [...document.querySelectorAll('[name="dog"]:checked')];
const dogIds = checked.map(cb => parseInt(cb.value)); const dogIds = checked.map(cb => parseInt(cb.value));
@ -441,7 +497,7 @@ window.Page_walks = (() => {
} }
// ---------------------------------------------------------- // ----------------------------------------------------------
// Treffen erstellen // Treffen erstellen / bearbeiten — Formular
// ---------------------------------------------------------- // ----------------------------------------------------------
function _showCreateForm(prefill = {}) { function _showCreateForm(prefill = {}) {
const today = new Date().toISOString().slice(0, 10); const today = new Date().toISOString().slice(0, 10);
@ -456,6 +512,13 @@ window.Page_walks = (() => {
const isEdit = !!walk; const isEdit = !!walk;
const v = walk || defaults; const v = walk || defaults;
// Location-State (verwaltet außerhalb des DOM)
let _locLat = v.lat != null ? parseFloat(v.lat) : null;
let _locLon = v.lon != null ? parseFloat(v.lon) : null;
let _locName = v.ort_name || null;
const _pinSvg = '<svg xmlns="http://www.w3.org/2000/svg" width="28" height="36" viewBox="0 0 32 40"><path d="M16 0C7.163 0 0 7.163 0 16c0 10 16 24 16 24S32 26 32 16C32 7.163 24.837 0 16 0z" fill="#C4843A"/><circle cx="16" cy="16" r="7" fill="white"/></svg>';
const body = ` const body = `
<form id="walk-form" autocomplete="off"> <form id="walk-form" autocomplete="off">
@ -479,20 +542,42 @@ window.Page_walks = (() => {
</div> </div>
</div> </div>
<div class="form-group"> <div class="form-group" id="wf-location-group">
<label class="form-label">Treffpunkt</label> <label class="form-label">Treffpunkt</label>
<div style="display:flex;gap:var(--space-2)">
<input class="form-control" type="text" name="ort_name" <!-- Mini-Karte -->
value="${_esc(v.ort_name || '')}" <div style="position:relative">
placeholder="z. B. Parkeingang Nordseite, U-Bahn Volkspark" <div id="wf-map-wrap" style="border-radius:var(--radius-md);overflow:hidden;height:180px;background:var(--c-surface-2)"></div>
style="flex:1">
<button type="button" class="btn btn-secondary" id="walk-gps-btn" title="GPS">${UI.icon('map-pin')}</button>
</div> </div>
<input type="hidden" name="lat" id="walk-lat" value="${v.lat || ''}">
<input type="hidden" name="lon" id="walk-lon" value="${v.lon || ''}"> <!-- Ort-Chip -->
<small id="walk-gps-hint" style="color:var(--c-text-secondary)"> <div style="margin-top:var(--space-2)">
${v.lat ? `${UI.icon('check')} Position gespeichert` : 'GPS-Button für aktuellen Standort'} <div id="wf-location-chip-wrap" style="${_locName ? '' : 'display:none'}">
</small> <div class="diary-location-chip">
${UI.icon('map-pin')}
<span id="wf-location-label">${_esc(_locName || '')}</span>
<button type="button" id="wf-location-clear" aria-label="Name entfernen">
${UI.icon('x')}
</button>
</div>
</div>
<div style="display:flex;gap:var(--space-2);margin-top:var(--space-2)">
<button type="button" class="btn btn-danger btn-sm" id="wf-coords-clear">Ort entfernen</button>
<button type="button" class="btn btn-secondary flex-1" id="wf-location-btn">
${UI.icon('map-pin')}
<span id="wf-location-btn-label">${_locLat ? 'POI suchen' : 'GPS → POI suchen'}</span>
</button>
</div>
<!-- Vorschläge -->
<div id="wf-location-suggestions" style="display:none;margin-top:var(--space-2)"></div>
</div>
<!-- Versteckte Koordinaten-Felder -->
<input type="hidden" name="lat" id="wf-lat" value="${_locLat || ''}">
<input type="hidden" name="lon" id="wf-lon" value="${_locLon || ''}">
<input type="hidden" name="ort_name" id="wf-ort-name" value="${_esc(_locName || '')}">
</div> </div>
<div class="form-group"> <div class="form-group">
@ -513,36 +598,178 @@ window.Page_walks = (() => {
const footer = ` const footer = `
<div style="display:flex;flex-direction:column;gap:var(--space-2);width:100%"> <div style="display:flex;flex-direction:column;gap:var(--space-2);width:100%">
<button type="submit" form="walk-form" class="btn btn-primary" style="width:100%"> <button type="submit" form="walk-form" class="btn btn-primary" style="width:100%">
${isEdit ? 'Speichern' : `${UI.icon('calendar-dots')} Treffen planen`} ${isEdit ? `${UI.icon('floppy-disk')} Speichern` : `${UI.icon('calendar-dots')} Treffen planen`}
</button> </button>
<button type="button" class="btn btn-secondary" id="wf-cancel">Abbrechen</button> <button type="button" class="btn btn-secondary" id="wf-cancel">Abbrechen</button>
</div> </div>
`; `;
UI.modal.open({ title: isEdit ? 'Treffen bearbeiten' : `${UI.icon('dog')} Treffen planen`, body, footer }); UI.modal.open({ title: isEdit ? `${UI.icon('pencil-simple')} Treffen bearbeiten` : `${UI.icon('dog')} Treffen planen`, body, footer });
document.getElementById('wf-cancel')?.addEventListener('click', UI.modal.close); document.getElementById('wf-cancel')?.addEventListener('click', UI.modal.close);
document.getElementById('walk-gps-btn')?.addEventListener('click', async () => { // --- Mini-Karte ---
const btn = document.getElementById('walk-gps-btn'); let _miniMap = null, _miniMarker = null, _mapEditing = false;
UI.setLoading(btn, true);
try { const _mkIcon = () => L.divIcon({ html: _pinSvg, className: '', iconSize: [28, 36], iconAnchor: [14, 36] });
const pos = await API.getLocation({ enableHighAccuracy: true });
_userPos = pos; function _placeMarker(lat, lon) {
document.getElementById('walk-lat').value = pos.lat; if (_miniMarker) { _miniMarker.setLatLng([lat, lon]); return; }
document.getElementById('walk-lon').value = pos.lon; _miniMarker = L.marker([lat, lon], { draggable: false, icon: _mkIcon() }).addTo(_miniMap);
document.getElementById('walk-gps-hint').innerHTML = `${UI.icon('check')} Standort ermittelt`; _miniMarker.on('dragend', () => {
} catch { UI.toast.error('GPS nicht verfügbar.'); } const p = _miniMarker.getLatLng();
UI.setLoading(btn, false); _locLat = p.lat; _locLon = p.lng;
document.getElementById('wf-lat').value = _locLat;
document.getElementById('wf-lon').value = _locLon;
document.getElementById('wf-location-btn-label').textContent = 'POI suchen';
});
}
function _setCoords(lat, lon) {
_locLat = lat; _locLon = lon;
document.getElementById('wf-lat').value = lat;
document.getElementById('wf-lon').value = lon;
}
function _setName(name) {
_locName = name;
document.getElementById('wf-location-label').textContent = name;
document.getElementById('wf-location-chip-wrap').style.display = '';
document.getElementById('wf-ort-name').value = name;
document.getElementById('wf-location-suggestions').style.display = 'none';
}
_loadLeaflet().then(() => {
setTimeout(() => {
const lat = _locLat || 48.0, lon = _locLon || 11.9, zoom = _locLat ? 15 : 7;
_miniMap = L.map('wf-map-wrap', {
zoomControl: true, attributionControl: false,
dragging: true, scrollWheelZoom: false,
}).setView([lat, lon], zoom);
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', { maxZoom: 19 })
.addTo(_miniMap);
_miniMap.invalidateSize();
if (_locLat) {
_placeMarker(lat, lon);
_miniMarker.dragging.disable();
}
_miniMap.on('click', e => {
if (!_mapEditing) return;
_setCoords(e.latlng.lat, e.latlng.lng);
_placeMarker(_locLat, _locLon);
document.getElementById('wf-location-btn-label').textContent = 'POI suchen';
});
}, 150);
}); });
// Ort-Name-Chip entfernen
document.getElementById('wf-location-clear')?.addEventListener('click', () => {
_locName = null;
document.getElementById('wf-location-chip-wrap').style.display = 'none';
document.getElementById('wf-ort-name').value = '';
});
// Koordinaten + Name entfernen (Zwei-Klick)
const clearBtn = document.getElementById('wf-coords-clear');
let _clearPending = false;
clearBtn?.addEventListener('click', () => {
if (!_clearPending) {
_clearPending = true;
clearBtn.textContent = 'Wirklich entfernen?';
clearBtn.style.color = 'var(--c-danger)';
setTimeout(() => {
_clearPending = false;
if (clearBtn) {
clearBtn.textContent = 'Ort entfernen';
clearBtn.style.color = '';
}
}, 3000);
return;
}
_clearPending = false;
clearBtn.textContent = 'Ort entfernen';
clearBtn.style.color = '';
_locLat = null; _locLon = null; _locName = null;
document.getElementById('wf-lat').value = '';
document.getElementById('wf-lon').value = '';
document.getElementById('wf-ort-name').value = '';
document.getElementById('wf-location-chip-wrap').style.display = 'none';
document.getElementById('wf-location-suggestions').style.display = 'none';
document.getElementById('wf-location-btn-label').textContent = 'GPS → POI suchen';
if (_miniMarker) { _miniMarker.remove(); _miniMarker = null; }
if (_miniMap) { _miniMap.setView([48.0, 11.9], 7); }
});
// GPS → POI-Suche (wie diary.js)
async function _showSuggestions() {
const btn = document.getElementById('wf-location-btn');
UI.setLoading(btn, true);
try {
let lat = _locLat, lon = _locLon;
if (lat == null || lon == null) {
const pos = await API.getLocation({ enableHighAccuracy: true });
lat = pos.lat; lon = pos.lon;
_setCoords(lat, lon);
if (_miniMap) {
_miniMap.setView([lat, lon], 15);
_placeMarker(lat, lon);
if (_miniMarker) _miniMarker.dragging.disable();
}
document.getElementById('wf-location-btn-label').textContent = 'POI suchen';
}
const suggestions = _appState.user
? await API.walks.nearby(lat, lon)
: [];
const sugEl = document.getElementById('wf-location-suggestions');
if (!suggestions.length) {
sugEl.innerHTML = '<p style="font-size:var(--text-sm);color:var(--c-text-secondary);padding:var(--space-2) 0">Keine Orte in der Nähe gefunden.</p>';
} else {
sugEl.innerHTML = suggestions.map(s => `
<button type="button" class="diary-location-suggestion"
data-name="${_esc(s.name)}" data-lat="${s.lat}" data-lon="${s.lon}">
${UI.icon(_sourceIcon(s.source))}
<span>${_esc(s.name)}</span>
<small>${s.distance_m < 1000 ? s.distance_m + ' m' : (s.distance_m / 1000).toFixed(1) + ' km'}</small>
</button>`).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 => { document.getElementById('walk-form')?.addEventListener('submit', async e => {
e.preventDefault(); e.preventDefault();
const btn = document.querySelector('[form="walk-form"][type="submit"]') || e.target.querySelector('[type="submit"]'); const btn = document.querySelector('[form="walk-form"][type="submit"]') || e.target.querySelector('[type="submit"]');
const fd = UI.formData(e.target); const fd = UI.formData(e.target);
if (!fd.lat || !fd.lon) { // Koordinaten aus State lesen (nicht aus fd, da hidden)
UI.toast.warning('Bitte GPS-Position ermitteln.'); const lat = _locLat;
const lon = _locLon;
if (!lat || !lon) {
UI.toast.warning('Bitte einen Treffpunkt auf der Karte wählen oder GPS nutzen.');
return; return;
} }
@ -551,9 +778,9 @@ window.Page_walks = (() => {
titel: fd.titel?.trim(), titel: fd.titel?.trim(),
datum: fd.datum, datum: fd.datum,
uhrzeit: fd.uhrzeit, uhrzeit: fd.uhrzeit,
lat: parseFloat(fd.lat), lat: parseFloat(lat),
lon: parseFloat(fd.lon), lon: parseFloat(lon),
ort_name: fd.ort_name || null, ort_name: _locName || null,
max_teilnehmer: parseInt(fd.max_teilnehmer) || 10, max_teilnehmer: parseInt(fd.max_teilnehmer) || 10,
beschreibung: fd.beschreibung || null, beschreibung: fd.beschreibung || null,
}; };
@ -566,8 +793,6 @@ window.Page_walks = (() => {
} else { } else {
const created = await API.walks.create(payload); const created = await API.walks.create(payload);
_data.unshift({ ...created, teilnehmer_count: 0 }); _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! 🎉'); UI.toast.success('Treffen geplant! 🎉');
} }

View file

@ -3,7 +3,7 @@
Offline-Cache + Push Notifications + Tile-Cache Offline-Cache + Push Notifications + Tile-Cache
============================================================ */ ============================================================ */
const CACHE_VERSION = 'by-v202'; const CACHE_VERSION = 'by-v203';
const CACHE_STATIC = `${CACHE_VERSION}-static`; const CACHE_STATIC = `${CACHE_VERSION}-static`;
const CACHE_TILES = 'ban-yaro-tiles-v1'; // bleibt über SW-Updates erhalten const CACHE_TILES = 'ban-yaro-tiles-v1'; // bleibt über SW-Updates erhalten