Compare commits

...

4 commits

Author SHA1 Message Date
46caa05020 OSM-Verknüpfung (Modell A): OAuth2-Fundament für Nutzer-Beiträge
- Tabelle user_osm (access_token verschlüsselt at rest via Fernet,
  Schlüssel aus JWT_SECRET abgeleitet oder OSM_TOKEN_KEY).
- Router /api/osm-auth: authorize (signierter state mit user_id+CSRF),
  callback (Code-Tausch + OSM-Name holen + speichern), status, unlink.
- Profil-UI (Settings): "OSM-Konto verknüpfen" / verknüpft-als / trennen,
  hundehalter-spezifische Motivation.
- cryptography in requirements.
- Basis für dog=yes-Beiträge + Gamification/Pro (folgt). Staging-Branch.

ENV nötig: OSM_CLIENT_ID, OSM_CLIENT_SECRET (Redirect-URI default staging).
2026-06-03 21:14:36 +02:00
4bc7454258 POI-Karte: Offline-Import aus OSM statt Live-Overpass-Scan (Build 4)
- osm.py: Live-Scanner deaktiviert — /pois liest nur noch aus DB,
  /analyze ist No-Op. Behebt wiederholte OSM-Banns (Tile-Load + Scanning).
- tools/osm-extract: Extraktion (pyosmium) + Loader (schützt user_edited)
  + Docker-Refresh-Job mit osmium-tags-filter-Vorstufe (RAM-schonend).
- docker-compose.osm.yml: Refresh-Service (mem_limit 4g), monatlich via
  DSM-Aufgabenplaner.
2026-06-03 20:44:32 +02:00
214543559c Marketing-Cockpit (MARKETING.md) + Flyer (HTML-Quellen & Print-PDFs); unsplash/ ignoriert 2026-06-03 17:24:47 +02:00
10e39ed135 Karten-Ausbau (OSM), Forum-Erweiterung, UI-Komponenten, Refactor Tagebuch/Gassi (DRY), Landing/SEO — APP_VER 1155 2026-06-03 17:24:47 +02:00
38 changed files with 2313 additions and 431 deletions

1
.gitignore vendored
View file

@ -12,3 +12,4 @@ __pycache__/
/icons/
.claude/worktrees/
Ban Yaro - Google Play package/
/unsplash/

72
MARKETING.md Normal file
View file

@ -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.

View file

@ -1 +1 @@
1141
1155

View file

@ -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,

View file

@ -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(
'<link rel="canonical" href="https://banyaro.app/">',
'<meta name="robots" content="noindex"><link rel="canonical" href="https://banyaro.app/">'
)
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(
'<link rel="canonical" href="https://banyaro.app/">',
f'<link rel="canonical" href="https://banyaro.app/breeder/{_html_mod.escape(zwingername)}">'
).replace(
'<title>Ban Yaro</title>',
f'<title>{_html_mod.escape(name)} — Hundezüchter auf Ban Yaro</title>'
f'\n <meta name="description" content="{desc}">'
f'\n <meta name="robots" content="index, follow">'
)
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):
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="robots" content="noindex">
<title>Heimtierausweis {esc(dog["name"])}</title>
<style>
* {{ box-sizing: border-box; margin: 0; padding: 0; }}
@ -1657,6 +1689,14 @@ async def knigge_page():
footer{background:#1a1a1a;color:#aaa;text-align:center;padding:1.5rem;font-size:.82rem;margin-top:2rem}
footer a{color:#C4843A}
</style>
<script type="application/ld+json">
{{"@context":"https://schema.org","@type":"FAQPage","mainEntity":[
{{"@type":"Question","name":"Muss mein Hund in der Öffentlichkeit an der Leine?","acceptedAnswer":{{"@type":"Answer","text":"In Deutschland gilt Leinenpflicht in Innenstädten, Parks, auf Kinderspielplätzen und in Tiergehegen. In ländlichen Gebieten gibt es je nach Bundesland Ausnahmen. In der Brut- und Setzzeit (MärzJuli) besteht vielerorts erweiterte Leinenpflicht auch auf Feldwegen."}}}},
{{"@type":"Question","name":"Darf ich meinen Hund im öffentlichen Nahverkehr mitnehmen?","acceptedAnswer":{{"@type":"Answer","text":"Kleine Hunde in einer Transporttasche fahren in der Regel kostenlos. Größere Hunde benötigen oft einen Kinderfahrschein und müssen angeleint und mit Maulkorb reisen. Die Regeln variieren je nach Verkehrsbetrieb."}}}},
{{"@type":"Question","name":"Wie verhalte ich mich bei der Begegnung mit anderen Hunden?","acceptedAnswer":{{"@type":"Answer","text":"Beim Aufeinandertreffen von Hunden: immer den anderen Hundehalter fragen, ob eine Begegnung erwünscht ist. Leinenstress vermeiden, indem du Abstand hältst oder ausweichst. Einen ängstlichen oder aggressiven Hund nie bedrängen lassen."}}}},
{{"@type":"Question","name":"Muss ich den Kot meines Hundes beseitigen?","acceptedAnswer":{{"@type":"Answer","text":"Ja, in Deutschland ist die Beseitigung von Hundekot auf öffentlichen Flächen gesetzlich vorgeschrieben. Bei Verstoß drohen Bußgelder von 25300 €. Bitte immer Kotbeutel dabeihaben."}}}},
{{"@type":"Question","name":"Brauche ich eine Haftpflichtversicherung für meinen Hund?","acceptedAnswer":{{"@type":"Answer","text":"In den meisten deutschen Bundesländern ist eine Hundehaftpflichtversicherung Pflicht. Sie deckt Schäden ab, die dein Hund an Personen oder Sachen verursacht. Ausnahme: In Bayern ist sie freiwillig, wird aber dringend empfohlen."}}}}
]}}</script>
</head>
<body>
<header>
@ -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'<section><h2>{_html.escape(label)}</h2>{items}</section>'
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"""<!DOCTYPE html>
<html lang="de">
@ -1744,6 +1793,8 @@ async def help_page():
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Hilfe & FAQ Ban Yaro</title>
<meta name="description" content="Antworten zu Ban Yaro und Ban Yaro Go: Installation, Standort, Account, Features.">
<link rel="canonical" href="https://banyaro.app/help">
<meta name="robots" content="index, follow">
<link rel="icon" href="/icons/icon-180.png">
<style>
:root {{
@ -1814,6 +1865,11 @@ async def help_page():
.contact p {{ margin: .25rem 0; color: var(--c-text-sec); }}
nav.top {{ margin-bottom: 1.5rem; }}
</style>
<script type="application/ld+json">{{
"@context": "https://schema.org",
"@type": "FAQPage",
"mainEntity": {faq_json_ld}
}}</script>
</head>
<body>
<nav class="top"><a href="/"> banyaro.app</a></nav>
@ -1851,6 +1907,8 @@ async def konto_loeschen():
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Konto löschen Ban Yaro</title>
<link rel="canonical" href="https://banyaro.app/konto-loeschen">
<meta name="robots" content="noindex">
<link rel="stylesheet" href="/css/design-system.css">
<style>
body { font-family: var(--font-sans); background: var(--c-bg); color: var(--c-text);
@ -1900,6 +1958,7 @@ async def force_update():
from fastapi.responses import HTMLResponse
html = """<!DOCTYPE html><html><head><meta charset="UTF-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<meta name="robots" content="noindex, nofollow">
<title>Ban Yaro Update</title>
<style>body{font-family:sans-serif;display:flex;align-items:center;justify-content:center;
height:100vh;margin:0;background:#0f1623;color:#fff;flex-direction:column;gap:16px}
@ -1971,6 +2030,8 @@ async def partner_landing():
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Ban Yaro Partner Werde Teil der ersten 100</title>
<meta name="description" content="Werde Ban Yaro Partner. Gib deiner Community exklusive Gründer-Lizenzen — nur 100 Plätze weltweit, nie wieder erhältlich.">
<link rel="canonical" href="https://banyaro.app/partner">
<meta name="robots" content="index, follow">
<meta property="og:title" content="Ban Yaro Partner">
<meta property="og:description" content="Gib deiner Community etwas Besonderes. 100 Gründer-Plätze. Exklusiv. Für immer.">
<meta property="og:image" content="https://banyaro.app/icons/icon-512.png">
@ -2256,6 +2317,7 @@ async def passport_share_page(token: str):
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="robots" content="noindex">
<title>Hundepass {dog['name']}</title>
<style>
* {{ box-sizing: border-box; margin: 0; padding: 0; }}
@ -2329,6 +2391,119 @@ async def passport_share_page(token: str):
return HTMLResponse(html)
# ------------------------------------------------------------------
# Wurfbörse /wurfboerse — SSR-Seite mit korrektem Canonical
# ------------------------------------------------------------------
@app.get("/wurfboerse")
async def wurfboerse_page():
from fastapi.responses import HTMLResponse
from database import db as _db
import html as _h
import json as _json
litters_html = ""
rows = []
ld_items = []
try:
with _db() as conn:
rows = conn.execute(
"""SELECT l.id, l.welpen_verfuegbar, l.preis_spanne, l.status,
bp.zwingername, bp.rasse,
wr.name AS rasse_name
FROM litters l
JOIN breeder_profiles bp ON bp.id = l.breeder_id
JOIN users u ON u.id = bp.user_id
LEFT JOIN wiki_rassen wr ON wr.id = bp.breed_id
WHERE l.sichtbar=1 AND u.rolle='breeder'
AND (l.sichtbar_bis IS NULL OR l.sichtbar_bis >= date('now'))
ORDER BY l.created_at DESC LIMIT 60"""
).fetchall()
for i, r in enumerate(rows, 1):
rasse_label = _h.escape(r["rasse_name"] or r["rasse"] or "")
zw = _h.escape(r["zwingername"] or "")
verfueg = r["welpen_verfuegbar"]
preis = _h.escape(r["preis_spanne"] or "")
status_map = {"geplant": "Geplant", "geboren": "Geboren", "verfuegbar": "Verfügbar"}
status_label = status_map.get(r["status"], r["status"])
litters_html += f"""<div class="litter-card">
<div class="litter-breed">{rasse_label or "Unbekannte Rasse"}</div>
<div class="litter-breeder">Züchter: <a href="/breeder/{_h.escape(r['zwingername'] or '')}">{zw}</a></div>
<div class="litter-meta">
<span class="badge">{status_label}</span>
{f'<span>{verfueg} Welpen verfügbar</span>' if verfueg else ''}
{f'<span>{preis}</span>' if preis else ''}
</div>
</div>"""
ld_items.append({
"@type": "ListItem",
"position": i,
"name": f"{r['rasse_name'] or r['rasse'] or 'Welpen'}{r['zwingername'] or 'Züchter'}",
"url": f"https://banyaro.app/breeder/{r['zwingername']}"
})
except Exception:
pass
count_text = f"{len(rows)} Würfe" if litters_html else "Aktuell keine Würfe eingetragen"
ld_json = _json.dumps({
"@context": "https://schema.org",
"@type": "ItemList",
"name": "Wurfbörse — Hundewelpen bei Ban Yaro",
"description": "Aktuelle Würfe von geprüften Züchtern auf Ban Yaro",
"url": "https://banyaro.app/wurfboerse",
"numberOfItems": len(rows),
"itemListElement": ld_items
}, ensure_ascii=False)
html = f"""<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Wurfbörse Hundewelpen bei Ban Yaro</title>
<meta name="description" content="Seriöse Hundewelpen von geprüften Züchtern auf Ban Yaro. Jetzt Welpen finden, Züchter kontaktieren und Stammbaum einsehen.">
<link rel="canonical" href="https://banyaro.app/wurfboerse">
<meta name="robots" content="index, follow">
<meta property="og:title" content="Wurfbörse — Hundewelpen bei Ban Yaro">
<meta property="og:description" content="Seriöse Hundewelpen von geprüften Züchtern auf Ban Yaro.">
<meta property="og:image" content="https://banyaro.app/icons/icon-512.png">
<link rel="icon" href="/icons/icon-180.png">
<script type="application/ld+json">{ld_json}</script>
<style>
*,*::before,*::after{{box-sizing:border-box;margin:0;padding:0}}
body{{font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;background:#fbfaf6;color:#1c1917;padding:0 0 4rem}}
.hero{{background:linear-gradient(135deg,#C4843A,#e8a857);color:#fff;padding:2.5rem 1.5rem;text-align:center}}
.hero h1{{font-size:clamp(1.6rem,4vw,2.2rem);font-weight:800;margin-bottom:.5rem}}
.hero p{{opacity:.9;font-size:1rem}}
.container{{max-width:720px;margin:2rem auto;padding:0 1rem}}
.count{{font-size:.85rem;color:#78716c;margin-bottom:1.5rem}}
.litter-card{{background:#fff;border-radius:12px;padding:1.2rem 1.4rem;margin-bottom:1rem;
box-shadow:0 1px 4px rgba(0,0,0,.08);border-left:4px solid #C4843A}}
.litter-breed{{font-size:1.05rem;font-weight:700;color:#1c1917;margin-bottom:.3rem}}
.litter-breeder{{font-size:.875rem;color:#57534e;margin-bottom:.5rem}}
.litter-breeder a{{color:#C4843A;text-decoration:none}}
.litter-meta{{display:flex;gap:.6rem;flex-wrap:wrap;font-size:.8rem;color:#78716c}}
.badge{{background:#fef3c7;color:#92400e;border-radius:100px;padding:.1rem .6rem;font-weight:600}}
.cta{{display:block;text-align:center;margin-top:2.5rem}}
.cta a{{background:#C4843A;color:#fff;border-radius:100px;padding:.85rem 2rem;
font-size:1rem;font-weight:700;text-decoration:none;display:inline-block}}
.empty{{text-align:center;padding:3rem 1rem;color:#78716c}}
</style>
</head>
<body>
<div class="hero">
<h1>Wurfbörse</h1>
<p>Hundewelpen von geprüften Züchtern</p>
</div>
<div class="container">
<p class="count">{count_text}</p>
{litters_html or '<div class="empty"><p>Aktuell keine Würfe eingetragen.<br>Schau bald wieder vorbei!</p></div>'}
<div class="cta"><a href="/">Zur Ban Yaro App</a></div>
</div>
</body>
</html>"""
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):

View file

@ -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

View file

@ -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
# ------------------------------------------------------------------

View file

@ -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

167
backend/routes/osm_auth.py Normal file
View file

@ -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"}

View file

@ -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;

View file

@ -86,14 +86,14 @@
<title>Ban Yaro</title>
<!-- Theme + theme-color Statusleiste vor CSS setzen -->
<script src="/js/boot-early.js?v=1141"></script>
<script src="/js/boot-early.js?v=1155"></script>
<!-- CSS: Reihenfolge ist wichtig — ?v= zwingt Browser zur Neuladung -->
<link rel="stylesheet" href="/css/design-system.css?v=1141">
<link rel="stylesheet" href="/css/layout.css?v=1141">
<link rel="stylesheet" href="/css/components.css?v=1141">
<link rel="stylesheet" href="/css/utilities.css?v=1141">
<link rel="stylesheet" href="/css/lists.css?v=1141">
<link rel="stylesheet" href="/css/design-system.css?v=1155">
<link rel="stylesheet" href="/css/layout.css?v=1155">
<link rel="stylesheet" href="/css/components.css?v=1155">
<link rel="stylesheet" href="/css/utilities.css?v=1155">
<link rel="stylesheet" href="/css/lists.css?v=1155">
</head>
<body>
@ -617,11 +617,11 @@
<div id="modal-container"></div>
<!-- JS: Reihenfolge ist wichtig — erst Basis, dann Features -->
<script src="/js/api.js?v=1141"></script>
<script src="/js/ui.js?v=1141"></script>
<script src="/js/app.js?v=1141"></script>
<script src="/js/worlds.js?v=1141"></script>
<script src="/js/offline-indicator.js?v=1141"></script>
<script src="/js/api.js?v=1155"></script>
<script src="/js/ui.js?v=1155"></script>
<script src="/js/app.js?v=1155"></script>
<script src="/js/worlds.js?v=1155"></script>
<script src="/js/offline-indicator.js?v=1155"></script>
<!-- Feature-Seiten werden lazy geladen -->
@ -631,7 +631,7 @@
<!-- Boot: Offline-Banner + SW-Registration (extrahiert für CSP) -->
<script src="/js/boot.js?v=1141"></script>
<script src="/js/boot.js?v=1155"></script>
</body>

View file

@ -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 });
},

View file

@ -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;

View file

@ -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());
}
});

View file

@ -1306,37 +1306,7 @@ window.Page_diary = (() => {
</div>
<div class="form-group" id="diary-location-group">
<label class="form-label">Ort <span class="text-secondary">(optional)</span></label>
<!-- Karte (Lesemodus, Edit per Button aktivierbar) -->
<div style="position:relative">
<div id="diary-map-wrap" style="border-radius:var(--radius-md);overflow:hidden;height:220px;background:var(--c-surface-2)"></div>
<button type="button" id="diary-map-edit-btn" class="btn btn-secondary btn-sm"
style="position:absolute;bottom:var(--space-2);right:var(--space-2);z-index:500">
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#pencil-simple"></use></svg>
<span id="diary-map-edit-label">Position ändern</span>
</button>
</div>
<!-- POI-Name + Aktionen -->
<div class="mt-2">
<div id="diary-location-chip-wrap" style="${entry?.location_name ? '' : 'display:none'}">
<div class="diary-location-chip">
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#map-pin"></use></svg>
<span id="diary-location-label">${UI.escape(entry?.location_name || '')}</span>
<button type="button" id="diary-location-clear" aria-label="Name entfernen">
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#x"></use></svg>
</button>
</div>
</div>
<div style="display:flex;gap:var(--space-2);margin-top:var(--space-2)">
<button type="button" class="btn btn-danger" id="diary-coords-clear">Ort entfernen</button>
<button type="button" class="btn btn-secondary btn-sm" id="diary-location-btn">
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#map-pin"></use></svg>
<span id="diary-location-btn-label">POI suchen</span>
</button>
</div>
<div id="diary-location-suggestions" style="display:none;margin-top:var(--space-2)"></div>
</div>
<div id="diary-location-picker"></div>
</div>
${dogPickerHtml}
<div class="form-group" style="margin-top:var(--space-5)">
@ -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 = '<svg xmlns="http://www.w3.org/2000/svg" width="32" height="40" viewBox="0 0 32 40"><path d="M16 0C7.163 0 0 7.163 0 16c0 10 16 24 16 24S32 26 32 16C32 7.163 24.837 0 16 0z" fill="#C4843A"/><circle cx="16" cy="16" r="7" fill="white"/></svg>';
const _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 = '<p style="font-size:var(--text-sm);color:var(--c-text-secondary);padding:var(--space-2) 0">Keine Orte in der Nähe gefunden.</p>';
} else {
sugEl.innerHTML = suggestions.map(s => `
<button type="button" class="diary-location-suggestion"
data-name="${UI.escape(s.name)}" data-lat="${s.lat}" data-lon="${s.lon}">
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#${_sourceIcon(s.source)}"></use></svg>
<span>${UI.escape(s.name)}</span>
<small>${s.distance_m < 1000 ? s.distance_m + ' m' : (s.distance_m / 1000).toFixed(1) + ' km'}</small>
</button>`).join('');
sugEl.querySelectorAll('.diary-location-suggestion').forEach(el => {
el.addEventListener('click', () => _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({

View file

@ -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 => `
<div style="display:flex;align-items:center;gap:var(--space-2);padding:var(--space-2) 0;border-bottom:1px solid var(--c-border-light)">
<div class="forum-avatar forum-avatar--sm">${UI.escape(_initial(l.name))}</div>
<span style="font-size:0.9rem">${UI.escape(l.name || 'Unbekannt')}</span>
${l.founder_number ? `<span style="font-size:10px;font-weight:700;background:#7c3aed;color:#fff;padding:1px 5px;border-radius:4px;margin-left:auto">Gründer #${l.founder_number}</span>` : ''}
</div>`).join('');
UI.modal.open({
title: `${UI.icon('heart')} ${likers.length} ${likers.length === 1 ? 'Like' : 'Likes'}`,
body: `<div style="max-height:50vh;overflow-y:auto">${rows}</div>`,
footer: `<button type="button" class="btn btn-secondary w-full" id="likers-close">Schließen</button>`,
});
document.getElementById('likers-close')?.addEventListener('click', UI.modal.close);
} catch (err) { UI.toast.error(err.message); }
}
// ----------------------------------------------------------
// Report-Formular
// ----------------------------------------------------------

View file

@ -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 = (() => {
</div>
</div>
<div class="map-fabs">
<button class="map-fab map-fab--pin" id="map-pin-btn" title="Marker setzen"><svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#push-pin"></use></svg></button>
${App.hasPro(_appState?.user) ? `
<button class="map-fab" id="map-radar-btn" title="Regenradar ein-/ausblenden"><svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#cloud-rain"></use></svg></button>
<button class="map-fab" id="map-temp-btn" title="Temperatur ein-/ausblenden"><svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#thermometer"></use></svg></button>
` : ''}
<button class="map-fab" id="map-locate-btn" title="Meinen Standort"><svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#map-pin"></use></svg></button>
<!-- Orts-Suche Panel (von oben einschiebend, geschlossen per default) -->
<div class="map-search-wrap" id="map-search-wrap">
<div class="map-search-row">
<svg class="ph-icon" aria-hidden="true" style="width:16px;height:16px;flex-shrink:0;color:#888"><use href="/icons/phosphor.svg#magnifying-glass"></use></svg>
<input type="search" id="map-search-input" class="map-search-input"
placeholder="Ort oder Adresse…" autocomplete="off" autocorrect="off" spellcheck="false">
<button class="map-search-clear" id="map-search-clear" aria-label="Suche schließen">
<svg class="ph-icon" aria-hidden="true" style="width:14px;height:14px"><use href="/icons/phosphor.svg#x"></use></svg>
</button>
</div>
<div class="map-search-results" id="map-search-results" style="display:none"></div>
</div>
<!-- Speed Dial -->
<div class="map-speed-dial" id="map-speed-dial">
<div class="map-sd-items">
<!-- DOM-Reihenfolge = Aufklappreihenfolge von unten nach oben -->
<div class="map-sd-item">
<span class="map-sd-label">Mein Standort</span>
<button class="map-sd-btn" id="map-locate-btn" title="Mein Standort"><svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#map-pin"></use></svg></button>
</div>
<div class="map-sd-item">
<span class="map-sd-label">Ort suchen</span>
<button class="map-sd-btn" id="map-search-btn" title="Ort suchen"><svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#magnifying-glass"></use></svg></button>
</div>
<div class="map-sd-item">
<span class="map-sd-label">Marker setzen</span>
<button class="map-sd-btn map-fab--pin" id="map-pin-btn" title="Marker setzen"><svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#push-pin"></use></svg></button>
</div>
${App.hasPro(_appState?.user) ? `
<div class="map-sd-item">
<span class="map-sd-label">Regenradar</span>
<button class="map-sd-btn" id="map-radar-btn" title="Regenradar"><svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#cloud-rain"></use></svg></button>
</div>
<div class="map-sd-item">
<span class="map-sd-label">Temperatur</span>
<button class="map-sd-btn" id="map-temp-btn" title="Temperatur"><svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#thermometer"></use></svg></button>
</div>
` : ''}
</div>
<button class="map-fab map-sd-trigger" id="map-sd-trigger" title="Karten-Aktionen">
<svg class="ph-icon map-sd-icon-open" aria-hidden="true"><use href="/icons/phosphor.svg#dots-three-vertical"></use></svg>
<svg class="ph-icon map-sd-icon-close" aria-hidden="true"><use href="/icons/phosphor.svg#x"></use></svg>
</button>
</div>
<div class="map-statusbar" id="map-statusbar">
@ -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 = '<div class="map-search-loading">Suche…</div>';
resultsEl.style.display = '';
try {
const data = await API.get(`/osm/geocode?q=${encodeURIComponent(q)}`);
if (!data.length) {
resultsEl.innerHTML = '<div class="map-search-empty">Keine Ergebnisse</div>';
return;
}
resultsEl.innerHTML = data.map((r, i) =>
`<div class="map-search-item" data-i="${i}">
<div class="map-search-item-name">${UI.escape(r.name)}</div>
${r.subtitle ? `<div class="map-search-item-sub">${UI.escape(r.subtitle)}</div>` : ''}
</div>`
).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 = '<div class="map-search-empty">Suche nicht verfügbar</div>';
}
}
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: `<div style="background:#C4843A;color:#fff;font-size:15px;
width:32px;height:32px;border-radius:50% 50% 50% 0;transform:rotate(-45deg);
display:flex;align-items:center;justify-content:center;
box-shadow:0 2px 8px rgba(0,0,0,0.4)">
<span style="transform:rotate(45deg)">
<svg style="width:16px;height:16px" viewBox="0 0 256 256" fill="currentColor">
<path d="M128,16a96,96,0,1,0,96,96A96.11,96.11,0,0,0,128,16Zm0,48a32,32,0,1,1-32,32A32,32,0,0,1,128,64Zm0,144a80,80,0,0,1-56.37-23.37C74.18,170.06,98.65,160,128,160s53.82,10.06,56.37,24.63A80,80,0,0,1,128,208Z"/>
</svg>
</span></div>`,
iconSize: [32, 32],
iconAnchor: [16, 32],
}),
zIndexOffset: 1000,
})
.addTo(_map)
.bindPopup(`<div style="font-size:13px;font-weight:600">${UI.escape(r.name)}</div>
${r.subtitle ? `<div style="font-size:11px;color:#888">${UI.escape(r.subtitle)}</div>` : ''}
<button class="btn btn-secondary btn-sm" id="search-marker-close" style="margin-top:8px">
Marker entfernen
</button>`, { 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 };
})();

View file

@ -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

View file

@ -672,6 +672,13 @@ window.Page_settings = (() => {
</div>
</div>
<div class="card mb-4">
<div class="by-card-section-header">OpenStreetMap die Karte mitverbessern</div>
<div id="settings-osm-body" class="p-4">
<div class="text-sm-muted">Lädt</div>
</div>
</div>
<div class="card mb-4">
<div class="card-body" style="padding:0">
<div class="sidebar-item" data-page="dog-profile"
@ -925,6 +932,54 @@ window.Page_settings = (() => {
});
}).catch(() => {});
// OSM-Account-Verknüpfung (Modell A) — Status laden + Buttons verdrahten
(function _osmLink() {
const el = document.getElementById('settings-osm-body');
if (!el) return;
API.get('/osm-auth/status').then(st => {
if (st.linked) {
el.innerHTML = `
<div style="display:flex;align-items:center;gap:8px;flex-wrap:wrap">
<svg class="ph-icon" style="color:var(--c-success)" aria-hidden="true"><use href="/icons/phosphor.svg#check-circle"></use></svg>
<span style="font-size:var(--text-sm)">Verknüpft als <strong>${UI.escape(st.osm_name)}</strong></span>
</div>
<button id="settings-osm-unlink"
style="margin-top:var(--space-3);background:none;border:none;
color:var(--c-text-muted);font-size:var(--text-xs);cursor:pointer">
Verknüpfung trennen
</button>`;
el.querySelector('#settings-osm-unlink').addEventListener('click', async () => {
try { await API.post('/osm-auth/unlink', {}); } catch (e) {}
_osmLink();
});
} else {
el.innerHTML = `
<p class="text-sm-muted" style="margin:0 0 var(--space-3);line-height:1.45">
Du kennst die hundefreundlichen Orte besser als jede Karte. Verknüpfe deinen
kostenlosen OpenStreetMap-Account und trag mit einem Tap ein, wo dein Hund
willkommen war das hilft jedem Hundehalter nach dir. Kostenlos, gemeinnützig,
keine Werbung.
</p>
<button id="settings-osm-link"
style="display:flex;align-items:center;justify-content:center;gap:var(--space-2);
padding:var(--space-3) var(--space-4);border-radius:var(--radius-md);
border:none;background:var(--c-primary);color:#fff;
font-size:var(--text-sm);font-weight:600;cursor:pointer">
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#map-trifold"></use></svg>
OSM-Konto verknüpfen
</button>`;
el.querySelector('#settings-osm-link').addEventListener('click', async () => {
try {
const r = await API.get('/osm-auth/authorize');
if (r.authorize_url) window.location.href = r.authorize_url;
} catch (e) {
UI.toast?.('OSM-Anbindung noch nicht konfiguriert.');
}
});
}
}).catch(() => { el.innerHTML = '<div class="text-sm-muted">OSM-Status nicht verfügbar.</div>'; });
})();
// Achievements laden (Streak + Stats + Badges)
API.get('/achievements/me').then(a => {
const statsEl = document.getElementById('settings-stats-body');

View file

@ -897,8 +897,6 @@ window.Page_walks = (() => {
let _locLon = v.lon != null ? parseFloat(v.lon) : null;
let _locName = v.ort_name || null;
const _pinSvg = '<svg xmlns="http://www.w3.org/2000/svg" width="28" height="36" viewBox="0 0 32 40"><path d="M16 0C7.163 0 0 7.163 0 16c0 10 16 24 16 24S32 26 32 16C32 7.163 24.837 0 16 0z" fill="#C4843A"/><circle cx="16" cy="16" r="7" fill="white"/></svg>';
const body = `
<form id="walk-form" autocomplete="off">
@ -924,48 +922,7 @@ window.Page_walks = (() => {
<div class="form-group" id="wf-location-group">
<label class="form-label">Treffpunkt</label>
<!-- Mini-Karte -->
<div style="position:relative">
<div id="wf-map-wrap" style="border-radius:var(--radius-md);overflow:hidden;height:200px;background:var(--c-surface-2)"></div>
<button type="button" id="wf-map-pin-here" style="
position:absolute;bottom:10px;left:50%;transform:translateX(-50%);
z-index:1000;background:var(--c-primary);color:#fff;border:none;
border-radius:var(--radius-full);padding:6px 14px;font-size:var(--text-xs);
font-weight:600;box-shadow:var(--shadow-md);cursor:pointer;
display:flex;align-items:center;gap:6px;white-space:nowrap">
${UI.icon('map-pin')} Pin hier setzen
</button>
</div>
<!-- Ort-Chip -->
<div class="mt-2">
<div id="wf-location-chip-wrap" style="${_locName ? '' : 'display:none'}">
<div class="diary-location-chip">
${UI.icon('map-pin')}
<span id="wf-location-label">${UI.escape(_locName || '')}</span>
<button type="button" id="wf-location-clear" aria-label="Name entfernen">
${UI.icon('x')}
</button>
</div>
</div>
<div style="display:flex;gap:var(--space-2);margin-top:var(--space-2)">
<button type="button" class="btn btn-danger btn-sm" id="wf-coords-clear">Ort entfernen</button>
<button type="button" class="btn btn-secondary flex-1" id="wf-location-btn">
${UI.icon('map-pin')}
<span id="wf-location-btn-label">${_locLat ? 'POI suchen' : 'GPS → POI suchen'}</span>
</button>
</div>
<!-- Vorschläge -->
<div id="wf-location-suggestions" style="display:none;margin-top:var(--space-2)"></div>
</div>
<!-- Versteckte Koordinaten-Felder -->
<input type="hidden" name="lat" id="wf-lat" value="${_locLat || ''}">
<input type="hidden" name="lon" id="wf-lon" value="${_locLon || ''}">
<input type="hidden" name="ort_name" id="wf-ort-name" value="${UI.escape(_locName || '')}">
<div id="wf-location-picker"></div>
</div>
<div class="form-group">
@ -996,157 +953,16 @@ window.Page_walks = (() => {
document.getElementById('wf-cancel')?.addEventListener('click', UI.modal.close);
// --- Mini-Karte ---
let _miniMap = null, _miniMarker = null, _mapEditing = false;
const _mkIcon = () => L.divIcon({ html: _pinSvg, className: '', iconSize: [28, 36], iconAnchor: [14, 36] });
function _placeMarker(lat, lon) {
if (_miniMarker) { _miniMarker.setLatLng([lat, lon]); return; }
_miniMarker = L.marker([lat, lon], { draggable: true, icon: _mkIcon() }).addTo(_miniMap);
_miniMarker.on('dragend', () => {
const p = _miniMarker.getLatLng();
_locLat = p.lat; _locLon = p.lng;
document.getElementById('wf-lat').value = _locLat;
document.getElementById('wf-lon').value = _locLon;
document.getElementById('wf-location-btn-label').textContent = 'POI suchen';
// Location Picker
let _wfPicker = null;
setTimeout(() => {
_wfPicker = UI.locationPicker({
containerId: 'wf-location-picker',
onSelect: (lat, lon, name) => { _locLat = lat; _locLon = lon; _locName = name; },
});
}
if (_locLat != null) _wfPicker.setValue(_locLat, _locLon, _locName);
}, 50);
function _setCoords(lat, lon) {
_locLat = lat; _locLon = lon;
document.getElementById('wf-lat').value = lat;
document.getElementById('wf-lon').value = lon;
}
function _setName(name) {
_locName = name;
document.getElementById('wf-location-label').textContent = name;
document.getElementById('wf-location-chip-wrap').style.display = '';
document.getElementById('wf-ort-name').value = name;
document.getElementById('wf-location-suggestions').style.display = 'none';
}
UI.loadLeaflet().then(() => {
setTimeout(() => {
const lat = _locLat || 48.0, lon = _locLon || 11.9, zoom = _locLat ? 15 : 7;
_miniMap = L.map('wf-map-wrap', {
zoomControl: true, attributionControl: false,
dragging: true, scrollWheelZoom: false,
}).setView([lat, lon], zoom);
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', { maxZoom: 19 })
.addTo(_miniMap);
_miniMap.invalidateSize();
if (_locLat) _placeMarker(lat, lon);
_miniMap.on('click', e => {
_setCoords(e.latlng.lat, e.latlng.lng);
_placeMarker(_locLat, _locLon);
document.getElementById('wf-location-btn-label').textContent = 'POI suchen';
});
document.getElementById('wf-map-pin-here')?.addEventListener('click', () => {
const c = _miniMap.getCenter();
_setCoords(c.lat, c.lng);
_placeMarker(c.lat, c.lng);
document.getElementById('wf-location-btn-label').textContent = 'POI suchen';
});
}, 150);
});
// Ort-Name-Chip entfernen
document.getElementById('wf-location-clear')?.addEventListener('click', () => {
_locName = null;
document.getElementById('wf-location-chip-wrap').style.display = 'none';
document.getElementById('wf-ort-name').value = '';
});
// Koordinaten + Name entfernen (Zwei-Klick)
const clearBtn = document.getElementById('wf-coords-clear');
let _clearPending = false;
clearBtn?.addEventListener('click', () => {
if (!_clearPending) {
_clearPending = true;
clearBtn.textContent = 'Wirklich entfernen?';
clearBtn.style.color = 'var(--c-danger)';
setTimeout(() => {
_clearPending = false;
if (clearBtn) {
clearBtn.textContent = 'Ort entfernen';
clearBtn.style.color = '';
}
}, 3000);
return;
}
_clearPending = false;
clearBtn.textContent = 'Ort entfernen';
clearBtn.style.color = '';
_locLat = null; _locLon = null; _locName = null;
document.getElementById('wf-lat').value = '';
document.getElementById('wf-lon').value = '';
document.getElementById('wf-ort-name').value = '';
document.getElementById('wf-location-chip-wrap').style.display = 'none';
document.getElementById('wf-location-suggestions').style.display = 'none';
document.getElementById('wf-location-btn-label').textContent = 'GPS → POI suchen';
if (_miniMarker) { _miniMarker.remove(); _miniMarker = null; }
if (_miniMap) { _miniMap.setView([48.0, 11.9], 7); }
});
// GPS → POI-Suche (wie diary.js)
async function _showSuggestions() {
const btn = document.getElementById('wf-location-btn');
UI.setLoading(btn, true);
try {
let lat = _locLat, lon = _locLon;
if (lat == null || lon == null) {
const pos = await API.getLocation({ enableHighAccuracy: true });
lat = pos.lat; lon = pos.lon;
_setCoords(lat, lon);
if (_miniMap) {
_miniMap.setView([lat, lon], 15);
_placeMarker(lat, lon);
if (_miniMarker) _miniMarker.dragging.disable();
}
document.getElementById('wf-location-btn-label').textContent = 'POI suchen';
}
const suggestions = _appState.user
? await API.walks.nearby(lat, lon)
: [];
const sugEl = document.getElementById('wf-location-suggestions');
if (!suggestions.length) {
sugEl.innerHTML = '<p style="font-size:var(--text-sm);color:var(--c-text-secondary);padding:var(--space-2) 0">Keine Orte in der Nähe gefunden.</p>';
} else {
sugEl.innerHTML = suggestions.map(s => `
<button type="button" class="diary-location-suggestion"
data-name="${UI.escape(s.name)}" data-lat="${s.lat}" data-lon="${s.lon}">
${UI.icon(_sourceIcon(s.source))}
<span>${UI.escape(s.name)}</span>
<small>${s.distance_m < 1000 ? s.distance_m + ' m' : (s.distance_m / 1000).toFixed(1) + ' km'}</small>
</button>`).join('');
sugEl.querySelectorAll('.diary-location-suggestion').forEach(el => {
el.addEventListener('click', () => {
const slat = parseFloat(el.dataset.lat);
const slon = parseFloat(el.dataset.lon);
_setCoords(slat, slon);
_setName(el.dataset.name);
if (_miniMap) {
_miniMap.setView([slat, slon], 16);
_placeMarker(slat, slon);
if (_miniMarker) _miniMarker.dragging.disable();
}
});
});
}
sugEl.style.display = '';
} catch (err) {
UI.toast.error(err?.message?.includes('GPS') || _locLat == null
? 'GPS nicht verfügbar.' : 'Ortssuche fehlgeschlagen.');
} finally {
UI.setLoading(btn, false);
}
}
document.getElementById('wf-location-btn')?.addEventListener('click', _showSuggestions);
// Formular absenden
document.getElementById('walk-form')?.addEventListener('submit', async e => {

View file

@ -453,6 +453,10 @@ const UI = (() => {
const isDark = document.documentElement.dataset.theme === 'dark';
if (isDark) tiles.getContainer().style.filter = 'brightness(0.7) invert(1) contrast(0.9) hue-rotate(200deg)';
}
// Safety-Net: Container-Größe nach Layout neu vermessen. Verhindert
// grau bleibende Bereiche wenn die Karte vor dem finalen Layout erstellt
// wird (z.B. in frisch eingefügten Overlays mit flex:1).
requestAnimationFrame(() => m.invalidateSize());
return m;
},
@ -873,12 +877,35 @@ const UI = (() => {
coordsClear: `${p}-coords-clear`,
suggestions: `${p}-suggestions`,
pinHere: `${p}-pin-here`,
geoInput: `${p}-geo-input`,
geoClear: `${p}-geo-clear`,
geoResults: `${p}-geo-results`,
};
// HTML in den Container rendern
function _render(container) {
container.innerHTML = `
<div style="position:relative">
<!-- Geocoding-Suchfeld als Overlay oben left:46px lässt Zoom-Control frei -->
<div style="position:absolute;top:8px;left:46px;right:8px;z-index:1001">
<div style="display:flex;align-items:center;gap:7px;background:rgba(255,255,255,0.96);
border-radius:var(--radius-full);padding:6px 11px;
box-shadow:0 2px 8px rgba(0,0,0,0.22);backdrop-filter:blur(4px)">
<svg class="ph-icon" aria-hidden="true" style="width:14px;height:14px;flex-shrink:0;color:#aaa"><use href="/icons/phosphor.svg#magnifying-glass"></use></svg>
<input type="search" id="${ids.geoInput}" placeholder="Ort oder Adresse suchen…"
autocomplete="off" autocorrect="off" spellcheck="false"
style="flex:1;border:none;outline:none;font-size:13px;background:transparent;
font-family:inherit;color:var(--c-text);min-width:0">
<button type="button" id="${ids.geoClear}" aria-label="Suche löschen"
style="display:none;background:none;border:none;padding:2px;cursor:pointer;
color:#bbb;line-height:1">
<svg class="ph-icon" aria-hidden="true" style="width:13px;height:13px"><use href="/icons/phosphor.svg#x"></use></svg>
</button>
</div>
<div id="${ids.geoResults}" style="display:none;background:rgba(255,255,255,0.98);
border-radius:10px;box-shadow:0 4px 14px rgba(0,0,0,0.18);
margin-top:5px;overflow:hidden;max-height:190px;overflow-y:auto"></div>
</div>
<div id="${ids.mapWrap}" style="border-radius:var(--radius-md);overflow:hidden;height:200px;background:var(--c-surface-2)"></div>
<button type="button" id="${ids.pinHere}" style="
position:absolute;bottom:10px;left:50%;transform:translateX(-50%);
@ -1102,6 +1129,75 @@ const UI = (() => {
}
_getEl(ids.locBtn)?.addEventListener('click', _showSuggestions);
// Geocoding-Suche
let _geoTimer = null;
const geoInput = _getEl(ids.geoInput);
const geoClear = _getEl(ids.geoClear);
const geoResults = _getEl(ids.geoResults);
geoInput?.addEventListener('input', () => {
const q = geoInput.value.trim();
if (geoClear) geoClear.style.display = q ? '' : 'none';
clearTimeout(_geoTimer);
if (q.length < 2) { if (geoResults) geoResults.style.display = 'none'; return; }
_geoTimer = setTimeout(async () => {
if (geoResults) {
geoResults.innerHTML = '<div style="padding:9px 13px;font-size:12px;color:var(--c-text-secondary)">Suche…</div>';
geoResults.style.display = '';
}
try {
const data = await API.get(`/osm/geocode?q=${encodeURIComponent(q)}`);
if (!geoResults) return;
if (!data.length) {
geoResults.innerHTML = '<div style="padding:9px 13px;font-size:12px;color:var(--c-text-secondary)">Keine Ergebnisse</div>';
return;
}
geoResults.innerHTML = data.map((r, i) => `
<div data-i="${i}" style="padding:9px 13px;cursor:pointer;border-bottom:1px solid rgba(0,0,0,0.05)">
<div style="font-size:13px;font-weight:600;color:var(--c-text);white-space:nowrap;overflow:hidden;text-overflow:ellipsis">${escape(r.name)}</div>
${r.subtitle ? `<div style="font-size:11px;color:var(--c-text-secondary)">${escape(r.subtitle)}</div>` : ''}
</div>`).join('');
geoResults.querySelectorAll('[data-i]').forEach(el => {
el.addEventListener('pointerdown', e => {
e.preventDefault();
const r = data[+el.dataset.i];
_setCoords(r.lat, r.lon);
_setName(r.name);
if (_map) {
_map.flyTo([r.lat, r.lon], 15, { duration: 0.8 });
_placeMarker(r.lat, r.lon);
}
const lbl = _getEl(ids.locBtnLabel);
if (lbl) lbl.textContent = 'POI suchen';
geoInput.value = '';
if (geoClear) geoClear.style.display = 'none';
geoResults.style.display = 'none';
onSelect?.(_lat, _lon, _name);
});
});
} catch {
if (geoResults) geoResults.innerHTML = '<div style="padding:9px 13px;font-size:12px;color:var(--c-text-secondary)">Suche nicht verfügbar</div>';
}
}, 400);
});
geoInput?.addEventListener('keydown', e => {
if (e.key === 'Escape') {
geoInput.value = '';
if (geoClear) geoClear.style.display = 'none';
if (geoResults) geoResults.style.display = 'none';
}
});
geoClear?.addEventListener('click', () => {
geoInput.value = '';
geoClear.style.display = 'none';
if (geoResults) geoResults.style.display = 'none';
});
_getEl(ids.mapWrap)?.addEventListener('pointerdown', () => {
if (geoResults) geoResults.style.display = 'none';
geoInput?.blur();
});
}
// Container initialisieren

View file

@ -4,7 +4,7 @@
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="color-scheme" content="light dark">
<script src="/js/landing-init.js?v=1141"></script>
<script src="/js/landing-init.js?v=1155"></script>
<title>Ban Yaro — Die Hunde-App für Deutschland, Österreich & Schweiz</title>
<meta name="description" content="Ban Yaro: Die kostenlose All-in-One Hunde-App für DACH. Tagebuch, Giftköder-Alarm, Training mit KI, Forum, Wurfbörse, Stammbaum, Inzucht-Check — DSGVO-konform, offline-fähig, ohne App Store.">
<meta name="keywords" content="Hunde App, Hunde Community, Wurfbörse, Züchter, Welpen kaufen, Stammbaum Hund, Inzuchtkoeffizient, Hundezucht, Impfpass Hund, Giftköder Alarm, Gassi Community, Hundetraining App, Hunde Forum, Hunde KI, Hundefilm Datenbank, Welpen Marktplatz">
@ -149,6 +149,25 @@
}
</script>
<script type="application/ld+json">
{
"@context": "https://schema.org",
"@type": "WebSite",
"name": "Ban Yaro",
"url": "https://banyaro.app",
"description": "Die Hunde-App für Deutschland, Österreich und die Schweiz",
"inLanguage": "de",
"potentialAction": {
"@type": "SearchAction",
"target": {
"@type": "EntryPoint",
"urlTemplate": "https://banyaro.app/wiki/rassen?q={search_term_string}"
},
"query-input": "required name=search_term_string"
}
}
</script>
<style>
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }

View file

@ -3,5 +3,10 @@ Disallow: /api/
Disallow: /ausweis/
Disallow: /teilen/
Disallow: /media/
Disallow: /force-update
Disallow: /pass/
Disallow: /widget
Disallow: /litters
Disallow: /?_t
Sitemap: https://banyaro.app/sitemap.xml

View file

@ -4,7 +4,7 @@
============================================================ */
// ← EINZIGE Stelle für die Version — STATIC_ASSETS und CACHE_VERSION leiten sich ab
const VER = '1141';
const VER = '1155';
const CACHE_VERSION = `by-v${VER}`;
const CACHE_STATIC = `${CACHE_VERSION}-static`;
const CACHE_TILES = 'ban-yaro-tiles-v1'; // bleibt über SW-Updates erhalten

16
docker-compose.osm.yml Normal file
View file

@ -0,0 +1,16 @@
# Monatlicher OSM-POI-Refresh (Build 4) — NICHT Teil des Default-Stacks.
# Wird manuell oder vom DSM-Aufgabenplaner getriggert:
# docker compose -f docker-compose.osm.yml run --rm osm-refresh
# Schreibt in dieselbe SQLite-DB wie der App-Container (./data:/data).
services:
osm-refresh:
build: ./tools/osm-extract
image: banyaro-osm-refresh
container_name: banyaro-osm-refresh
mem_limit: 4g # Schutzschranke gegen die anderen Container
volumes:
- ./data:/data # gleiche DB wie die App (/data/banyaro.db)
environment:
- DB_PATH=/data/banyaro.db
# - COUNTRIES=switzerland austria germany # bei Bedarf überschreiben
restart: "no"

Binary file not shown.

Binary file not shown.

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

Binary file not shown.

Binary file not shown.

5
tools/osm-extract/.gitignore vendored Normal file
View file

@ -0,0 +1,5 @@
# Große/temporäre Datendateien — nie committen
*.osm.pbf
*.sqlite
*.db
*.log

View file

@ -0,0 +1,14 @@
FROM python:3.12-slim-bookworm
# osmium-tool = RAM-schonende tags-filter-Vorstufe (C++, streaming),
# pyosmium = Extraktion + Schwerpunktberechnung.
RUN apt-get update && apt-get install -y --no-install-recommends \
osmium-tool curl ca-certificates \
&& rm -rf /var/lib/apt/lists/*
RUN pip install --no-cache-dir osmium
WORKDIR /app
COPY extract_osm_pois.py load_into_prod.py refresh.sh /app/
RUN chmod +x /app/refresh.sh
ENTRYPOINT ["/app/refresh.sh"]

View file

@ -0,0 +1,74 @@
# Build 4 — POI-Offline-Umstellung auf der DiskStation (DSM-Upload)
Ziel: Live-Overpass-Scanner abschalten + die 1,45 Mio DACH-POIs in die
Produktiv-DB migrieren. Ohne 5,7-GB-Download (die fertige `dach.sqlite` wird
mit hochgeladen).
App-Verzeichnis auf der DS: **`/volume1/docker/banyaro/`** (im File Station:
`docker``banyaro`).
---
## Schritt 1 — Code hochladen (File Station)
1. `build4-osm-code.zip` in den Ordner **`docker/banyaro`** hochladen.
2. Rechtsklick → **Entpacken → Hierher entpacken**. Das überschreibt
`backend/routes/osm.py` (Scanner aus) und legt `tools/osm-extract/` +
`docker-compose.osm.yml` an. Andere Dateien bleiben unberührt.
## Schritt 2 — Vorbereiteten POI-Extrakt hochladen
3. `dach.sqlite` (181 MB) in den Ordner **`docker/banyaro/data`** hochladen.
(Liegt dann im Container als `/data/dach.sqlite`.)
## Schritt 3 — Migration + Deploy (SSH-Terminal: `ssh ds`)
```sh
cd /volume1/docker/banyaro
# Refresh-Image bauen (einmalig)
docker compose -f docker-compose.osm.yml build
# MIGRATION: lädt dach.sqlite in die Produktiv-DB (Backup wird vorher angelegt,
# user_edited-POIs + Community-Marker bleiben geschützt)
docker compose -f docker-compose.osm.yml run --rm \
-e PREBUILT_SQLITE=/data/dach.sqlite osm-refresh
# DEPLOY: baut die App neu → scanner-lose osm.py geht live
docker compose up -d --build
```
> Reihenfolge bewusst: erst Daten laden, dann App neu bauen → kein Fenster mit
> leerer Karte.
## Schritt 4 — Prüfen
```sh
docker compose -f docker-compose.osm.yml run --rm --entrypoint python3 \
osm-refresh -c "import sqlite3; c=sqlite3.connect('/data/banyaro.db'); \
print('osm_pois:', c.execute('select count(*) from osm_pois').fetchone()[0]); \
print(c.execute('select type,count(*) from osm_pois group by type order by 2 desc').fetchall())"
```
Erwartung: ~1.452.675 POIs (bank ~1,0 Mio, restaurant ~60k …). In der App die
Karte öffnen → Marker laden ohne Overpass.
## Aufräumen (optional)
`dach.sqlite` aus `docker/banyaro/data` kann nach erfolgreicher Migration weg.
## Monatlicher Auto-Refresh (DSM-Aufgabenplaner)
Systemsteuerung → Aufgabenplaner → Erstellen → Geplante Aufgabe →
Benutzerdefiniertes Skript. Benutzer `root`, monatlich z. B. 1. um 04:00:
```sh
cd /volume1/docker/banyaro && \
docker compose -f docker-compose.osm.yml run --rm osm-refresh \
>> /volume1/docker/banyaro/data/osm-refresh.log 2>&1
```
(ohne `PREBUILT_SQLITE` → holt frisch von Geofabrik, ~12 GB RAM dank tags-filter)
## Rollback
Vor jedem Lauf entsteht `data/banyaro.pre-osm-JJJJMMTT.db`. Im Notfall:
App stoppen, diese Datei auf `banyaro.db` zurückkopieren, App starten.

View file

@ -0,0 +1,55 @@
# OSM-POI Offline-Refresh (Build 4)
Ersetzt das Live-Scannen gegen `overpass-api.de` (war wiederholt OSM-Bann-Quelle)
durch einen **monatlichen Offline-Batch**: POIs werden aus den Geofabrik-OSM-Daten
extrahiert und in die Produktiv-DB geladen. Danach keine OSM-Live-Last mehr.
## Bestandteile
| Datei | Zweck |
|---|---|
| `extract_osm_pois.py` | pbf → `osm_pois`-Schema (pyosmium). Kanonisches Kategorie→Tag-Mapping. |
| `load_into_prod.py` | Extrakt → Produktiv-DB. Schützt `user_edited=1`, ersetzt den Rest. |
| `refresh.sh` | Orchestrierung im Container: download → tags-filter → extract → load. |
| `Dockerfile` | Image mit osmium-tool + pyosmium. |
| `../../docker-compose.osm.yml` | Eigener Service, **nicht** im Default-Stack. |
## Was es tut
- Lädt CH/AT/DE von Geofabrik (~5,7 GB), dampft sie mit `osmium tags-filter`
(streaming, <500 MB RAM) auf die relevanten Objekte ein, extrahiert die 9
Ban-Yaro-Kategorien und lädt sie in `/data/banyaro.db`.
- **Sicherheitskopie** der DB vor jedem Lauf (letzte 3 bleiben).
- `user_edited=1`-POIs und `user_map_pois` (Community-Marker) bleiben unberührt.
- Peak-RAM ~12 GB, hartes `mem_limit: 4g` als Schutzschranke.
- Aktuelle Größenordnung: DACH ~1,45 Mio POIs, ~180 MB.
## Manuell ausführen (Test)
```sh
cd /pfad/zu/banyaro # dort, wo docker-compose.yml liegt
docker compose -f docker-compose.osm.yml build
docker compose -f docker-compose.osm.yml run --rm osm-refresh
```
## Monatlich per DSM-Aufgabenplaner
1. **Systemsteuerung → Aufgabenplaner → Erstellen → Geplante Aufgabe → Benutzerdefiniertes Skript**
2. Benutzer: `root` (für Docker-Zugriff)
3. Zeitplan: **monatlich**, z. B. am 1. um 04:00 (lastarm)
4. Aufgabeneinstellungen → Benutzerdefiniertes Skript:
```sh
cd /volume1/docker/banyaro && \
/usr/local/bin/docker compose -f docker-compose.osm.yml run --rm osm-refresh \
>> /volume1/docker/banyaro/data/osm-refresh.log 2>&1
```
> Pfad `/volume1/docker/banyaro` an euer Compose-Verzeichnis anpassen.
> Docker-Binary auf DSM ist meist `/usr/local/bin/docker` (`docker compose`),
> bei älteren DSM ggf. `docker-compose`.
## Rollback
Vor jedem Lauf wird `banyaro.pre-osm-JJJJMMTT.db` neben der DB abgelegt. Im
Notfall App stoppen, diese Datei auf `banyaro.db` zurückkopieren, App starten.

View file

@ -0,0 +1,140 @@
#!/usr/bin/env python3
"""
Offline-Extraktion der Ban-Yaro-POIs aus einer OSM-.pbf-Datei.
Ersetzt das Live-Scannen gegen overpass-api.de (backend/routes/osm.py) durch
einen einmaligen/periodischen Batch-Lauf keine OSM-Live-Last, kein Bann mehr.
Das KategorieTag-Mapping ist 1:1 aus OSM_QUERIES in backend/routes/osm.py
übernommen. Schreibt ins selbe Schema wie die Produktiv-Tabelle `osm_pois`
(osm_id, type, lat, lon, name, opening_hours, phone, website, cached_at).
Aufruf:
python3 extract_osm_pois.py <input.osm.pbf> <output.sqlite>
"""
import sys
import sqlite3
import osmium
# --- Kategorie-Klassifikation (1:1 aus OSM_QUERIES, backend/routes/osm.py) ---
def classify(t) -> list[str]:
"""Gibt alle Ban-Yaro-Typen zurück, die auf dieses OSM-Objekt passen."""
types: list[str] = []
a = t.get("amenity")
shop = t.get("shop")
craft = t.get("craft")
leisure = t.get("leisure")
tourism = t.get("tourism")
dog = t.get("dog")
outdoor = t.get("outdoor_seating")
# "hundefreundlich, breiter gefasst": explizit erlaubt ODER Terrasse
dog_ok = dog in ("yes", "allowed", "leashed")
if a == "waste_basket":
types.append("waste_basket")
if a == "drinking_water":
types.append("drinking_water")
if a == "veterinary":
types.append("tierarzt")
if a == "bench":
types.append("bank")
if leisure == "dog_park" or (leisure == "park" and dog == "yes"):
types.append("dog_park")
if shop == "pet":
types.append("shop")
if shop == "pet_grooming" or craft == "pet_grooming":
types.append("hundesalon")
if (a in ("restaurant", "cafe") and (dog_ok or outdoor == "yes")) or a == "biergarten":
types.append("restaurant")
if tourism in ("hotel", "guest_house", "hostel") and dog_ok:
types.append("hotel")
return types
SCHEMA = """
CREATE TABLE IF NOT EXISTS osm_pois (
osm_id INTEGER NOT NULL,
type TEXT NOT NULL,
lat REAL NOT NULL,
lon REAL NOT NULL,
name TEXT,
opening_hours TEXT,
phone TEXT,
website TEXT,
user_edited INTEGER NOT NULL DEFAULT 0,
cached_at TEXT NOT NULL DEFAULT (datetime('now')),
PRIMARY KEY (osm_id, type)
);
CREATE INDEX IF NOT EXISTS idx_osm_pois_loc ON osm_pois(type, lat, lon);
"""
class PoiHandler(osmium.SimpleHandler):
def __init__(self, conn):
super().__init__()
self.conn = conn
self.rows = 0
self.objs = 0
def _save(self, osm_id, tags, lat, lon):
types = classify(tags)
if not types:
return
self.objs += 1
name = tags.get("name")
oh = tags.get("opening_hours")
phone = tags.get("phone") or tags.get("contact:phone")
web = tags.get("website") or tags.get("contact:website")
for ty in types:
self.conn.execute(
"INSERT OR REPLACE INTO osm_pois "
"(osm_id, type, lat, lon, name, opening_hours, phone, website, cached_at) "
"VALUES (?,?,?,?,?,?,?,?, datetime('now'))",
(osm_id, ty, lat, lon, name, oh, phone, web),
)
self.rows += 1
def node(self, n):
if n.tags:
self._save(n.id, n.tags, n.location.lat, n.location.lon)
def way(self, w):
# Wege (z. B. Tierarzt im Gebäude) → Schwerpunkt aus Knoten ("out center")
if not w.tags:
return
lats, lons = [], []
for nd in w.nodes:
if nd.location.valid():
lats.append(nd.location.lat)
lons.append(nd.location.lon)
if lats:
self._save(w.id, w.tags, sum(lats) / len(lats), sum(lons) / len(lons))
def main():
if len(sys.argv) != 3:
print(__doc__)
sys.exit(1)
src, dst = sys.argv[1], sys.argv[2]
conn = sqlite3.connect(dst)
conn.executescript(SCHEMA)
h = PoiHandler(conn)
# locations=True: Knoten-Koordinaten im Speicher halten, damit Wege einen
# Schwerpunkt bekommen. flex_mem skaliert bis Länder-Extrakte.
h.apply_file(src, locations=True, idx="flex_mem")
conn.commit()
print(f"\nObjekte mit Treffer: {h.objs:,} eingefügte Zeilen: {h.rows:,}")
print("\nPro Typ:")
for ty, cnt in conn.execute(
"SELECT type, COUNT(*) FROM osm_pois GROUP BY type ORDER BY 2 DESC"
):
print(f" {ty:16s} {cnt:>8,}")
conn.close()
if __name__ == "__main__":
main()

View file

@ -0,0 +1,71 @@
#!/usr/bin/env python3
"""
Lädt die offline extrahierten POIs (dach.sqlite) in die Produktiv-DB.
Semantik des Monats-Refresh:
* Alle nicht-editierten OSM-POIs (user_edited=0) werden ersetzt POIs, die
aus OSM verschwunden sind, fallen sauber raus.
* Von Nutzern korrigierte POIs (user_edited=1, via Moderation) bleiben
UNANGETASTET INSERT OR IGNORE überspringt sie bei Kollision.
* Community-Marker (Tabelle user_map_pois) sind separat und werden nie
berührt.
Läuft in EINER Transaktion. Bei Fehler bleibt die alte DB unverändert.
Aufruf:
python3 load_into_prod.py <extract.sqlite> <ziel/banyaro.db>
"""
import sys
import sqlite3
COLS = "osm_id, type, lat, lon, name, opening_hours, phone, website, user_edited, cached_at"
def main():
if len(sys.argv) != 3:
print(__doc__)
sys.exit(1)
extract_path, prod_path = sys.argv[1], sys.argv[2]
# timeout/busy_timeout: die App schreibt evtl. parallel — auf Lock warten,
# statt sofort zu scheitern. Der Load läuft in EINER Transaktion.
conn = sqlite3.connect(prod_path, timeout=120)
conn.execute("PRAGMA busy_timeout=120000")
conn.execute("PRAGMA foreign_keys=ON")
conn.execute(f"ATTACH DATABASE ? AS ext", (extract_path,))
before = conn.execute("SELECT COUNT(*) FROM osm_pois").fetchone()[0]
edited = conn.execute("SELECT COUNT(*) FROM osm_pois WHERE user_edited=1").fetchone()[0]
incoming = conn.execute("SELECT COUNT(*) FROM ext.osm_pois").fetchone()[0]
try:
conn.execute("BEGIN")
# 1) nicht-editierte OSM-POIs verwerfen (editierte bleiben stehen)
conn.execute("DELETE FROM osm_pois WHERE user_edited=0")
# 2) frische Extraktion einspielen; editierte Survivor nicht überschreiben
conn.execute(
f"INSERT OR IGNORE INTO osm_pois ({COLS}) "
f"SELECT {COLS} FROM ext.osm_pois"
)
conn.execute("COMMIT")
except Exception:
conn.execute("ROLLBACK")
raise
after = conn.execute("SELECT COUNT(*) FROM osm_pois").fetchone()[0]
print(f"Vorher: {before:>10,}")
print(f"davon user_edited:{edited:>10,} (geschützt)")
print(f"Eingespielt: {incoming:>10,}")
print(f"Nachher: {after:>10,}")
print("\nPro Typ (nachher):")
for ty, cnt in conn.execute(
"SELECT type, COUNT(*) FROM osm_pois GROUP BY type ORDER BY 2 DESC"
):
print(f" {ty:16s} {cnt:>9,}")
conn.execute("DETACH DATABASE ext")
conn.close()
if __name__ == "__main__":
main()

View file

@ -0,0 +1,66 @@
#!/usr/bin/env bash
#
# Monatlicher POI-Refresh (Build 4) — läuft im Docker-Container auf der Synology.
# download → tags-filter (RAM-schonend) → extract → load in die Produktiv-DB.
# Ersetzt das Live-Overpass-Scannen (war Bann-Quelle).
#
# Erst-Migration ohne 5,7-GB-Download: vorab gebaute dach.sqlite mitliefern und
# PREBUILT_SQLITE=/data/dach.sqlite setzen → überspringt download/extract.
#
set -euo pipefail
DB="${DB_PATH:-/data/banyaro.db}"
WORK="${WORK_DIR:-/work}"
COUNTRIES="${COUNTRIES:-switzerland austria germany}"
GEOFABRIK="${GEOFABRIK_BASE:-https://download.geofabrik.de/europe}"
KEEP_BACKUPS="${KEEP_BACKUPS:-3}"
PREBUILT_SQLITE="${PREBUILT_SQLITE:-}"
# OSM-Tags für die 9 Ban-Yaro-Kategorien (Superset; finale Klassifikation macht
# extract_osm_pois.py). nw/ = node+way, referenzierte Knoten bleiben für die
# Weg-Geometrie automatisch erhalten.
FILTER=(
"nw/amenity=waste_basket,drinking_water,veterinary,bench,biergarten,restaurant,cafe"
"nw/leisure=dog_park,park"
"nw/shop=pet,pet_grooming"
"nw/craft=pet_grooming"
"nw/tourism=hotel,guest_house,hostel"
)
mkdir -p "$WORK"; cd "$WORK"
echo "[$(date -u)] POI-Refresh start → $DB"
# 1) Sicherheitskopie der Produktiv-DB (nur die letzten N behalten)
if [ -f "$DB" ]; then
bak="${DB%.db}.pre-osm-$(date -u +%Y%m%d).db"
cp -p "$DB" "$bak"
echo "Backup: $bak"
ls -1t "${DB%.db}".pre-osm-*.db 2>/dev/null | tail -n +$((KEEP_BACKUPS + 1)) | xargs -r rm -f
fi
# 2a) Schnellweg: vorab gebauten Extrakt direkt laden (kein Download/Extract)
if [ -n "$PREBUILT_SQLITE" ]; then
[ -f "$PREBUILT_SQLITE" ] || { echo "FEHLER: $PREBUILT_SQLITE nicht gefunden"; exit 1; }
echo "[$(date -u)] PREBUILT_SQLITE=$PREBUILT_SQLITE → überspringe download/extract"
python3 /app/load_into_prod.py "$PREBUILT_SQLITE" "$DB"
echo "[$(date -u)] POI-Refresh (prebuilt) fertig."
exit 0
fi
# 2b) Regulärer Monatslauf: frisch holen + extrahieren
rm -f dach.sqlite
for c in $COUNTRIES; do
echo "[$(date -u)] $c: download"
curl -fSL --retry 3 -o "$c.osm.pbf" "$GEOFABRIK/$c-latest.osm.pbf"
echo "[$(date -u)] $c: tags-filter"
osmium tags-filter --overwrite -o "$c.f.osm.pbf" "$c.osm.pbf" "${FILTER[@]}"
rm -f "$c.osm.pbf"
echo "[$(date -u)] $c: extract"
python3 /app/extract_osm_pois.py "$c.f.osm.pbf" dach.sqlite
rm -f "$c.f.osm.pbf"
done
echo "[$(date -u)] load → Produktiv-DB"
python3 /app/load_into_prod.py dach.sqlite "$DB"
rm -f dach.sqlite
echo "[$(date -u)] POI-Refresh fertig."