diff --git a/.gitignore b/.gitignore index 28e4c9f..cbcf3ae 100644 --- a/.gitignore +++ b/.gitignore @@ -12,3 +12,4 @@ __pycache__/ /icons/ .claude/worktrees/ Ban Yaro - Google Play package/ +/unsplash/ diff --git a/MARKETING.md b/MARKETING.md new file mode 100644 index 0000000..d232fe2 --- /dev/null +++ b/MARKETING.md @@ -0,0 +1,72 @@ +# đŸŸ Ban Yaro — Marketing-Cockpit + +**Single Source of Truth fĂŒrs Marketing.** Vor jeder Aktion hier prĂŒfen, danach updaten — so wird nichts doppelt gemacht, vergessen oder ĂŒbersehen. Pflege: RenĂ© + Claude. + +_Stand: 2026-06-03_ + +> Diese Datei = Planung & Checkliste. FĂŒr **Live-Daten** (User-Meilenstein, Kanal-Tracking) lohnt zusĂ€tzlich ein Marketing-Tab im **Admin-Bereich** — siehe „Ausbau" unten. + +## 📊 Kanal-Überblick +| Kanal / Bereich | Status | NĂ€chster Schritt | +|---|---|---| +| Flyer Print | 🟱 1000 gedruckt (03.06.) | lokal verteilen | +| Flyer Digital | 💡 Idee | Doppelseiten-PDF + Empfehlungs-QR | +| Lokal (Ebersberg) | ⬜ offen | TierĂ€rzte, Hundeschulen, FutterlĂ€den, Tierheim | +| Online-Communities | ⬜ offen | FB-Gruppen Landkreis EBE + nebenan.de | +| Empfehlung / Referral | 🟡 Infra da (`referral_code`) | Empfehlungs-QR + Tracking sichtbar machen | +| Influencer | 🟡 2 Runden (Mai), kaum Resonanz | Runde 3 erst ab ~50 aktiven Usern | +| Presse / Blogs | 🟡 1 Runde, kaum Resonanz | keine Massenwelle; Nische zuerst | +| Verzeichnisse / Listings | ⬜ offen | Product Hunt, PWA-Dirs, Google Business EBE | +| SEO / KI-Auffindbarkeit | 🟡 technisch optimiert | Backlinks (Blog-Testberichte) | +| Landing Page | 🟡 Redesign-Briefing da | 3 Einstiege, Outcomes statt Features | +| App Store (iOS) | 🟱 in Review (1.0 (3), 03.06.) | Freigabe abwarten | +| Play Store (Android) | 🔮 ON HOLD | 12 Closed-Tester / 14 Tage fehlen | +| Merch / NFC-Halsband | 💡 recherchiert | 20 Tags fĂŒr Beta (~33 €) | + +Legende: 🟱 lĂ€uft/erledigt · 🟡 angefangen · ⬜ offen · 💡 Idee · 🔮 blockiert + +## ⏳ Gates / Trigger (nicht zu frĂŒh starten) +- **Influencer & Presse Runde 3** erst ab **~50 aktiven Usern** — vorher zu frĂŒh (Großredaktionen fragen zuerst nach Zahlen). → Bei jeder Session aktuelle User-Zahl checken. +- iOS-App ist nativ gebaut & in Review — **ĂŒberholt** die alte „iOS erst ab 10k via Rork/PWABuilder"-Strategie. + +## 📋 Backlog (konkret als NĂ€chstes) +- [ ] **Flyer lokal verteilen (Ebersberg)** — TierĂ€rzte (Wartezimmer), Hundeschulen/Welpengruppen, FutterlĂ€den, Hundesalons, Tierheim, Hundewiesen-AushĂ€nge, hundefreundliche CafĂ©s. Persönlich erklĂ€ren; AufhĂ€nger: Giftköder-Radar + „Daten in Deutschland". **Lokal bĂŒndeln, nicht streuen** (Community-Dichte fĂŒr Gassi-Treffen/Giftköder). +- [ ] **Digitaler Doppelseiten-Flyer (PDF)** mit **Empfehlungs-QR** fĂŒr Online-/Gruppen-Verteilung. Quelle: `promotion/flyer_a5_*.html`. _Offene Frage: generischer `?ref=empfehlung`-Link vs. pro-User `referral_code`._ +- [ ] **Lokale FB-Gruppen + nebenan.de** — Flyer-Foto + Link posten. +- [ ] **Verzeichnisse** — Product Hunt, progressivewebappstore.com, pwafire.org/directory, Google Business (Ebersberg). +- [ ] **Landing-Page-Redesign** nach Briefing (3 Zielgruppen-Einstiege Hundebesitzer/ZĂŒchter/WelpenkĂ€ufer, Outcomes statt Features, ZĂŒchter-SaaS prominent, Datenschutz als Argument, GrĂŒnder-Story + Foto). +- [ ] **Messung einbauen** — „Wie hast du von uns gehört?" im Onboarding + QR-refs pro Kanal. + +## ✅ Erledigt +- [x] 1000 Flyer A5 (zweiseitig) gedruckt — 03.06.2026 +- [x] iOS-App nativ gebaut + eingereicht (1.0 (3), in Review) — Details im Repo `banyaro-ios` +- [x] Influencer-Outreach Runde 1 (5) + Runde 2 (13) — Mai 2026 +- [x] SEO-Grundlagen (llms.txt, Landing About-Section) + +## 📈 Messung — was bringt wirklich Nutzer? +- **Onboarding-Frage „Wie hast du von uns gehört?"** (1 Klick) = billigste & wichtigste Kontrolle. _(noch einzubauen)_ +- **QR-refs pro Ort/Kanal** (z. B. `banyaro.app/?ref=tierarzt-grafing`) → ab nĂ€chster Flyer-Charge. +- **`referral_code`** (in DB, `routes/auth.py`) → Empfehlungen zĂ€hlbar. +- Aktive User aktuell: _[aus Admin eintragen]_ + +## 🗂 Details je Kanal + +### Influencer +2 Runden im Mai gesendet (`partner@banyaro.app`; DKIM/SPF/DMARC aktiv), **kaum Resonanz** — zu frĂŒh (wenige User), teils falsche Adressen (z. B. GEO → richtig `chefredaktion@geo.de`). +**Runde 3:** keine Massenwelle ohne PR-Agentur; **Hundeschulen/-trainer zuerst** (kleines Netzwerk, empfehlen aktiv Tools, Trainingsfeature ist stark), persönliche Mails, AufhĂ€nger = neue Features + echte Nutzerzahlen. +→ **Wer schon kontaktiert wurde:** AI-Memory `project_influencer_outreach` (Runde 1: verpinscht, missyminzi, wanderlust_samoyed, viviundholly, doguniversity, dogstv; Runde 2: nami.and.tommy, brina.explores, heimatherzen, pfotentick, flummis_diary, verwolft, wildwildwilli, knutini_, ninja.vom.wolfstor, pupsonality, osman_theparson, babybearyuki, dogswiss). **Vor neuer Runde dort prĂŒfen.** + +### Play Store (Android TWA) +PWABuilder-Paket fertig (`Ban Yaro - Google Play package/`, Package `app.banyaro.twa`). **BLOCKER:** Google verlangt 12 Closed-Tester ĂŒber 14 Tage — Tester fehlen (Engpass, nicht die Technik). assetlinks.json + Play-Console-Eintrag stehen bereit. Nicht priorisieren bis Tester da. + +### Merch / NFC-Halsband +Tag recherchiert: **HID Laundry Tag 16 mm** (shopnfc, SKU RE-ICO2-16, ~1 €/Stk ab 500), fĂŒr `banyaro.app/hund/{id}`. Beta: 20 Stk (~33 €) an erste Nutzer. + +### Flyer +Print: A5 zweiseitig, Quelle `promotion/flyer_a5_allgemein.html` + `flyer_a5_rueckseite.html`, QR → banyaro.app. Vorderseite = alle Hundebesitzer, RĂŒckseite stark ZĂŒchter-fokussiert. + +## 🚀 Ausbau: Live-Tool im Admin-Bereich (optional) +Diese Datei deckt Planung/Checkliste ab (Claude pflegt sie). Der **Admin-Bereich** lohnt sich fĂŒr die Teile mit echten Daten: +- **User-Meilenstein-Anzeige** (aktive User) → blendet automatisch den „Outreach Runde 3"-Hinweis ein, sobald ~50 erreicht. +- **Kanal-Tracking**: Auswertung „Wie gehört?" + QR-ref-ZĂ€hler + `referral_code`-Statistik. +- Optional: das Kanal-Board (Status/Backlog) als editierbare Admin-Seite. 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/database.py b/backend/database.py index 08ac7db..ef3b9fa 100644 --- a/backend/database.py +++ b/backend/database.py @@ -356,6 +356,18 @@ def init_db(): ); CREATE INDEX IF NOT EXISTS idx_osm_pois_loc ON osm_pois(type, lat, lon); + -- OSM-Account-VerknĂŒpfung (OAuth2) je Nutzer — Basis fĂŒr OSM-BeitrĂ€ge + -- ("Hund war willkommen" → dog=yes) + spĂ€tere Gamification/Pro-Freischaltung. + -- access_token verschlĂŒsselt at rest (token_enc). + CREATE TABLE IF NOT EXISTS user_osm ( + user_id INTEGER PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE, + osm_uid INTEGER NOT NULL, + osm_name TEXT NOT NULL, + token_enc TEXT NOT NULL, + scopes TEXT, + linked_at TEXT NOT NULL DEFAULT (datetime('now')) + ); + -- VERLORENE HUNDE CREATE TABLE IF NOT EXISTS lost_dogs ( id INTEGER PRIMARY KEY AUTOINCREMENT, diff --git a/backend/main.py b/backend/main.py index df5124d..465231d 100644 --- a/backend/main.py +++ b/backend/main.py @@ -227,6 +227,7 @@ from routes.walks import router as walks_router from routes.events import router as events_router from routes.sitting import router as sitting_router from routes.osm import router as osm_router +from routes.osm_auth import router as osm_auth_router from routes.forum import router as forum_router from routes.lost import router as lost_router from routes.knigge import router as knigge_router @@ -292,6 +293,7 @@ app.include_router(walks_router, prefix="/api/walks", tags=["Gassi-Tre app.include_router(events_router, prefix="/api/events", tags=["Events"]) app.include_router(sitting_router, prefix="/api/sitting", tags=["Sitting"]) app.include_router(osm_router, prefix="/api/osm", tags=["OSM"]) +app.include_router(osm_auth_router, prefix="/api/osm-auth", tags=["OSM-Auth"]) app.include_router(weather_router, prefix="/api/weather", tags=["Wetter"]) app.include_router(social_router, prefix="/api/social", tags=["Social"]) app.include_router(forum_router, prefix="/api/forum", tags=["Forum"]) @@ -511,11 +513,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 +528,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 +1344,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 +1502,7 @@ async def ausweis_page(dog_id: int, request: Request): + Heimtierausweis – {esc(dog["name"])} +
@@ -1727,7 +1767,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 +1778,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 +1793,8 @@ async def help_page(): Hilfe & FAQ — Ban Yaro + + + @@ -1851,6 +1907,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/requirements.txt b/backend/requirements.txt index 414ec32..d45b6f8 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -7,6 +7,7 @@ pydantic[email]==2.10.6 bcrypt==4.3.0 PyJWT==2.10.1 httpx==0.28.1 +cryptography==44.0.0 openai==1.59.2 anthropic==0.49.0 pywebpush==2.0.0 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..a6799de 100644 --- a/backend/routes/osm.py +++ b/backend/routes/osm.py @@ -1,6 +1,11 @@ """ -BAN YARO — OSM/Overpass POI-Cache + Community-Pins -Cacht OSM-Daten lokal, erlaubt Nutzern eigene Marker und Meldungen. +BAN YARO — OSM POI-Daten + Community-Pins +Liest OSM-POIs aus der lokalen Tabelle osm_pois (monatlicher Offline-Import, +tools/osm-extract/), erlaubt Nutzern eigene Marker und Meldungen. + +Build 4: Live-Scannen gegen overpass-api.de ist DEAKTIVIERT (war Bann-Quelle). +Die Overpass-Hilfsfunktionen unten sind ungenutzt und können spĂ€ter entfernt +werden. /geocode nutzt weiterhin Nominatim fĂŒr die Adresssuche (geringe Last). """ import math @@ -191,17 +196,9 @@ async def get_pois( fetched_fresh = False if type in OSM_QUERIES: - tiles = _covering_tiles(south, west, north, east, CACHE_ZOOM) - stale = _stale_tiles(type, tiles) - - if stale and not fast: - async def _bg_fetch(poi_type, stale_tiles): - for (x, y) in stale_tiles: - await _fetch_and_store_tile(poi_type, x, y) - task = asyncio.create_task(_bg_fetch(type, stale)) - _bg_tasks.add(task) - task.add_done_callback(_bg_tasks.discard) - + # Scanner deaktiviert (Build 4): keine Live-Overpass-Abfragen mehr. + # POIs stammen aus dem monatlichen Offline-Import in die Tabelle + # osm_pois (tools/osm-extract/). Hier wird nur noch daraus gelesen. with db() as conn: reported = { row[0] for row in conn.execute( @@ -364,24 +361,17 @@ async def report_poi(body: ReportIn, user = Depends(get_current_user)): # ------------------------------------------------------------------ @router.post('/analyze') async def analyze_region( - background_tasks: BackgroundTasks, south: float = Query(...), west: float = Query(...), north: float = Query(...), east: float = Query(...), ): - tiles = _covering_tiles(south, west, north, east, CACHE_ZOOM) - - async def _warmup(): - tasks = [ - _fetch_and_store_tile(pt, x, y) - for pt in OSM_QUERIES - for (x, y) in _stale_tiles(pt, tiles) - ] - await asyncio.gather(*tasks) - - background_tasks.add_task(_warmup) - return {'status': 'gestartet', 'tiles': len(tiles), 'types': list(OSM_QUERIES.keys())} + # Scanner deaktiviert (Build 4): kein Live-Overpass-Warmup mehr. POIs + # kommen aus dem monatlichen Offline-Import (tools/osm-extract/). Endpoint + # bleibt als No-Op erhalten, damit bestehende Frontends nicht 404 laufen. + return {'status': 'offline-import', + 'message': 'POIs werden monatlich offline importiert — kein Live-Scan nötig.', + 'types': list(OSM_QUERIES.keys())} # ------------------------------------------------------------------ @@ -423,3 +413,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/routes/osm_auth.py b/backend/routes/osm_auth.py new file mode 100644 index 0000000..0de97b8 --- /dev/null +++ b/backend/routes/osm_auth.py @@ -0,0 +1,167 @@ +""" +OSM-Account-VerknĂŒpfung via OAuth2 (Modell A: BeitrĂ€ge laufen unter dem +eigenen OSM-Account des Nutzers). Basis fĂŒrs spĂ€tere "Hund war willkommen" +(dog=yes) + Gamification/Pro-Freischaltung. + +Flow: + 1. Frontend ruft (eingeloggt) GET /api/osm-auth/authorize → bekommt die + OSM-Authorize-URL inkl. signiertem `state` (trĂ€gt die banyaro-user_id + + CSRF-Nonce, 10 Min gĂŒltig) und leitet den Browser dorthin. + 2. OSM leitet zurĂŒck auf GET /api/osm-auth/callback?code=&state= (ohne JWT — + daher die user_id aus `state`). Token-Tausch, OSM-Name holen, Token + verschlĂŒsselt in user_osm speichern, zurĂŒck in die App leiten. + 3. GET /status zeigt VerknĂŒpfungsstatus, POST /unlink trennt. + +ENV: OSM_CLIENT_ID, OSM_CLIENT_SECRET, OSM_REDIRECT_URI, OSM_POST_LINK_REDIRECT. +Token-SchlĂŒssel wird aus JWT_SECRET abgeleitet (oder OSM_TOKEN_KEY ĂŒberschreibt). +""" +import os +import base64 +import hashlib +import logging +from urllib.parse import urlencode +from datetime import datetime, timezone, timedelta + +import jwt +import httpx +from fastapi import APIRouter, Depends, HTTPException, Query +from fastapi.responses import RedirectResponse +from cryptography.fernet import Fernet, InvalidToken + +from database import db +from auth import get_current_user, JWT_SECRET, JWT_ALGO + +logger = logging.getLogger(__name__) +router = APIRouter() + +# --- OSM-OAuth2-Endpunkte --- +OSM_AUTHORIZE = "https://www.openstreetmap.org/oauth2/authorize" +OSM_TOKEN = "https://www.openstreetmap.org/oauth2/token" +OSM_USER_API = "https://api.openstreetmap.org/api/0.6/user/details.json" +OSM_SCOPES = "read_prefs write_api" + +CLIENT_ID = os.getenv("OSM_CLIENT_ID", "") +CLIENT_SECRET = os.getenv("OSM_CLIENT_SECRET", "") +REDIRECT_URI = os.getenv("OSM_REDIRECT_URI", "https://staging.banyaro.app/api/osm-auth/callback") +POST_LINK_REDIRECT = os.getenv("OSM_POST_LINK_REDIRECT", "/?osm=verknuepft") + +_STATE_TTL_MIN = 10 + +# Fernet-SchlĂŒssel zur Token-VerschlĂŒsselung: dediziertes OSM_TOKEN_KEY oder +# deterministisch aus JWT_SECRET abgeleitet (kein zusĂ€tzliches Secret nötig). +def _fernet() -> Fernet: + raw = os.getenv("OSM_TOKEN_KEY") + if raw: + return Fernet(raw.encode() if isinstance(raw, str) else raw) + key = base64.urlsafe_b64encode(hashlib.sha256(JWT_SECRET.encode()).digest()) + return Fernet(key) + +def _encrypt(token: str) -> str: + return _fernet().encrypt(token.encode()).decode() + +def _decrypt(token_enc: str) -> str: + return _fernet().decrypt(token_enc.encode()).decode() + + +# ------------------------------------------------------------------ +# GET /authorize — liefert die OSM-Authorize-URL (Frontend redirectet dorthin) +# ------------------------------------------------------------------ +@router.get("/authorize") +async def authorize(user=Depends(get_current_user)): + if not CLIENT_ID: + raise HTTPException(503, "OSM-Anbindung nicht konfiguriert (OSM_CLIENT_ID fehlt).") + state = jwt.encode( + {"uid": user["id"], + "exp": datetime.now(timezone.utc) + timedelta(minutes=_STATE_TTL_MIN), + "purpose": "osm-link"}, + JWT_SECRET, algorithm=JWT_ALGO, + ) + params = { + "response_type": "code", + "client_id": CLIENT_ID, + "redirect_uri": REDIRECT_URI, + "scope": OSM_SCOPES, + "state": state, + } + url = OSM_AUTHORIZE + "?" + urlencode(params) + return {"authorize_url": url} + + +# ------------------------------------------------------------------ +# GET /callback — OSM leitet hierher zurĂŒck (Browser-Redirect, kein JWT) +# ------------------------------------------------------------------ +@router.get("/callback") +async def callback(code: str = Query(...), state: str = Query(...)): + # 1) state verifizieren → banyaro-user_id (CSRF + Zuordnung) + try: + payload = jwt.decode(state, JWT_SECRET, algorithms=[JWT_ALGO]) + if payload.get("purpose") != "osm-link": + raise ValueError("falscher state-Zweck") + uid = int(payload["uid"]) + except Exception: + raise HTTPException(400, "UngĂŒltiger oder abgelaufener VerknĂŒpfungs-Link.") + + # 2) code → access_token tauschen + async with httpx.AsyncClient(timeout=15) as client: + tok = await client.post(OSM_TOKEN, data={ + "grant_type": "authorization_code", + "code": code, + "redirect_uri": REDIRECT_URI, + "client_id": CLIENT_ID, + "client_secret": CLIENT_SECRET, + }) + if tok.status_code != 200: + logger.warning("OSM-Token-Tausch fehlgeschlagen: %s %s", tok.status_code, tok.text[:200]) + raise HTTPException(502, "OSM-Token-Tausch fehlgeschlagen.") + access_token = tok.json().get("access_token") + if not access_token: + raise HTTPException(502, "OSM lieferte kein access_token.") + + # 3) OSM-IdentitĂ€t holen (uid + Anzeigename) + me = await client.get(OSM_USER_API, headers={"Authorization": f"Bearer {access_token}"}) + if me.status_code != 200: + raise HTTPException(502, "OSM-Nutzerdaten konnten nicht geladen werden.") + u = me.json().get("user", {}) + osm_uid, osm_name = u.get("id"), u.get("display_name") + if not (osm_uid and osm_name): + raise HTTPException(502, "OSM-Nutzerdaten unvollstĂ€ndig.") + + # 4) verschlĂŒsselt speichern (eine VerknĂŒpfung pro banyaro-User) + with db() as conn: + conn.execute( + """INSERT INTO user_osm (user_id, osm_uid, osm_name, token_enc, scopes, linked_at) + VALUES (?, ?, ?, ?, ?, datetime('now')) + ON CONFLICT(user_id) DO UPDATE SET + osm_uid=excluded.osm_uid, osm_name=excluded.osm_name, + token_enc=excluded.token_enc, scopes=excluded.scopes, + linked_at=excluded.linked_at""", + (uid, osm_uid, osm_name, _encrypt(access_token), OSM_SCOPES), + ) + logger.info("OSM verknĂŒpft: banyaro-user %s ↔ OSM '%s' (%s)", uid, osm_name, osm_uid) + return RedirectResponse(POST_LINK_REDIRECT, status_code=302) + + +# ------------------------------------------------------------------ +# GET /status — VerknĂŒpfungsstatus des eingeloggten Nutzers +# ------------------------------------------------------------------ +@router.get("/status") +async def status(user=Depends(get_current_user)): + with db() as conn: + row = conn.execute( + "SELECT osm_name, osm_uid, linked_at FROM user_osm WHERE user_id=?", + (user["id"],) + ).fetchone() + if not row: + return {"linked": False} + return {"linked": True, "osm_name": row["osm_name"], + "osm_uid": row["osm_uid"], "linked_at": row["linked_at"]} + + +# ------------------------------------------------------------------ +# POST /unlink — VerknĂŒpfung trennen (Token lokal löschen) +# ------------------------------------------------------------------ +@router.post("/unlink") +async def unlink(user=Depends(get_current_user)): + with db() as conn: + conn.execute("DELETE FROM user_osm WHERE user_id=?", (user["id"],)) + return {"status": "ok"} 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/settings.js b/backend/static/js/pages/settings.js index 1cfbe70..e87d6e0 100644 --- a/backend/static/js/pages/settings.js +++ b/backend/static/js/pages/settings.js @@ -672,6 +672,13 @@ window.Page_settings = (() => {
+
+
OpenStreetMap – die Karte mitverbessern
+
+
LĂ€dt

+
+
+