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
+
+
+
â banyaro.app
@@ -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 = (() => {
${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 => `
-
-
- ${UI.escape(s.name)}
- ${s.distance_m < 1000 ? s.distance_m + ' m' : (s.distance_m / 1000).toFixed(1) + ' km'}
- `).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: `
SchlieĂen `,
+ });
+ 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)}
` : ''}
+
+ Marker entfernen
+ `, { 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 = (() => {
+
+