diff --git a/VERSION b/VERSION index 41edc23..0948691 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1141 \ No newline at end of file +1155 \ No newline at end of file diff --git a/backend/main.py b/backend/main.py index df5124d..e954c83 100644 --- a/backend/main.py +++ b/backend/main.py @@ -511,11 +511,11 @@ async def sitemap(): urls = [ ("https://banyaro.app/", "weekly", "1.0"), ("https://banyaro.app/zuechter", "weekly", "0.9"), - ("https://banyaro.app/info", "monthly", "0.8"), - ("https://banyaro.app/presse", "monthly", "0.7"), - ("https://banyaro.app/wiki/rassen", "weekly", "0.8"), - ("https://banyaro.app/knigge", "monthly", "0.7"), ("https://banyaro.app/wurfboerse", "daily", "0.8"), + ("https://banyaro.app/wiki/rassen", "weekly", "0.8"), + ("https://banyaro.app/help", "monthly", "0.7"), + ("https://banyaro.app/knigge", "monthly", "0.7"), + ("https://banyaro.app/partner", "monthly", "0.6"), ] try: @@ -526,12 +526,6 @@ async def sitemap(): for r in rassen: urls.append((f"https://banyaro.app/wiki/rasse/{r['slug']}", "monthly", "0.7")) - events = conn.execute( - "SELECT id FROM events WHERE datum >= date('now') LIMIT 200" - ).fetchall() - for e in events: - urls.append((f"https://banyaro.app/api/events/{e['id']}", "weekly", "0.5")) - # Öffentliche Züchter-Profile breeders = conn.execute( "SELECT bp.zwingername FROM breeder_profiles bp " @@ -1348,12 +1342,47 @@ async def public_dog_page(dog_id: int): # ------------------------------------------------------------------ @app.get("/teilen/{token}") async def invite_page(token: str): - return FileResponse(f"{STATIC_DIR}/index.html", headers={"Cache-Control": "no-store, no-cache"}) + from fastapi.responses import HTMLResponse + with open(f"{STATIC_DIR}/index.html", encoding="utf-8") as _f: + _html = _f.read() + _html = _html.replace( + '', + '' + ) + return HTMLResponse(content=_html, headers={"Cache-Control": "no-store, no-cache"}) @app.get("/breeder/{zwingername}") async def breeder_profile_page(zwingername: str): - return FileResponse(f"{STATIC_DIR}/index.html", headers={"Cache-Control": "no-store, no-cache"}) + from fastapi.responses import HTMLResponse + from urllib.parse import unquote + from database import db as _db + import html as _html_mod + name = unquote(zwingername) + desc = f"Hundezüchter {_html_mod.escape(name)} auf Ban Yaro — Wurfbörse, Stammbaum und mehr." + try: + with _db() as conn: + bp = conn.execute( + "SELECT bp.rasse, bp.beschreibung FROM breeder_profiles bp " + "JOIN users u ON u.id = bp.user_id WHERE bp.zwingername=? AND u.rolle='breeder' LIMIT 1", + (name,) + ).fetchone() + if bp and bp["beschreibung"]: + desc = _html_mod.escape(bp["beschreibung"][:160]) + except Exception: + pass + with open(f"{STATIC_DIR}/index.html", encoding="utf-8") as _f: + _page = _f.read() + _page = _page.replace( + '', + f'' + ).replace( + 'Ban Yaro', + f'{_html_mod.escape(name)} — Hundezüchter auf Ban Yaro' + f'\n ' + f'\n ' + ) + return HTMLResponse(content=_page, headers={"Cache-Control": "no-store, no-cache"}) @app.get("/litters") @@ -1471,6 +1500,7 @@ async def ausweis_page(dog_id: int, request: Request): + Heimtierausweis – {esc(dog["name"])} +
@@ -1727,7 +1765,9 @@ async def help_page(): k for k in by_kat.keys() if k not in KAT_LABEL ] + import json as _json sections_html = "" + faq_items = [] for kat in kat_order: label = KAT_LABEL.get(kat, kat.replace("_", " ").title()) items = "".join( @@ -1736,6 +1776,13 @@ async def help_page(): for a in by_kat[kat] ) sections_html += f'

{_html.escape(label)}

{items}
' + for a in by_kat[kat]: + faq_items.append({ + "@type": "Question", + "name": a["frage"], + "acceptedAnswer": {"@type": "Answer", "text": a["antwort"]} + }) + faq_json_ld = _json.dumps(faq_items, ensure_ascii=False) html = f""" @@ -1744,6 +1791,8 @@ async def help_page(): Hilfe & FAQ — Ban Yaro + + + @@ -1851,6 +1905,8 @@ async def konto_loeschen(): Konto löschen — Ban Yaro + + + + +
+

Wurfbörse

+

Hundewelpen von geprüften Züchtern

+
+
+

{count_text}

+ {litters_html or '

Aktuell keine Würfe eingetragen.
Schau bald wieder vorbei!

'} + +
+ +""" + return HTMLResponse(content=html, headers={"Cache-Control": "max-age=1800"}) + + # SPA Fallback — ALLE nicht-API-Routen gehen zur index.html @app.get("/{full_path:path}") async def spa_fallback(full_path: str): diff --git a/backend/routes/forum.py b/backend/routes/forum.py index f8ee5e0..33eb726 100644 --- a/backend/routes/forum.py +++ b/backend/routes/forum.py @@ -586,6 +586,25 @@ async def toggle_like(data: LikeBody, user=Depends(get_current_user)): return {"liked": liked, "count": count} +# ------------------------------------------------------------------ +# GET /api/forum/likes/{target_type}/{target_id} — Wer hat geliked? +# ------------------------------------------------------------------ +@router.get("/likes/{target_type}/{target_id}") +async def list_likers(target_type: str, target_id: int): + if target_type not in _LIKE_TABLE: + raise HTTPException(400, "Ungültiger Typ.") + with db() as conn: + rows = conn.execute( + """SELECT u.name AS name, u.founder_number AS founder_number + FROM forum_likes fl + JOIN users u ON u.id = fl.user_id + WHERE fl.target_type = ? AND fl.target_id = ? + ORDER BY fl.id DESC""", + (target_type, target_id) + ).fetchall() + return [dict(r) for r in rows] + + # ------------------------------------------------------------------ # POST /api/forum/report # ------------------------------------------------------------------ diff --git a/backend/routes/osm.py b/backend/routes/osm.py index 5fc22b9..71ed5d2 100644 --- a/backend/routes/osm.py +++ b/backend/routes/osm.py @@ -423,3 +423,65 @@ async def submit_poi_edit(osm_id: str, data: PoiEditCreate, poi[data.field], data.new_value.strip(), user["id"]) ) return {"status": "pending", "message": "Korrektur wurde zur Prüfung eingereicht."} + + +# ------------------------------------------------------------------ +# Geocoding-Proxy GET /api/osm/geocode?q=… +# Nominatim-Rate-Limit: 1 req/s — serverseitig throttled +# ------------------------------------------------------------------ +_nominatim_sem = asyncio.Semaphore(1) +_nominatim_last = 0.0 + +@router.get('/geocode') +async def geocode_search(q: str = Query(..., min_length=2, max_length=200)): + import time + global _nominatim_last + async with _nominatim_sem: + wait = 1.1 - (time.monotonic() - _nominatim_last) + if wait > 0: + await asyncio.sleep(wait) + _nominatim_last = time.monotonic() + try: + async with httpx.AsyncClient(timeout=6.0) as client: + resp = await client.get( + 'https://nominatim.openstreetmap.org/search', + params={ + 'q': q, + 'format': 'jsonv2', + 'limit': 6, + 'countrycodes': 'de,at,ch', + 'addressdetails': 1, + 'accept-language': 'de', + }, + headers={ + 'User-Agent': _OVERPASS_UA, + 'Referer': 'https://banyaro.app/', + } + ) + resp.raise_for_status() + data = resp.json() + except Exception as e: + logger.warning("Nominatim-Fehler: %s", e) + raise HTTPException(502, "Geocoding nicht verfügbar") + + out = [] + for r in data[:6]: + addr = r.get('address', {}) + short = ( + addr.get('amenity') or addr.get('shop') or addr.get('leisure') or + addr.get('road') or addr.get('village') or addr.get('town') or + addr.get('city') or r.get('name') or + r.get('display_name', '').split(',')[0] + ) + city = addr.get('city') or addr.get('town') or addr.get('village') or addr.get('municipality') or '' + state = addr.get('state', '') + subtitle = ', '.join(filter(None, [city, state])) + out.append({ + 'lat': float(r['lat']), + 'lon': float(r['lon']), + 'name': short, + 'subtitle': subtitle, + 'full': r.get('display_name', ''), + 'type': r.get('type', ''), + }) + return out diff --git a/backend/static/css/components.css b/backend/static/css/components.css index 1f724ec..1f22ba8 100644 --- a/backend/static/css/components.css +++ b/backend/static/css/components.css @@ -3256,6 +3256,182 @@ html.modal-open { } } +/* Orts-Suche — Panel schiebt von oben rein wenn aktiv */ +.map-search-wrap { + position: absolute; + top: 0; + left: 0; + right: 0; + z-index: 1002; + padding: 8px 12px 4px; + background: rgba(255,255,255,0.97); + backdrop-filter: blur(8px); + box-shadow: 0 3px 14px rgba(0,0,0,0.18); + transform: translateY(-110%); + transition: transform 0.22s ease; + pointer-events: none; +} +.map-search-wrap.active { + transform: translateY(0); + pointer-events: auto; +} +:root[data-theme="dark"] .map-search-wrap { background: rgba(22,22,24,0.97); } +.map-search-row { + display: flex; + align-items: center; + gap: 8px; + background: var(--c-bg, #fff); + border-radius: var(--radius-full); + border: 1px solid var(--c-border, #e5e7eb); + padding: 8px 12px; +} +.map-search-input { + flex: 1; + border: none; + outline: none; + font-size: 15px; + font-family: inherit; + background: transparent; + color: var(--c-text); + min-width: 0; +} +.map-search-input::placeholder { color: #aaa; } +.map-search-clear { + background: none; + border: none; + padding: 4px; + cursor: pointer; + color: #999; + line-height: 1; + flex-shrink: 0; + border-radius: 50%; +} +.map-search-clear:hover { color: var(--c-text); background: var(--c-bg-subtle); } +.map-search-results { + background: var(--c-bg, #fff); + border-radius: 12px; + border: 1px solid var(--c-border, #e5e7eb); + margin-top: 6px; + margin-bottom: 4px; + overflow: hidden; + max-height: 240px; + overflow-y: auto; +} +.map-search-item { + padding: 10px 14px; + cursor: pointer; + border-bottom: 1px solid var(--c-border-light, rgba(0,0,0,0.05)); +} +.map-search-item:last-child { border-bottom: none; } +.map-search-item:hover, +.map-search-item:active { background: var(--c-primary-subtle, #fef3c7); } +.map-search-item-name { + font-size: 13px; + font-weight: 600; + color: var(--c-text); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} +.map-search-item-sub { + font-size: 11px; + color: var(--c-text-secondary); + margin-top: 1px; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} +.map-search-loading, +.map-search-empty { + padding: 12px 14px; + font-size: 13px; + color: var(--c-text-secondary); + text-align: center; +} + +/* Speed Dial — Ein Trigger-Button, Sub-Buttons fächern nach oben auf */ +.map-speed-dial { + position: absolute; + bottom: calc(var(--safe-bottom) + 82px); + right: 20px; + z-index: 1000; + display: flex; + flex-direction: column; + align-items: flex-end; + gap: var(--space-2); +} +.map-sd-items { + display: flex; + flex-direction: column-reverse; /* unterste Item = erstes im DOM */ + align-items: flex-end; + gap: var(--space-2); + pointer-events: none; +} +.map-speed-dial.open .map-sd-items { pointer-events: auto; } + +.map-sd-item { + display: flex; + align-items: center; + gap: 10px; + opacity: 0; + transform: translateY(8px) scale(0.88); + transition: opacity 0.16s ease, transform 0.16s ease; +} +.map-speed-dial.open .map-sd-item { opacity: 1; transform: translateY(0) scale(1); } +.map-speed-dial.open .map-sd-item:nth-child(1) { transition-delay: 0ms; } +.map-speed-dial.open .map-sd-item:nth-child(2) { transition-delay: 50ms; } +.map-speed-dial.open .map-sd-item:nth-child(3) { transition-delay: 100ms; } +.map-speed-dial.open .map-sd-item:nth-child(4) { transition-delay: 150ms; } +.map-speed-dial.open .map-sd-item:nth-child(5) { transition-delay: 200ms; } + +.map-sd-label { + background: rgba(20,20,20,0.72); + color: #fff; + font-size: 12px; + font-weight: 600; + padding: 5px 11px; + border-radius: var(--radius-full); + white-space: nowrap; + backdrop-filter: blur(4px); + pointer-events: none; + letter-spacing: 0.01em; +} +.map-sd-btn { + width: 46px; + height: 46px; + border-radius: 50%; + background: #fff; + color: #C4843A; + border: 2px solid rgba(196,132,58,0.25); + box-shadow: 0 2px 8px rgba(0,0,0,0.22); + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + font-size: 1.1rem; + flex-shrink: 0; + transition: background 0.12s, color 0.12s; + -webkit-tap-highlight-color: transparent; +} +.map-sd-btn:hover, +.map-sd-btn:active { background: #fef3c7; } +.map-sd-btn.active { background: #C4843A; color: #fff; border-color: #C4843A; } +.map-sd-btn.map-fab--pin.active { background: var(--c-danger); border-color: var(--c-danger); color: #fff; } +#map-radar-btn.active { background: #1d4ed8; color: #fff; border-color: #1d4ed8; } +#map-temp-btn.active { background: #dc2626; color: #fff; border-color: #dc2626; } + +.map-sd-trigger { + transition: background 0.15s, transform 0.2s ease; +} +.map-speed-dial.open .map-sd-trigger { + background: #6b4a20; + transform: rotate(90deg); +} +.map-sd-icon-open { display: block; } +.map-sd-icon-close { display: none; } +.map-speed-dial.open .map-sd-icon-open { display: none; } +.map-speed-dial.open .map-sd-icon-close { display: block; } + /* FAB-Gruppe rechts unten — direkt über dem Zurück-Button */ .map-fabs { position: absolute; diff --git a/backend/static/index.html b/backend/static/index.html index a85a5a8..da37ccd 100644 --- a/backend/static/index.html +++ b/backend/static/index.html @@ -86,14 +86,14 @@ Ban Yaro - + - - - - - + + + + + @@ -617,11 +617,11 @@ - - - - - + + + + + @@ -631,7 +631,7 @@ - + diff --git a/backend/static/js/api.js b/backend/static/js/api.js index de39835..7fd420e 100644 --- a/backend/static/js/api.js +++ b/backend/static/js/api.js @@ -45,9 +45,12 @@ const API = (() => { throw new APIError(msg, 0, 'network'); } - // Versions-Check: Server meldet neue Version → Banner anzeigen (einmalig) + // Versions-Check: Server meldet neue Version → beim nächsten navigate() aktualisieren. + // Ausnahme: _BY_SW_RELOAD = wir sind gerade von /force-update weitergeleitet worden. + // In dem Fall ist APP_VER kurzzeitig veraltet (SW-Cache läuft noch aus) — KEIN erneuter + // Pending setzen, sonst entsteht sofort ein Loop beim nächsten Seitenwechsel. const serverVer = response.headers.get('x-app-version'); - if (serverVer && serverVer !== APP_VER && !window._byUpdatePending) { + if (serverVer && serverVer !== APP_VER && !window._byUpdatePending && !window._BY_SW_RELOAD) { window._byUpdatePending = true; window._byNewVersion = serverVer; } @@ -439,6 +442,9 @@ const API = (() => { like(targetType, targetId) { return post('/forum/like', { target_type: targetType, target_id: targetId }); }, + likers(targetType, targetId) { + return get(`/forum/likes/${targetType}/${targetId}`); + }, report(targetType, targetId, grund) { return post('/forum/report', { target_type: targetType, target_id: targetId, grund }); }, diff --git a/backend/static/js/app.js b/backend/static/js/app.js index 187a0e1..ce91fdf 100644 --- a/backend/static/js/app.js +++ b/backend/static/js/app.js @@ -3,7 +3,7 @@ Router, State-Management, Navigation, Initialisierung. ============================================================ */ -const APP_VER = '1141'; // ← bei jedem Deploy mit Frontend-Änderungen erhöhen +const APP_VER = '1155'; // ← bei jedem Deploy mit Frontend-Änderungen erhöhen const APP_VERSION = '1.6.0'; // ← semantische Version, wird bei make release gesetzt window.APP_VER = APP_VER; // global verfügbar für andere Module (z.B. offline-indicator) window.APP_VERSION = APP_VERSION; diff --git a/backend/static/js/boot.js b/backend/static/js/boot.js index 9f54463..d1ef297 100644 --- a/backend/static/js/boot.js +++ b/backend/static/js/boot.js @@ -57,7 +57,10 @@ if ('serviceWorker' in navigator) { if (!sw) return; sw.addEventListener('statechange', function() { if (sw.state === 'activated') { - if (sessionStorage.getItem('by_skip_sw_reload')) return; + if (sessionStorage.getItem('by_skip_sw_reload')) { + sessionStorage.removeItem('by_skip_sw_reload'); // einmalig konsumieren + return; + } window.location.replace('/?_t=' + Date.now()); } }); diff --git a/backend/static/js/pages/diary.js b/backend/static/js/pages/diary.js index 653b117..686007c 100644 --- a/backend/static/js/pages/diary.js +++ b/backend/static/js/pages/diary.js @@ -1306,37 +1306,7 @@ window.Page_diary = (() => {
- - -
-
- -
- - -
-
-
- - ${UI.escape(entry?.location_name || '')} - -
-
-
- - -
- -
+
${dogPickerHtml}
@@ -1538,140 +1508,15 @@ window.Page_diary = (() => { let _locLat = (entry?.gps_lat != null) ? entry.gps_lat : null; let _locLon = (entry?.gps_lon != null) ? entry.gps_lon : null; let _locName = entry?.location_name || null; - let _miniMap = null, _miniMarker = null; - const _pinSvg = ''; - const _mkIcon = () => L.divIcon({ html: _pinSvg, className: '', iconSize: [32,40], iconAnchor: [16,40] }); - - function _setName(name) { - _locName = name; - document.getElementById('diary-location-label').textContent = name; - document.getElementById('diary-location-chip-wrap').style.display = ''; - document.getElementById('diary-location-suggestions').style.display = 'none'; - } - - function _placeMarker(lat, lon) { - if (_miniMarker) { _miniMarker.setLatLng([lat, lon]); return; } - _miniMarker = L.marker([lat, lon], { draggable: false, icon: _mkIcon() }).addTo(_miniMap); - _miniMarker.on('dragend', () => { - const p = _miniMarker.getLatLng(); _locLat = p.lat; _locLon = p.lng; - document.getElementById('diary-location-btn-label').textContent = 'POI suchen'; + // Location Picker (gemeinsame UI-Komponente) + setTimeout(() => { + const _diaryPicker = UI.locationPicker({ + containerId: 'diary-location-picker', + onSelect: (lat, lon, name) => { _locLat = lat; _locLon = lon; _locName = name; }, }); - } - - document.getElementById('diary-location-clear')?.addEventListener('click', () => { - _locName = null; - document.getElementById('diary-location-chip-wrap').style.display = 'none'; - }); - const _clearBtn = document.getElementById('diary-coords-clear'); - let _clearPending = false; - _clearBtn?.addEventListener('click', () => { - if (!_clearPending) { - _clearPending = true; - _clearBtn.textContent = 'Wirklich entfernen?'; - _clearBtn.style.color = 'var(--c-danger)'; - setTimeout(() => { - if (_clearPending) { - _clearPending = false; - _clearBtn.textContent = 'Ort entfernen'; - _clearBtn.style.color = 'var(--c-text-muted)'; - } - }, 3000); - return; - } - _clearPending = false; - _clearBtn.textContent = 'Ort entfernen'; - _clearBtn.style.color = 'var(--c-text-muted)'; - _locLat = null; _locLon = null; _locName = null; - document.getElementById('diary-location-chip-wrap').style.display = 'none'; - document.getElementById('diary-location-suggestions').style.display = 'none'; - document.getElementById('diary-location-btn-label').textContent = 'POI suchen'; - if (_miniMarker) { _miniMarker.remove(); _miniMarker = null; } - if (_miniMap) { _miniMap.setView([48.0, 11.9], 7); _setMapEditing(false); } - }); - - let _mapEditing = false; - - function _setMapEditing(on) { - _mapEditing = on; - const lbl = document.getElementById('diary-map-edit-label'); - if (lbl) lbl.textContent = on ? 'Fertig' : 'Position ändern'; - if (!_miniMap) return; - if (on) { - if (_miniMarker) _miniMarker.dragging.enable(); - } else { - if (_miniMarker) _miniMarker.dragging.disable(); - } - } - - document.getElementById('diary-map-edit-btn')?.addEventListener('click', () => { - _setMapEditing(!_mapEditing); - }); - - // Karte beim Formular-Open automatisch laden - UI.loadLeaflet().then(() => { - setTimeout(() => { - const lat = _locLat || 48.0, lon = _locLon || 11.9, zoom = _locLat ? 15 : 7; - _miniMap = L.map('diary-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(); // Lesemodus: kein Drag - } - // Klick nur im Edit-Modus - _miniMap.on('click', e => { - if (!_mapEditing) return; - _locLat = e.latlng.lat; _locLon = e.latlng.lng; - _placeMarker(_locLat, _locLon); - if (!_mapEditing) _miniMarker.dragging.disable(); - document.getElementById('diary-location-btn-label').textContent = 'POI suchen'; - }); - }, 150); - }); - - async function _showSuggestions() { - const btn = document.getElementById('diary-location-btn'); - UI.setLoading(btn, true); - try { - let lat = _locLat, lon = _locLon; - if (lat == null || lon == null) { - const pos = await API.getLocation(); - lat = pos.lat; lon = pos.lon; - _locLat = lat; _locLon = lon; - if (_miniMap) { _miniMap.setView([lat, lon], 15); _placeMarker(lat, lon); } - document.getElementById('diary-location-btn-label').textContent = 'POI suchen'; - } - const suggestions = await API.diary.nearby(_appState.activeDog.id, lat, lon); - const sugEl = document.getElementById('diary-location-suggestions'); - if (suggestions.length === 0) { - sugEl.innerHTML = '

Keine Orte in der Nähe gefunden.

'; - } else { - sugEl.innerHTML = suggestions.map(s => ` - `).join(''); - sugEl.querySelectorAll('.diary-location-suggestion').forEach(el => { - el.addEventListener('click', () => _setName(el.dataset.name)); - }); - } - sugEl.style.display = ''; - } catch (err) { - UI.toast.error(err?.message?.includes('GPS') || lat == null - ? 'GPS nicht verfügbar.' : 'Ortssuche fehlgeschlagen.'); - } finally { - UI.setLoading(btn, false); - } - } - - document.getElementById('diary-location-btn')?.addEventListener('click', _showSuggestions); + if (_locLat != null) _diaryPicker.setValue(_locLat, _locLon, _locName); + }, 50); document.getElementById('diary-form-delete')?.addEventListener('click', async () => { const ok = await UI.modal.confirm({ diff --git a/backend/static/js/pages/forum.js b/backend/static/js/pages/forum.js index e2dfe19..512ed6d 100644 --- a/backend/static/js/pages/forum.js +++ b/backend/static/js/pages/forum.js @@ -640,6 +640,17 @@ function _fmtDate(iso) { } catch (err) { UI.toast.error(err.message); } }); + // Liker-Liste anzeigen (Klick auf die Zahl) + const _thLikeCount = document.getElementById('thread-like-count'); + if (_thLikeCount) { + _thLikeCount.style.cursor = 'pointer'; + _thLikeCount.title = 'Wer hat geliked?'; + _thLikeCount.addEventListener('click', e => { + e.stopPropagation(); + if ((thread.likes || 0) > 0) _showLikers('thread', thread.id); + }); + } + // Report thread document.getElementById('thread-report-btn')?.addEventListener('click', () => { _showReportForm('thread', thread.id); @@ -812,9 +823,9 @@ function _fmtDate(iso) { // Like container.querySelectorAll('.forum-post-like:not([data-bound])').forEach(btn => { btn.dataset.bound = '1'; + const postId = parseInt(btn.dataset.postId); btn.addEventListener('click', async () => { if (!uid) { UI.toast.info('Bitte erst anmelden.'); return; } - const postId = parseInt(btn.dataset.postId); try { const res = await API.forum.like('post', postId); btn.classList.toggle('active', res.liked); @@ -822,6 +833,16 @@ function _fmtDate(iso) { if (countEl) countEl.textContent = res.count; } catch (err) { UI.toast.error(err.message); } }); + // Klick auf die Zahl → Liker-Liste + const countEl = btn.querySelector('.forum-post-like-count'); + if (countEl) { + countEl.style.cursor = 'pointer'; + countEl.title = 'Wer hat geliked?'; + countEl.addEventListener('click', e => { + e.stopPropagation(); + if (parseInt(countEl.textContent) > 0) _showLikers('post', postId); + }); + } }); // Report @@ -874,6 +895,28 @@ function _fmtDate(iso) { }); } + // ---------------------------------------------------------- + // Liker-Liste — wer hat geliked? + // ---------------------------------------------------------- + async function _showLikers(targetType, targetId) { + try { + const likers = await API.forum.likers(targetType, targetId); + if (!likers.length) { UI.toast.info('Noch keine Likes.'); return; } + const rows = likers.map(l => ` +
+
${UI.escape(_initial(l.name))}
+ ${UI.escape(l.name || 'Unbekannt')} + ${l.founder_number ? `Gründer #${l.founder_number}` : ''} +
`).join(''); + UI.modal.open({ + title: `${UI.icon('heart')} ${likers.length} ${likers.length === 1 ? 'Like' : 'Likes'}`, + body: `
${rows}
`, + footer: ``, + }); + document.getElementById('likers-close')?.addEventListener('click', UI.modal.close); + } catch (err) { UI.toast.error(err.message); } + } + // ---------------------------------------------------------- // Report-Formular // ---------------------------------------------------------- diff --git a/backend/static/js/pages/map.js b/backend/static/js/pages/map.js index 7b70409..00a4324 100644 --- a/backend/static/js/pages/map.js +++ b/backend/static/js/pages/map.js @@ -59,6 +59,7 @@ window.Page_map = (() => { treffpunkt: [], community: [], zuechter: [], + hotel: [], }; const VISIBLE_KEY = 'by_map_visible_v1'; @@ -130,6 +131,10 @@ window.Page_map = (() => { interactive: false, }; + // Orts-Suche + let _searchTimer = null; + let _searchMarker = null; + let _overpassTimer = null; let _overpassActive = false; let _ringClosing = false; @@ -210,13 +215,50 @@ window.Page_map = (() => {
-
- - ${App.hasPro(_appState?.user) ? ` - - - ` : ''} - + +
+
+ + + +
+ +
+ + +
+
+ +
+ Mein Standort + +
+
+ Ort suchen + +
+
+ Marker setzen + +
+ ${App.hasPro(_appState?.user) ? ` +
+ Regenradar + +
+
+ Temperatur + +
+ ` : ''} +
+
@@ -289,7 +331,19 @@ window.Page_map = (() => { _saveVisible(); }); + // Speed Dial + const _sdEl = document.getElementById('map-speed-dial'); + document.getElementById('map-sd-trigger')?.addEventListener('click', e => { + e.stopPropagation(); + _sdEl?.classList.toggle('open'); + }); + // Klick auf Karte / außerhalb schließt Speed Dial + document.getElementById('central-map')?.addEventListener('pointerdown', () => { + _sdEl?.classList.remove('open'); + }); + document.getElementById('map-locate-btn').addEventListener('click', () => { + _sdEl?.classList.remove('open'); if (_userPos) { _map?.setView([_userPos.lat, _userPos.lon], 16); } else { @@ -297,9 +351,54 @@ window.Page_map = (() => { } }); - document.getElementById('map-pin-btn').addEventListener('click', _togglePlacementMode); - document.getElementById('map-radar-btn')?.addEventListener('click', _toggleRadar); - document.getElementById('map-temp-btn')?.addEventListener('click', _toggleTemp); + document.getElementById('map-pin-btn').addEventListener('click', () => { + _sdEl?.classList.remove('open'); + _togglePlacementMode(); + }); + document.getElementById('map-radar-btn')?.addEventListener('click', () => { + _sdEl?.classList.remove('open'); + _toggleRadar(); + }); + document.getElementById('map-temp-btn')?.addEventListener('click', () => { + _sdEl?.classList.remove('open'); + _toggleTemp(); + }); + + // Suche — FAB öffnet Panel + document.getElementById('map-search-btn')?.addEventListener('click', () => { + document.getElementById('map-speed-dial')?.classList.remove('open'); + const wrap = document.getElementById('map-search-wrap'); + const isOpen = wrap?.classList.contains('active'); + if (isOpen) { + _clearSearch(); + } else { + wrap?.classList.add('active'); + setTimeout(() => document.getElementById('map-search-input')?.focus(), 60); + document.getElementById('map-search-btn')?.classList.add('active'); + } + }); + + const searchInput = document.getElementById('map-search-input'); + const searchResults = document.getElementById('map-search-results'); + + searchInput?.addEventListener('input', () => { + const q = searchInput.value.trim(); + clearTimeout(_searchTimer); + if (q.length < 2) { searchResults.style.display = 'none'; return; } + _searchTimer = setTimeout(() => _runSearch(q), 400); + }); + + searchInput?.addEventListener('keydown', e => { + if (e.key === 'Escape') _clearSearch(); + }); + + document.getElementById('map-search-clear')?.addEventListener('click', _clearSearch); + + // Klick auf Karte schließt Ergebnisse (aber behält Marker) + document.getElementById('central-map')?.addEventListener('pointerdown', () => { + searchResults.style.display = 'none'; + searchInput?.blur(); + }); } // ---------------------------------------------------------- @@ -907,7 +1006,7 @@ window.Page_map = (() => { const params = new URLSearchParams({ type: osmType, ...bbox }); try { const pois = await fetch(`/api/osm/pois?${params}`).then(r => r.json()); - const osmCount = _layers[layerKey].filter(m => !m._ownPlace).length; + const osmCount = (_layers[layerKey] || []).filter(m => !m._ownPlace).length; if (pois.length !== osmCount) _replaceOsmMarkers(layerKey, pois); _done++; const pct = Math.round(20 + _done / _total * 80); @@ -919,11 +1018,14 @@ window.Page_map = (() => { const pct = Math.round(20 + _done / _total * 80); const total = Object.values(_layers).flat().filter(m => !m._ownPlace).length; _setOsmStatus(pct < 100 ? `Scanne…` : `${total} Marker`, pct); - return _layers[layerKey].filter(m => !m._ownPlace).length; + return (_layers[layerKey] || []).filter(m => !m._ownPlace).length; } }); - await Promise.all(freshTasks); - _overpassActive = false; + try { + await Promise.all(freshTasks); + } finally { + _overpassActive = false; + } const totalLoaded = Object.values(_layers).flat().filter(m => !m._ownPlace).length; const allHidden = Object.keys(OSM_LAYER_MAP).every(k => _visible[k] === false); @@ -931,10 +1033,13 @@ window.Page_map = (() => { _setOsmStatus('Layer deaktiviert — Liste antippen', 100); } - // Wenn 0 OSM-Marker: Hintergrund-Fetch läuft noch — max 3× automatisch nachfragen - if (totalLoaded === 0 && zoom >= 14 && _autoRetryCount < 3) { + // Wenn 0 OSM-Marker: Hintergrund-Overpass-Fetch läuft noch — bis zu 8× nachfragen + // Overpass für alle Layer sequential: bis zu ~4min → Retries müssen das abdecken + if (totalLoaded === 0 && zoom >= 14 && _autoRetryCount < 8) { _autoRetryCount++; - const delay = _autoRetryCount * 30000; // 30s, 60s, 90s + // 10s, 20s, 35s, 50s, 70s, 90s, 120s, 150s + const delays = [10000, 20000, 35000, 50000, 70000, 90000, 120000, 150000]; + const delay = delays[_autoRetryCount - 1] || 120000; _setOsmStatus(`Neue Umgebung – Daten werden geladen…`); setTimeout(() => { if (!_overpassActive) _scheduleOsmLoad(); }, delay); } @@ -1944,6 +2049,92 @@ window.Page_map = (() => { } catch { /* still */ } } + // ---------------------------------------------------------- + // Orts-Suche (Nominatim-Proxy) + // ---------------------------------------------------------- + async function _runSearch(q) { + const resultsEl = document.getElementById('map-search-results'); + if (!resultsEl) return; + resultsEl.innerHTML = '
Suche…
'; + resultsEl.style.display = ''; + try { + const data = await API.get(`/osm/geocode?q=${encodeURIComponent(q)}`); + if (!data.length) { + resultsEl.innerHTML = '
Keine Ergebnisse
'; + return; + } + resultsEl.innerHTML = data.map((r, i) => + `
+
${UI.escape(r.name)}
+ ${r.subtitle ? `
${UI.escape(r.subtitle)}
` : ''} +
` + ).join(''); + resultsEl.querySelectorAll('.map-search-item').forEach(el => { + el.addEventListener('pointerdown', e => { + e.stopPropagation(); + const r = data[+el.dataset.i]; + _flyToResult(r); + document.getElementById('map-search-input').value = r.name; + document.getElementById('map-search-clear').style.display = ''; + resultsEl.style.display = 'none'; + }); + }); + } catch { + resultsEl.innerHTML = '
Suche nicht verfügbar
'; + } + } + + function _flyToResult(r) { + if (!_map || !window.L) return; + _searchMarker?.remove(); + _map.flyTo([r.lat, r.lon], 15, { duration: 1.0 }); + _searchMarker = L.marker([r.lat, r.lon], { + icon: L.divIcon({ + className: '', + html: `
+ + + + +
`, + iconSize: [32, 32], + iconAnchor: [16, 32], + }), + zIndexOffset: 1000, + }) + .addTo(_map) + .bindPopup(`
${UI.escape(r.name)}
+ ${r.subtitle ? `
${UI.escape(r.subtitle)}
` : ''} + `, { maxWidth: 240 }) + .openPopup(); + + setTimeout(() => { + document.getElementById('search-marker-close')?.addEventListener('click', () => { + _clearSearch(); + _searchMarker?.closePopup(); + }); + }, 50); + } + + function _clearSearch() { + const input = document.getElementById('map-search-input'); + const results = document.getElementById('map-search-results'); + const wrap = document.getElementById('map-search-wrap'); + const btn = document.getElementById('map-search-btn'); + if (input) { input.value = ''; input.blur(); } + if (results) results.style.display = 'none'; + wrap?.classList.remove('active'); + btn?.classList.remove('active'); + _searchMarker?.remove(); + _searchMarker = null; + clearTimeout(_searchTimer); + } + return { init, refresh, onDogChange, startRecording: _startRecording, stopRecording: _stopRecording, isRecording: () => _recActive }; })(); diff --git a/backend/static/js/pages/routes.js b/backend/static/js/pages/routes.js index 0f4e1af..bf3ccaa 100644 --- a/backend/static/js/pages/routes.js +++ b/backend/static/js/pages/routes.js @@ -1698,6 +1698,10 @@ window.Page_routes = (() => { center: [mid.lat, mid.lon], zoom: 15, zoomControl: false, attributionControl: false, }); + // Container hat im frisch eingefügten Fixed-Overlay erst jetzt seine + // finale Flex-Höhe — Leaflet muss sie neu vermessen, sonst lädt es nur + // oben Tiles und der Rest bleibt grau. + _navMap.invalidateSize(); // Route-Polylines: erledigt (grün) + ausstehend (orange) const doneLine = L.polyline([], { color: '#22c55e', weight: 5, opacity: 0.85 }).addTo(_navMap); @@ -1705,6 +1709,14 @@ window.Page_routes = (() => { _navMap.fitBounds(remainLine.getBounds(), { padding: [20, 20] }); _addRouteArrows(_navMap, track, '#3b82f6'); + // iOS rendert das Flex-Layout teils verzögert — nochmal neu vermessen + // und Ausschnitt erneut anpassen. + setTimeout(() => { + if (!_navMap) return; + _navMap.invalidateSize(); + _navMap.fitBounds(remainLine.getBounds(), { padding: [20, 20] }); + }, 250); + // Start/End-Marker (als Variable damit Reverse sie neu setzen kann) const mkPin = (p, color) => L.circleMarker([p.lat, p.lon], { radius: 8, color: '#fff', weight: 2, fillColor: color, fillOpacity: 1 diff --git a/backend/static/js/pages/walks.js b/backend/static/js/pages/walks.js index 2d2072e..dfa11ee 100644 --- a/backend/static/js/pages/walks.js +++ b/backend/static/js/pages/walks.js @@ -897,8 +897,6 @@ window.Page_walks = (() => { let _locLon = v.lon != null ? parseFloat(v.lon) : null; let _locName = v.ort_name || null; - const _pinSvg = ''; - const body = `
@@ -924,48 +922,7 @@ window.Page_walks = (() => {
- - -
-
- -
- - -
-
-
- ${UI.icon('map-pin')} - ${UI.escape(_locName || '')} - -
-
- -
- - -
- - - -
- - - - - +
@@ -996,157 +953,16 @@ window.Page_walks = (() => { document.getElementById('wf-cancel')?.addEventListener('click', UI.modal.close); - // --- Mini-Karte --- - let _miniMap = null, _miniMarker = null, _mapEditing = false; - - const _mkIcon = () => L.divIcon({ html: _pinSvg, className: '', iconSize: [28, 36], iconAnchor: [14, 36] }); - - function _placeMarker(lat, lon) { - if (_miniMarker) { _miniMarker.setLatLng([lat, lon]); return; } - _miniMarker = L.marker([lat, lon], { draggable: true, icon: _mkIcon() }).addTo(_miniMap); - _miniMarker.on('dragend', () => { - const p = _miniMarker.getLatLng(); - _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'; + // Location Picker + let _wfPicker = null; + setTimeout(() => { + _wfPicker = UI.locationPicker({ + containerId: 'wf-location-picker', + onSelect: (lat, lon, name) => { _locLat = lat; _locLon = lon; _locName = name; }, }); - } + if (_locLat != null) _wfPicker.setValue(_locLat, _locLon, _locName); + }, 50); - 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'; - } - - UI.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); - _miniMap.on('click', e => { - _setCoords(e.latlng.lat, e.latlng.lng); - _placeMarker(_locLat, _locLon); - document.getElementById('wf-location-btn-label').textContent = 'POI suchen'; - }); - document.getElementById('wf-map-pin-here')?.addEventListener('click', () => { - const c = _miniMap.getCenter(); - _setCoords(c.lat, c.lng); - _placeMarker(c.lat, c.lng); - 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 = '

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 => { diff --git a/backend/static/js/ui.js b/backend/static/js/ui.js index 9f50342..4b014c2 100644 --- a/backend/static/js/ui.js +++ b/backend/static/js/ui.js @@ -453,6 +453,10 @@ const UI = (() => { const isDark = document.documentElement.dataset.theme === 'dark'; if (isDark) tiles.getContainer().style.filter = 'brightness(0.7) invert(1) contrast(0.9) hue-rotate(200deg)'; } + // Safety-Net: Container-Größe nach Layout neu vermessen. Verhindert + // grau bleibende Bereiche wenn die Karte vor dem finalen Layout erstellt + // wird (z.B. in frisch eingefügten Overlays mit flex:1). + requestAnimationFrame(() => m.invalidateSize()); return m; }, @@ -873,12 +877,35 @@ const UI = (() => { coordsClear: `${p}-coords-clear`, suggestions: `${p}-suggestions`, pinHere: `${p}-pin-here`, + geoInput: `${p}-geo-input`, + geoClear: `${p}-geo-clear`, + geoResults: `${p}-geo-results`, }; // HTML in den Container rendern function _render(container) { container.innerHTML = `
+ +
+
+ + + +
+ +
'; + geoResults.style.display = ''; + } + try { + const data = await API.get(`/osm/geocode?q=${encodeURIComponent(q)}`); + if (!geoResults) return; + if (!data.length) { + geoResults.innerHTML = '
Keine Ergebnisse
'; + return; + } + geoResults.innerHTML = data.map((r, i) => ` +
+
${escape(r.name)}
+ ${r.subtitle ? `
${escape(r.subtitle)}
` : ''} +
`).join(''); + geoResults.querySelectorAll('[data-i]').forEach(el => { + el.addEventListener('pointerdown', e => { + e.preventDefault(); + const r = data[+el.dataset.i]; + _setCoords(r.lat, r.lon); + _setName(r.name); + if (_map) { + _map.flyTo([r.lat, r.lon], 15, { duration: 0.8 }); + _placeMarker(r.lat, r.lon); + } + const lbl = _getEl(ids.locBtnLabel); + if (lbl) lbl.textContent = 'POI suchen'; + geoInput.value = ''; + if (geoClear) geoClear.style.display = 'none'; + geoResults.style.display = 'none'; + onSelect?.(_lat, _lon, _name); + }); + }); + } catch { + if (geoResults) geoResults.innerHTML = '
Suche nicht verfügbar
'; + } + }, 400); + }); + + geoInput?.addEventListener('keydown', e => { + if (e.key === 'Escape') { + geoInput.value = ''; + if (geoClear) geoClear.style.display = 'none'; + if (geoResults) geoResults.style.display = 'none'; + } + }); + geoClear?.addEventListener('click', () => { + geoInput.value = ''; + geoClear.style.display = 'none'; + if (geoResults) geoResults.style.display = 'none'; + }); + _getEl(ids.mapWrap)?.addEventListener('pointerdown', () => { + if (geoResults) geoResults.style.display = 'none'; + geoInput?.blur(); + }); } // Container initialisieren diff --git a/backend/static/landing.html b/backend/static/landing.html index 59277a8..15f12fe 100644 --- a/backend/static/landing.html +++ b/backend/static/landing.html @@ -4,7 +4,7 @@ - + Ban Yaro — Die Hunde-App für Deutschland, Österreich & Schweiz @@ -149,6 +149,25 @@ } + +