Karten-Ausbau (OSM), Forum-Erweiterung, UI-Komponenten, Refactor Tagebuch/Gassi (DRY), Landing/SEO — APP_VER 1155

This commit is contained in:
rene 2026-06-03 17:24:47 +02:00
parent 2d907f6370
commit 10e39ed135
18 changed files with 871 additions and 405 deletions

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';
// 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

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