Compare commits

...

2 commits

26 changed files with 1619 additions and 405 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

@ -511,11 +511,11 @@ async def sitemap():
urls = [
("https://banyaro.app/", "weekly", "1.0"),
("https://banyaro.app/zuechter", "weekly", "0.9"),
("https://banyaro.app/info", "monthly", "0.8"),
("https://banyaro.app/presse", "monthly", "0.7"),
("https://banyaro.app/wiki/rassen", "weekly", "0.8"),
("https://banyaro.app/knigge", "monthly", "0.7"),
("https://banyaro.app/wurfboerse", "daily", "0.8"),
("https://banyaro.app/wiki/rassen", "weekly", "0.8"),
("https://banyaro.app/help", "monthly", "0.7"),
("https://banyaro.app/knigge", "monthly", "0.7"),
("https://banyaro.app/partner", "monthly", "0.6"),
]
try:
@ -526,12 +526,6 @@ async def sitemap():
for r in rassen:
urls.append((f"https://banyaro.app/wiki/rasse/{r['slug']}", "monthly", "0.7"))
events = conn.execute(
"SELECT id FROM events WHERE datum >= date('now') LIMIT 200"
).fetchall()
for e in events:
urls.append((f"https://banyaro.app/api/events/{e['id']}", "weekly", "0.5"))
# Öffentliche Züchter-Profile
breeders = conn.execute(
"SELECT bp.zwingername FROM breeder_profiles bp "
@ -1348,12 +1342,47 @@ async def public_dog_page(dog_id: int):
# ------------------------------------------------------------------
@app.get("/teilen/{token}")
async def invite_page(token: str):
return FileResponse(f"{STATIC_DIR}/index.html", headers={"Cache-Control": "no-store, no-cache"})
from fastapi.responses import HTMLResponse
with open(f"{STATIC_DIR}/index.html", encoding="utf-8") as _f:
_html = _f.read()
_html = _html.replace(
'<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 +1500,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 +1687,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 +1765,9 @@ async def help_page():
k for k in by_kat.keys() if k not in KAT_LABEL
]
import json as _json
sections_html = ""
faq_items = []
for kat in kat_order:
label = KAT_LABEL.get(kat, kat.replace("_", " ").title())
items = "".join(
@ -1736,6 +1776,13 @@ async def help_page():
for a in by_kat[kat]
)
sections_html += f'<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 +1791,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 +1863,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 +1905,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 +1956,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 +2028,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 +2315,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 +2389,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

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

@ -423,3 +423,65 @@ async def submit_poi_edit(osm_id: str, data: PoiEditCreate,
poi[data.field], data.new_value.strip(), user["id"])
)
return {"status": "pending", "message": "Korrektur wurde zur Prüfung eingereicht."}
# ------------------------------------------------------------------
# Geocoding-Proxy GET /api/osm/geocode?q=…
# Nominatim-Rate-Limit: 1 req/s — serverseitig throttled
# ------------------------------------------------------------------
_nominatim_sem = asyncio.Semaphore(1)
_nominatim_last = 0.0
@router.get('/geocode')
async def geocode_search(q: str = Query(..., min_length=2, max_length=200)):
import time
global _nominatim_last
async with _nominatim_sem:
wait = 1.1 - (time.monotonic() - _nominatim_last)
if wait > 0:
await asyncio.sleep(wait)
_nominatim_last = time.monotonic()
try:
async with httpx.AsyncClient(timeout=6.0) as client:
resp = await client.get(
'https://nominatim.openstreetmap.org/search',
params={
'q': q,
'format': 'jsonv2',
'limit': 6,
'countrycodes': 'de,at,ch',
'addressdetails': 1,
'accept-language': 'de',
},
headers={
'User-Agent': _OVERPASS_UA,
'Referer': 'https://banyaro.app/',
}
)
resp.raise_for_status()
data = resp.json()
except Exception as e:
logger.warning("Nominatim-Fehler: %s", e)
raise HTTPException(502, "Geocoding nicht verfügbar")
out = []
for r in data[:6]:
addr = r.get('address', {})
short = (
addr.get('amenity') or addr.get('shop') or addr.get('leisure') or
addr.get('road') or addr.get('village') or addr.get('town') or
addr.get('city') or r.get('name') or
r.get('display_name', '').split(',')[0]
)
city = addr.get('city') or addr.get('town') or addr.get('village') or addr.get('municipality') or ''
state = addr.get('state', '')
subtitle = ', '.join(filter(None, [city, state]))
out.append({
'lat': float(r['lat']),
'lon': float(r['lon']),
'name': short,
'subtitle': subtitle,
'full': r.get('display_name', ''),
'type': r.get('type', ''),
})
return out

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';
});
}
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)';
// Location Picker (gemeinsame UI-Komponente)
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); }
const _diaryPicker = UI.locationPicker({
containerId: 'diary-location-picker',
onSelect: (lat, lon, name) => { _locLat = lat; _locLon = lon; _locName = name; },
});
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>
<!-- 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) ? `
<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>
<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>
` : ''}
<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>
</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;
}
});
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

@ -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';
});
}
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(() => {
// Location Picker
let _wfPicker = null;
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);
_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);
// 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

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.