Karten-Ausbau (OSM), Forum-Erweiterung, UI-Komponenten, Refactor Tagebuch/Gassi (DRY), Landing/SEO — APP_VER 1155
This commit is contained in:
parent
2d907f6370
commit
10e39ed135
18 changed files with 871 additions and 405 deletions
2
VERSION
2
VERSION
|
|
@ -1 +1 @@
|
|||
1141
|
||||
1155
|
||||
197
backend/main.py
197
backend/main.py
|
|
@ -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ärz–Juli) 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 25–300 €. 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):
|
||||
|
|
|
|||
|
|
@ -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
|
||||
# ------------------------------------------------------------------
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
}
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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
|
||||
// ----------------------------------------------------------
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
|
||||
})();
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 => {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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; }
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue