Compare commits
No commits in common. "214543559c9e26a9dc19643e0c77ba10ab44c705" and "2d907f6370af1248ec903b90ca5038a75e177540" have entirely different histories.
214543559c
...
2d907f6370
26 changed files with 405 additions and 1619 deletions
1
.gitignore
vendored
1
.gitignore
vendored
|
|
@ -12,4 +12,3 @@ __pycache__/
|
|||
/icons/
|
||||
.claude/worktrees/
|
||||
Ban Yaro - Google Play package/
|
||||
/unsplash/
|
||||
|
|
|
|||
72
MARKETING.md
72
MARKETING.md
|
|
@ -1,72 +0,0 @@
|
|||
# 🐾 Ban Yaro — Marketing-Cockpit
|
||||
|
||||
**Single Source of Truth fürs Marketing.** Vor jeder Aktion hier prüfen, danach updaten — so wird nichts doppelt gemacht, vergessen oder übersehen. Pflege: René + Claude.
|
||||
|
||||
_Stand: 2026-06-03_
|
||||
|
||||
> Diese Datei = Planung & Checkliste. Für **Live-Daten** (User-Meilenstein, Kanal-Tracking) lohnt zusätzlich ein Marketing-Tab im **Admin-Bereich** — siehe „Ausbau" unten.
|
||||
|
||||
## 📊 Kanal-Überblick
|
||||
| Kanal / Bereich | Status | Nächster Schritt |
|
||||
|---|---|---|
|
||||
| Flyer Print | 🟢 1000 gedruckt (03.06.) | lokal verteilen |
|
||||
| Flyer Digital | 💡 Idee | Doppelseiten-PDF + Empfehlungs-QR |
|
||||
| Lokal (Ebersberg) | ⬜ offen | Tierärzte, Hundeschulen, Futterläden, Tierheim |
|
||||
| Online-Communities | ⬜ offen | FB-Gruppen Landkreis EBE + nebenan.de |
|
||||
| Empfehlung / Referral | 🟡 Infra da (`referral_code`) | Empfehlungs-QR + Tracking sichtbar machen |
|
||||
| Influencer | 🟡 2 Runden (Mai), kaum Resonanz | Runde 3 erst ab ~50 aktiven Usern |
|
||||
| Presse / Blogs | 🟡 1 Runde, kaum Resonanz | keine Massenwelle; Nische zuerst |
|
||||
| Verzeichnisse / Listings | ⬜ offen | Product Hunt, PWA-Dirs, Google Business EBE |
|
||||
| SEO / KI-Auffindbarkeit | 🟡 technisch optimiert | Backlinks (Blog-Testberichte) |
|
||||
| Landing Page | 🟡 Redesign-Briefing da | 3 Einstiege, Outcomes statt Features |
|
||||
| App Store (iOS) | 🟢 in Review (1.0 (3), 03.06.) | Freigabe abwarten |
|
||||
| Play Store (Android) | 🔴 ON HOLD | 12 Closed-Tester / 14 Tage fehlen |
|
||||
| Merch / NFC-Halsband | 💡 recherchiert | 20 Tags für Beta (~33 €) |
|
||||
|
||||
Legende: 🟢 läuft/erledigt · 🟡 angefangen · ⬜ offen · 💡 Idee · 🔴 blockiert
|
||||
|
||||
## ⏳ Gates / Trigger (nicht zu früh starten)
|
||||
- **Influencer & Presse Runde 3** erst ab **~50 aktiven Usern** — vorher zu früh (Großredaktionen fragen zuerst nach Zahlen). → Bei jeder Session aktuelle User-Zahl checken.
|
||||
- iOS-App ist nativ gebaut & in Review — **überholt** die alte „iOS erst ab 10k via Rork/PWABuilder"-Strategie.
|
||||
|
||||
## 📋 Backlog (konkret als Nächstes)
|
||||
- [ ] **Flyer lokal verteilen (Ebersberg)** — Tierärzte (Wartezimmer), Hundeschulen/Welpengruppen, Futterläden, Hundesalons, Tierheim, Hundewiesen-Aushänge, hundefreundliche Cafés. Persönlich erklären; Aufhänger: Giftköder-Radar + „Daten in Deutschland". **Lokal bündeln, nicht streuen** (Community-Dichte für Gassi-Treffen/Giftköder).
|
||||
- [ ] **Digitaler Doppelseiten-Flyer (PDF)** mit **Empfehlungs-QR** für Online-/Gruppen-Verteilung. Quelle: `promotion/flyer_a5_*.html`. _Offene Frage: generischer `?ref=empfehlung`-Link vs. pro-User `referral_code`._
|
||||
- [ ] **Lokale FB-Gruppen + nebenan.de** — Flyer-Foto + Link posten.
|
||||
- [ ] **Verzeichnisse** — Product Hunt, progressivewebappstore.com, pwafire.org/directory, Google Business (Ebersberg).
|
||||
- [ ] **Landing-Page-Redesign** nach Briefing (3 Zielgruppen-Einstiege Hundebesitzer/Züchter/Welpenkäufer, Outcomes statt Features, Züchter-SaaS prominent, Datenschutz als Argument, Gründer-Story + Foto).
|
||||
- [ ] **Messung einbauen** — „Wie hast du von uns gehört?" im Onboarding + QR-refs pro Kanal.
|
||||
|
||||
## ✅ Erledigt
|
||||
- [x] 1000 Flyer A5 (zweiseitig) gedruckt — 03.06.2026
|
||||
- [x] iOS-App nativ gebaut + eingereicht (1.0 (3), in Review) — Details im Repo `banyaro-ios`
|
||||
- [x] Influencer-Outreach Runde 1 (5) + Runde 2 (13) — Mai 2026
|
||||
- [x] SEO-Grundlagen (llms.txt, Landing About-Section)
|
||||
|
||||
## 📈 Messung — was bringt wirklich Nutzer?
|
||||
- **Onboarding-Frage „Wie hast du von uns gehört?"** (1 Klick) = billigste & wichtigste Kontrolle. _(noch einzubauen)_
|
||||
- **QR-refs pro Ort/Kanal** (z. B. `banyaro.app/?ref=tierarzt-grafing`) → ab nächster Flyer-Charge.
|
||||
- **`referral_code`** (in DB, `routes/auth.py`) → Empfehlungen zählbar.
|
||||
- Aktive User aktuell: _[aus Admin eintragen]_
|
||||
|
||||
## 🗂 Details je Kanal
|
||||
|
||||
### Influencer
|
||||
2 Runden im Mai gesendet (`partner@banyaro.app`; DKIM/SPF/DMARC aktiv), **kaum Resonanz** — zu früh (wenige User), teils falsche Adressen (z. B. GEO → richtig `chefredaktion@geo.de`).
|
||||
**Runde 3:** keine Massenwelle ohne PR-Agentur; **Hundeschulen/-trainer zuerst** (kleines Netzwerk, empfehlen aktiv Tools, Trainingsfeature ist stark), persönliche Mails, Aufhänger = neue Features + echte Nutzerzahlen.
|
||||
→ **Wer schon kontaktiert wurde:** AI-Memory `project_influencer_outreach` (Runde 1: verpinscht, missyminzi, wanderlust_samoyed, viviundholly, doguniversity, dogstv; Runde 2: nami.and.tommy, brina.explores, heimatherzen, pfotentick, flummis_diary, verwolft, wildwildwilli, knutini_, ninja.vom.wolfstor, pupsonality, osman_theparson, babybearyuki, dogswiss). **Vor neuer Runde dort prüfen.**
|
||||
|
||||
### Play Store (Android TWA)
|
||||
PWABuilder-Paket fertig (`Ban Yaro - Google Play package/`, Package `app.banyaro.twa`). **BLOCKER:** Google verlangt 12 Closed-Tester über 14 Tage — Tester fehlen (Engpass, nicht die Technik). assetlinks.json + Play-Console-Eintrag stehen bereit. Nicht priorisieren bis Tester da.
|
||||
|
||||
### Merch / NFC-Halsband
|
||||
Tag recherchiert: **HID Laundry Tag 16 mm** (shopnfc, SKU RE-ICO2-16, ~1 €/Stk ab 500), für `banyaro.app/hund/{id}`. Beta: 20 Stk (~33 €) an erste Nutzer.
|
||||
|
||||
### Flyer
|
||||
Print: A5 zweiseitig, Quelle `promotion/flyer_a5_allgemein.html` + `flyer_a5_rueckseite.html`, QR → banyaro.app. Vorderseite = alle Hundebesitzer, Rückseite stark Züchter-fokussiert.
|
||||
|
||||
## 🚀 Ausbau: Live-Tool im Admin-Bereich (optional)
|
||||
Diese Datei deckt Planung/Checkliste ab (Claude pflegt sie). Der **Admin-Bereich** lohnt sich für die Teile mit echten Daten:
|
||||
- **User-Meilenstein-Anzeige** (aktive User) → blendet automatisch den „Outreach Runde 3"-Hinweis ein, sobald ~50 erreicht.
|
||||
- **Kanal-Tracking**: Auswertung „Wie gehört?" + QR-ref-Zähler + `referral_code`-Statistik.
|
||||
- Optional: das Kanal-Board (Status/Backlog) als editierbare Admin-Seite.
|
||||
2
VERSION
2
VERSION
|
|
@ -1 +1 @@
|
|||
1155
|
||||
1141
|
||||
195
backend/main.py
195
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/wurfboerse", "daily", "0.8"),
|
||||
("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/help", "monthly", "0.7"),
|
||||
("https://banyaro.app/knigge", "monthly", "0.7"),
|
||||
("https://banyaro.app/partner", "monthly", "0.6"),
|
||||
("https://banyaro.app/wurfboerse", "daily", "0.8"),
|
||||
]
|
||||
|
||||
try:
|
||||
|
|
@ -526,6 +526,12 @@ 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 "
|
||||
|
|
@ -1342,47 +1348,12 @@ async def public_dog_page(dog_id: int):
|
|||
# ------------------------------------------------------------------
|
||||
@app.get("/teilen/{token}")
|
||||
async def invite_page(token: str):
|
||||
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"})
|
||||
return FileResponse(f"{STATIC_DIR}/index.html", headers={"Cache-Control": "no-store, no-cache"})
|
||||
|
||||
|
||||
@app.get("/breeder/{zwingername}")
|
||||
async def breeder_profile_page(zwingername: str):
|
||||
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"})
|
||||
return FileResponse(f"{STATIC_DIR}/index.html", headers={"Cache-Control": "no-store, no-cache"})
|
||||
|
||||
|
||||
@app.get("/litters")
|
||||
|
|
@ -1500,7 +1471,6 @@ 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; }}
|
||||
|
|
@ -1687,14 +1657,6 @@ 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>
|
||||
|
|
@ -1765,9 +1727,7 @@ 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(
|
||||
|
|
@ -1776,13 +1736,6 @@ 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">
|
||||
|
|
@ -1791,8 +1744,6 @@ 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 {{
|
||||
|
|
@ -1863,11 +1814,6 @@ 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>
|
||||
|
|
@ -1905,8 +1851,6 @@ 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);
|
||||
|
|
@ -1956,7 +1900,6 @@ 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}
|
||||
|
|
@ -2028,8 +1971,6 @@ 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">
|
||||
|
|
@ -2315,7 +2256,6 @@ 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; }}
|
||||
|
|
@ -2389,119 +2329,6 @@ 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,25 +586,6 @@ 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,65 +423,3 @@ 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,182 +3256,6 @@ 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=1155"></script>
|
||||
<script src="/js/boot-early.js?v=1141"></script>
|
||||
|
||||
<!-- CSS: Reihenfolge ist wichtig — ?v= zwingt Browser zur Neuladung -->
|
||||
<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">
|
||||
<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">
|
||||
</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=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>
|
||||
<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>
|
||||
|
||||
<!-- Feature-Seiten werden lazy geladen -->
|
||||
|
||||
|
|
@ -631,7 +631,7 @@
|
|||
|
||||
|
||||
<!-- Boot: Offline-Banner + SW-Registration (extrahiert für CSP) -->
|
||||
<script src="/js/boot.js?v=1155"></script>
|
||||
<script src="/js/boot.js?v=1141"></script>
|
||||
|
||||
|
||||
</body>
|
||||
|
|
|
|||
|
|
@ -45,12 +45,9 @@ const API = (() => {
|
|||
throw new APIError(msg, 0, 'network');
|
||||
}
|
||||
|
||||
// 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.
|
||||
// Versions-Check: Server meldet neue Version → Banner anzeigen (einmalig)
|
||||
const serverVer = response.headers.get('x-app-version');
|
||||
if (serverVer && serverVer !== APP_VER && !window._byUpdatePending && !window._BY_SW_RELOAD) {
|
||||
if (serverVer && serverVer !== APP_VER && !window._byUpdatePending) {
|
||||
window._byUpdatePending = true;
|
||||
window._byNewVersion = serverVer;
|
||||
}
|
||||
|
|
@ -442,9 +439,6 @@ 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 = '1155'; // ← bei jedem Deploy mit Frontend-Änderungen erhöhen
|
||||
const APP_VER = '1141'; // ← 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,10 +57,7 @@ if ('serviceWorker' in navigator) {
|
|||
if (!sw) return;
|
||||
sw.addEventListener('statechange', function() {
|
||||
if (sw.state === 'activated') {
|
||||
if (sessionStorage.getItem('by_skip_sw_reload')) {
|
||||
sessionStorage.removeItem('by_skip_sw_reload'); // einmalig konsumieren
|
||||
return;
|
||||
}
|
||||
if (sessionStorage.getItem('by_skip_sw_reload')) return;
|
||||
window.location.replace('/?_t=' + Date.now());
|
||||
}
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1306,7 +1306,37 @@ window.Page_diary = (() => {
|
|||
</div>
|
||||
<div class="form-group" id="diary-location-group">
|
||||
<label class="form-label">Ort <span class="text-secondary">(optional)</span></label>
|
||||
<div id="diary-location-picker"></div>
|
||||
|
||||
<!-- 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>
|
||||
${dogPickerHtml}
|
||||
<div class="form-group" style="margin-top:var(--space-5)">
|
||||
|
|
@ -1508,15 +1538,140 @@ 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;
|
||||
|
||||
// Location Picker (gemeinsame UI-Komponente)
|
||||
setTimeout(() => {
|
||||
const _diaryPicker = UI.locationPicker({
|
||||
containerId: 'diary-location-picker',
|
||||
onSelect: (lat, lon, name) => { _locLat = lat; _locLon = lon; _locName = name; },
|
||||
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';
|
||||
});
|
||||
if (_locLat != null) _diaryPicker.setValue(_locLat, _locLon, _locName);
|
||||
}, 50);
|
||||
}
|
||||
|
||||
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);
|
||||
|
||||
document.getElementById('diary-form-delete')?.addEventListener('click', async () => {
|
||||
const ok = await UI.modal.confirm({
|
||||
|
|
|
|||
|
|
@ -640,17 +640,6 @@ 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);
|
||||
|
|
@ -823,9 +812,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);
|
||||
|
|
@ -833,16 +822,6 @@ 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
|
||||
|
|
@ -895,28 +874,6 @@ 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,7 +59,6 @@ window.Page_map = (() => {
|
|||
treffpunkt: [],
|
||||
community: [],
|
||||
zuechter: [],
|
||||
hotel: [],
|
||||
};
|
||||
|
||||
const VISIBLE_KEY = 'by_map_visible_v1';
|
||||
|
|
@ -131,10 +130,6 @@ window.Page_map = (() => {
|
|||
interactive: false,
|
||||
};
|
||||
|
||||
// Orts-Suche
|
||||
let _searchTimer = null;
|
||||
let _searchMarker = null;
|
||||
|
||||
let _overpassTimer = null;
|
||||
let _overpassActive = false;
|
||||
let _ringClosing = false;
|
||||
|
|
@ -215,50 +210,13 @@ window.Page_map = (() => {
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 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 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>
|
||||
</div>
|
||||
|
||||
<div class="map-statusbar" id="map-statusbar">
|
||||
|
|
@ -331,19 +289,7 @@ 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 {
|
||||
|
|
@ -351,54 +297,9 @@ window.Page_map = (() => {
|
|||
}
|
||||
});
|
||||
|
||||
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();
|
||||
});
|
||||
document.getElementById('map-pin-btn').addEventListener('click', _togglePlacementMode);
|
||||
document.getElementById('map-radar-btn')?.addEventListener('click', _toggleRadar);
|
||||
document.getElementById('map-temp-btn')?.addEventListener('click', _toggleTemp);
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------
|
||||
|
|
@ -1006,7 +907,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);
|
||||
|
|
@ -1018,14 +919,11 @@ window.Page_map = (() => {
|
|||
const pct = Math.round(20 + _done / _total * 80);
|
||||
const total = Object.values(_layers).flat().filter(m => !m._ownPlace).length;
|
||||
_setOsmStatus(pct < 100 ? `Scanne…` : `${total} Marker`, pct);
|
||||
return (_layers[layerKey] || []).filter(m => !m._ownPlace).length;
|
||||
return _layers[layerKey].filter(m => !m._ownPlace).length;
|
||||
}
|
||||
});
|
||||
try {
|
||||
await Promise.all(freshTasks);
|
||||
} finally {
|
||||
_overpassActive = false;
|
||||
}
|
||||
await Promise.all(freshTasks);
|
||||
_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);
|
||||
|
|
@ -1033,13 +931,10 @@ window.Page_map = (() => {
|
|||
_setOsmStatus('Layer deaktiviert — Liste antippen', 100);
|
||||
}
|
||||
|
||||
// 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) {
|
||||
// Wenn 0 OSM-Marker: Hintergrund-Fetch läuft noch — max 3× automatisch nachfragen
|
||||
if (totalLoaded === 0 && zoom >= 14 && _autoRetryCount < 3) {
|
||||
_autoRetryCount++;
|
||||
// 10s, 20s, 35s, 50s, 70s, 90s, 120s, 150s
|
||||
const delays = [10000, 20000, 35000, 50000, 70000, 90000, 120000, 150000];
|
||||
const delay = delays[_autoRetryCount - 1] || 120000;
|
||||
const delay = _autoRetryCount * 30000; // 30s, 60s, 90s
|
||||
_setOsmStatus(`Neue Umgebung – Daten werden geladen…`);
|
||||
setTimeout(() => { if (!_overpassActive) _scheduleOsmLoad(); }, delay);
|
||||
}
|
||||
|
|
@ -2049,92 +1944,6 @@ 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,10 +1698,6 @@ 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);
|
||||
|
|
@ -1709,14 +1705,6 @@ 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,6 +897,8 @@ 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">
|
||||
|
||||
|
|
@ -922,7 +924,48 @@ window.Page_walks = (() => {
|
|||
|
||||
<div class="form-group" id="wf-location-group">
|
||||
<label class="form-label">Treffpunkt</label>
|
||||
<div id="wf-location-picker"></div>
|
||||
|
||||
<!-- 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>
|
||||
|
||||
<div class="form-group">
|
||||
|
|
@ -953,16 +996,157 @@ window.Page_walks = (() => {
|
|||
|
||||
document.getElementById('wf-cancel')?.addEventListener('click', UI.modal.close);
|
||||
|
||||
// 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);
|
||||
// --- Mini-Karte ---
|
||||
let _miniMap = null, _miniMarker = null, _mapEditing = false;
|
||||
|
||||
const _mkIcon = () => L.divIcon({ html: _pinSvg, className: '', iconSize: [28, 36], iconAnchor: [14, 36] });
|
||||
|
||||
function _placeMarker(lat, lon) {
|
||||
if (_miniMarker) { _miniMarker.setLatLng([lat, lon]); return; }
|
||||
_miniMarker = L.marker([lat, lon], { draggable: true, icon: _mkIcon() }).addTo(_miniMap);
|
||||
_miniMarker.on('dragend', () => {
|
||||
const p = _miniMarker.getLatLng();
|
||||
_locLat = p.lat; _locLon = p.lng;
|
||||
document.getElementById('wf-lat').value = _locLat;
|
||||
document.getElementById('wf-lon').value = _locLon;
|
||||
document.getElementById('wf-location-btn-label').textContent = 'POI suchen';
|
||||
});
|
||||
}
|
||||
|
||||
function _setCoords(lat, lon) {
|
||||
_locLat = lat; _locLon = lon;
|
||||
document.getElementById('wf-lat').value = lat;
|
||||
document.getElementById('wf-lon').value = lon;
|
||||
}
|
||||
|
||||
function _setName(name) {
|
||||
_locName = name;
|
||||
document.getElementById('wf-location-label').textContent = name;
|
||||
document.getElementById('wf-location-chip-wrap').style.display = '';
|
||||
document.getElementById('wf-ort-name').value = name;
|
||||
document.getElementById('wf-location-suggestions').style.display = 'none';
|
||||
}
|
||||
|
||||
UI.loadLeaflet().then(() => {
|
||||
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,10 +453,6 @@ 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;
|
||||
},
|
||||
|
||||
|
|
@ -877,35 +873,12 @@ 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%);
|
||||
|
|
@ -1129,75 +1102,6 @@ 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=1155"></script>
|
||||
<script src="/js/landing-init.js?v=1141"></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,25 +149,6 @@
|
|||
}
|
||||
</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,10 +3,5 @@ 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 = '1155';
|
||||
const VER = '1141';
|
||||
const CACHE_VERSION = `by-v${VER}`;
|
||||
const CACHE_STATIC = `${CACHE_VERSION}-static`;
|
||||
const CACHE_TILES = 'ban-yaro-tiles-v1'; // bleibt über SW-Updates erhalten
|
||||
|
|
|
|||
Binary file not shown.
Binary file not shown.
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
Binary file not shown.
Binary file not shown.
Loading…
Add table
Add a link
Reference in a new issue