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:
parent
80e3f0dc0d
commit
e3230237a2
4 changed files with 379 additions and 75 deletions
|
|
@ -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
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
|
@ -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}`); },
|
||||||
};
|
};
|
||||||
|
|
||||||
// ----------------------------------------------------------
|
// ----------------------------------------------------------
|
||||||
|
|
|
||||||
|
|
@ -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! 🎉');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue