Compare commits
4 commits
2d907f6370
...
46caa05020
| Author | SHA1 | Date | |
|---|---|---|---|
| 46caa05020 | |||
| 4bc7454258 | |||
| 214543559c | |||
| 10e39ed135 |
38 changed files with 2313 additions and 431 deletions
1
.gitignore
vendored
1
.gitignore
vendored
|
|
@ -12,3 +12,4 @@ __pycache__/
|
||||||
/icons/
|
/icons/
|
||||||
.claude/worktrees/
|
.claude/worktrees/
|
||||||
Ban Yaro - Google Play package/
|
Ban Yaro - Google Play package/
|
||||||
|
/unsplash/
|
||||||
|
|
|
||||||
72
MARKETING.md
Normal file
72
MARKETING.md
Normal file
|
|
@ -0,0 +1,72 @@
|
||||||
|
# 🐾 Ban Yaro — Marketing-Cockpit
|
||||||
|
|
||||||
|
**Single Source of Truth fürs Marketing.** Vor jeder Aktion hier prüfen, danach updaten — so wird nichts doppelt gemacht, vergessen oder übersehen. Pflege: René + Claude.
|
||||||
|
|
||||||
|
_Stand: 2026-06-03_
|
||||||
|
|
||||||
|
> Diese Datei = Planung & Checkliste. Für **Live-Daten** (User-Meilenstein, Kanal-Tracking) lohnt zusätzlich ein Marketing-Tab im **Admin-Bereich** — siehe „Ausbau" unten.
|
||||||
|
|
||||||
|
## 📊 Kanal-Überblick
|
||||||
|
| Kanal / Bereich | Status | Nächster Schritt |
|
||||||
|
|---|---|---|
|
||||||
|
| Flyer Print | 🟢 1000 gedruckt (03.06.) | lokal verteilen |
|
||||||
|
| Flyer Digital | 💡 Idee | Doppelseiten-PDF + Empfehlungs-QR |
|
||||||
|
| Lokal (Ebersberg) | ⬜ offen | Tierärzte, Hundeschulen, Futterläden, Tierheim |
|
||||||
|
| Online-Communities | ⬜ offen | FB-Gruppen Landkreis EBE + nebenan.de |
|
||||||
|
| Empfehlung / Referral | 🟡 Infra da (`referral_code`) | Empfehlungs-QR + Tracking sichtbar machen |
|
||||||
|
| Influencer | 🟡 2 Runden (Mai), kaum Resonanz | Runde 3 erst ab ~50 aktiven Usern |
|
||||||
|
| Presse / Blogs | 🟡 1 Runde, kaum Resonanz | keine Massenwelle; Nische zuerst |
|
||||||
|
| Verzeichnisse / Listings | ⬜ offen | Product Hunt, PWA-Dirs, Google Business EBE |
|
||||||
|
| SEO / KI-Auffindbarkeit | 🟡 technisch optimiert | Backlinks (Blog-Testberichte) |
|
||||||
|
| Landing Page | 🟡 Redesign-Briefing da | 3 Einstiege, Outcomes statt Features |
|
||||||
|
| App Store (iOS) | 🟢 in Review (1.0 (3), 03.06.) | Freigabe abwarten |
|
||||||
|
| Play Store (Android) | 🔴 ON HOLD | 12 Closed-Tester / 14 Tage fehlen |
|
||||||
|
| Merch / NFC-Halsband | 💡 recherchiert | 20 Tags für Beta (~33 €) |
|
||||||
|
|
||||||
|
Legende: 🟢 läuft/erledigt · 🟡 angefangen · ⬜ offen · 💡 Idee · 🔴 blockiert
|
||||||
|
|
||||||
|
## ⏳ Gates / Trigger (nicht zu früh starten)
|
||||||
|
- **Influencer & Presse Runde 3** erst ab **~50 aktiven Usern** — vorher zu früh (Großredaktionen fragen zuerst nach Zahlen). → Bei jeder Session aktuelle User-Zahl checken.
|
||||||
|
- iOS-App ist nativ gebaut & in Review — **überholt** die alte „iOS erst ab 10k via Rork/PWABuilder"-Strategie.
|
||||||
|
|
||||||
|
## 📋 Backlog (konkret als Nächstes)
|
||||||
|
- [ ] **Flyer lokal verteilen (Ebersberg)** — Tierärzte (Wartezimmer), Hundeschulen/Welpengruppen, Futterläden, Hundesalons, Tierheim, Hundewiesen-Aushänge, hundefreundliche Cafés. Persönlich erklären; Aufhänger: Giftköder-Radar + „Daten in Deutschland". **Lokal bündeln, nicht streuen** (Community-Dichte für Gassi-Treffen/Giftköder).
|
||||||
|
- [ ] **Digitaler Doppelseiten-Flyer (PDF)** mit **Empfehlungs-QR** für Online-/Gruppen-Verteilung. Quelle: `promotion/flyer_a5_*.html`. _Offene Frage: generischer `?ref=empfehlung`-Link vs. pro-User `referral_code`._
|
||||||
|
- [ ] **Lokale FB-Gruppen + nebenan.de** — Flyer-Foto + Link posten.
|
||||||
|
- [ ] **Verzeichnisse** — Product Hunt, progressivewebappstore.com, pwafire.org/directory, Google Business (Ebersberg).
|
||||||
|
- [ ] **Landing-Page-Redesign** nach Briefing (3 Zielgruppen-Einstiege Hundebesitzer/Züchter/Welpenkäufer, Outcomes statt Features, Züchter-SaaS prominent, Datenschutz als Argument, Gründer-Story + Foto).
|
||||||
|
- [ ] **Messung einbauen** — „Wie hast du von uns gehört?" im Onboarding + QR-refs pro Kanal.
|
||||||
|
|
||||||
|
## ✅ Erledigt
|
||||||
|
- [x] 1000 Flyer A5 (zweiseitig) gedruckt — 03.06.2026
|
||||||
|
- [x] iOS-App nativ gebaut + eingereicht (1.0 (3), in Review) — Details im Repo `banyaro-ios`
|
||||||
|
- [x] Influencer-Outreach Runde 1 (5) + Runde 2 (13) — Mai 2026
|
||||||
|
- [x] SEO-Grundlagen (llms.txt, Landing About-Section)
|
||||||
|
|
||||||
|
## 📈 Messung — was bringt wirklich Nutzer?
|
||||||
|
- **Onboarding-Frage „Wie hast du von uns gehört?"** (1 Klick) = billigste & wichtigste Kontrolle. _(noch einzubauen)_
|
||||||
|
- **QR-refs pro Ort/Kanal** (z. B. `banyaro.app/?ref=tierarzt-grafing`) → ab nächster Flyer-Charge.
|
||||||
|
- **`referral_code`** (in DB, `routes/auth.py`) → Empfehlungen zählbar.
|
||||||
|
- Aktive User aktuell: _[aus Admin eintragen]_
|
||||||
|
|
||||||
|
## 🗂 Details je Kanal
|
||||||
|
|
||||||
|
### Influencer
|
||||||
|
2 Runden im Mai gesendet (`partner@banyaro.app`; DKIM/SPF/DMARC aktiv), **kaum Resonanz** — zu früh (wenige User), teils falsche Adressen (z. B. GEO → richtig `chefredaktion@geo.de`).
|
||||||
|
**Runde 3:** keine Massenwelle ohne PR-Agentur; **Hundeschulen/-trainer zuerst** (kleines Netzwerk, empfehlen aktiv Tools, Trainingsfeature ist stark), persönliche Mails, Aufhänger = neue Features + echte Nutzerzahlen.
|
||||||
|
→ **Wer schon kontaktiert wurde:** AI-Memory `project_influencer_outreach` (Runde 1: verpinscht, missyminzi, wanderlust_samoyed, viviundholly, doguniversity, dogstv; Runde 2: nami.and.tommy, brina.explores, heimatherzen, pfotentick, flummis_diary, verwolft, wildwildwilli, knutini_, ninja.vom.wolfstor, pupsonality, osman_theparson, babybearyuki, dogswiss). **Vor neuer Runde dort prüfen.**
|
||||||
|
|
||||||
|
### Play Store (Android TWA)
|
||||||
|
PWABuilder-Paket fertig (`Ban Yaro - Google Play package/`, Package `app.banyaro.twa`). **BLOCKER:** Google verlangt 12 Closed-Tester über 14 Tage — Tester fehlen (Engpass, nicht die Technik). assetlinks.json + Play-Console-Eintrag stehen bereit. Nicht priorisieren bis Tester da.
|
||||||
|
|
||||||
|
### Merch / NFC-Halsband
|
||||||
|
Tag recherchiert: **HID Laundry Tag 16 mm** (shopnfc, SKU RE-ICO2-16, ~1 €/Stk ab 500), für `banyaro.app/hund/{id}`. Beta: 20 Stk (~33 €) an erste Nutzer.
|
||||||
|
|
||||||
|
### Flyer
|
||||||
|
Print: A5 zweiseitig, Quelle `promotion/flyer_a5_allgemein.html` + `flyer_a5_rueckseite.html`, QR → banyaro.app. Vorderseite = alle Hundebesitzer, Rückseite stark Züchter-fokussiert.
|
||||||
|
|
||||||
|
## 🚀 Ausbau: Live-Tool im Admin-Bereich (optional)
|
||||||
|
Diese Datei deckt Planung/Checkliste ab (Claude pflegt sie). Der **Admin-Bereich** lohnt sich für die Teile mit echten Daten:
|
||||||
|
- **User-Meilenstein-Anzeige** (aktive User) → blendet automatisch den „Outreach Runde 3"-Hinweis ein, sobald ~50 erreicht.
|
||||||
|
- **Kanal-Tracking**: Auswertung „Wie gehört?" + QR-ref-Zähler + `referral_code`-Statistik.
|
||||||
|
- Optional: das Kanal-Board (Status/Backlog) als editierbare Admin-Seite.
|
||||||
2
VERSION
2
VERSION
|
|
@ -1 +1 @@
|
||||||
1141
|
1155
|
||||||
|
|
@ -356,6 +356,18 @@ def init_db():
|
||||||
);
|
);
|
||||||
CREATE INDEX IF NOT EXISTS idx_osm_pois_loc ON osm_pois(type, lat, lon);
|
CREATE INDEX IF NOT EXISTS idx_osm_pois_loc ON osm_pois(type, lat, lon);
|
||||||
|
|
||||||
|
-- OSM-Account-Verknüpfung (OAuth2) je Nutzer — Basis für OSM-Beiträge
|
||||||
|
-- ("Hund war willkommen" → dog=yes) + spätere Gamification/Pro-Freischaltung.
|
||||||
|
-- access_token verschlüsselt at rest (token_enc).
|
||||||
|
CREATE TABLE IF NOT EXISTS user_osm (
|
||||||
|
user_id INTEGER PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
osm_uid INTEGER NOT NULL,
|
||||||
|
osm_name TEXT NOT NULL,
|
||||||
|
token_enc TEXT NOT NULL,
|
||||||
|
scopes TEXT,
|
||||||
|
linked_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||||
|
);
|
||||||
|
|
||||||
-- VERLORENE HUNDE
|
-- VERLORENE HUNDE
|
||||||
CREATE TABLE IF NOT EXISTS lost_dogs (
|
CREATE TABLE IF NOT EXISTS lost_dogs (
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
|
|
||||||
199
backend/main.py
199
backend/main.py
|
|
@ -227,6 +227,7 @@ from routes.walks import router as walks_router
|
||||||
from routes.events import router as events_router
|
from routes.events import router as events_router
|
||||||
from routes.sitting import router as sitting_router
|
from routes.sitting import router as sitting_router
|
||||||
from routes.osm import router as osm_router
|
from routes.osm import router as osm_router
|
||||||
|
from routes.osm_auth import router as osm_auth_router
|
||||||
from routes.forum import router as forum_router
|
from routes.forum import router as forum_router
|
||||||
from routes.lost import router as lost_router
|
from routes.lost import router as lost_router
|
||||||
from routes.knigge import router as knigge_router
|
from routes.knigge import router as knigge_router
|
||||||
|
|
@ -292,6 +293,7 @@ app.include_router(walks_router, prefix="/api/walks", tags=["Gassi-Tre
|
||||||
app.include_router(events_router, prefix="/api/events", tags=["Events"])
|
app.include_router(events_router, prefix="/api/events", tags=["Events"])
|
||||||
app.include_router(sitting_router, prefix="/api/sitting", tags=["Sitting"])
|
app.include_router(sitting_router, prefix="/api/sitting", tags=["Sitting"])
|
||||||
app.include_router(osm_router, prefix="/api/osm", tags=["OSM"])
|
app.include_router(osm_router, prefix="/api/osm", tags=["OSM"])
|
||||||
|
app.include_router(osm_auth_router, prefix="/api/osm-auth", tags=["OSM-Auth"])
|
||||||
app.include_router(weather_router, prefix="/api/weather", tags=["Wetter"])
|
app.include_router(weather_router, prefix="/api/weather", tags=["Wetter"])
|
||||||
app.include_router(social_router, prefix="/api/social", tags=["Social"])
|
app.include_router(social_router, prefix="/api/social", tags=["Social"])
|
||||||
app.include_router(forum_router, prefix="/api/forum", tags=["Forum"])
|
app.include_router(forum_router, prefix="/api/forum", tags=["Forum"])
|
||||||
|
|
@ -511,11 +513,11 @@ async def sitemap():
|
||||||
urls = [
|
urls = [
|
||||||
("https://banyaro.app/", "weekly", "1.0"),
|
("https://banyaro.app/", "weekly", "1.0"),
|
||||||
("https://banyaro.app/zuechter", "weekly", "0.9"),
|
("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/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:
|
try:
|
||||||
|
|
@ -526,12 +528,6 @@ async def sitemap():
|
||||||
for r in rassen:
|
for r in rassen:
|
||||||
urls.append((f"https://banyaro.app/wiki/rasse/{r['slug']}", "monthly", "0.7"))
|
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
|
# Öffentliche Züchter-Profile
|
||||||
breeders = conn.execute(
|
breeders = conn.execute(
|
||||||
"SELECT bp.zwingername FROM breeder_profiles bp "
|
"SELECT bp.zwingername FROM breeder_profiles bp "
|
||||||
|
|
@ -1348,12 +1344,47 @@ async def public_dog_page(dog_id: int):
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
@app.get("/teilen/{token}")
|
@app.get("/teilen/{token}")
|
||||||
async def invite_page(token: str):
|
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}")
|
@app.get("/breeder/{zwingername}")
|
||||||
async def breeder_profile_page(zwingername: str):
|
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")
|
@app.get("/litters")
|
||||||
|
|
@ -1471,6 +1502,7 @@ async def ausweis_page(dog_id: int, request: Request):
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<meta name="robots" content="noindex">
|
||||||
<title>Heimtierausweis – {esc(dog["name"])}</title>
|
<title>Heimtierausweis – {esc(dog["name"])}</title>
|
||||||
<style>
|
<style>
|
||||||
* {{ box-sizing: border-box; margin: 0; padding: 0; }}
|
* {{ box-sizing: border-box; margin: 0; padding: 0; }}
|
||||||
|
|
@ -1657,6 +1689,14 @@ async def knigge_page():
|
||||||
footer{background:#1a1a1a;color:#aaa;text-align:center;padding:1.5rem;font-size:.82rem;margin-top:2rem}
|
footer{background:#1a1a1a;color:#aaa;text-align:center;padding:1.5rem;font-size:.82rem;margin-top:2rem}
|
||||||
footer a{color:#C4843A}
|
footer a{color:#C4843A}
|
||||||
</style>
|
</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>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<header>
|
<header>
|
||||||
|
|
@ -1727,7 +1767,9 @@ async def help_page():
|
||||||
k for k in by_kat.keys() if k not in KAT_LABEL
|
k for k in by_kat.keys() if k not in KAT_LABEL
|
||||||
]
|
]
|
||||||
|
|
||||||
|
import json as _json
|
||||||
sections_html = ""
|
sections_html = ""
|
||||||
|
faq_items = []
|
||||||
for kat in kat_order:
|
for kat in kat_order:
|
||||||
label = KAT_LABEL.get(kat, kat.replace("_", " ").title())
|
label = KAT_LABEL.get(kat, kat.replace("_", " ").title())
|
||||||
items = "".join(
|
items = "".join(
|
||||||
|
|
@ -1736,6 +1778,13 @@ async def help_page():
|
||||||
for a in by_kat[kat]
|
for a in by_kat[kat]
|
||||||
)
|
)
|
||||||
sections_html += f'<section><h2>{_html.escape(label)}</h2>{items}</section>'
|
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 = f"""<!DOCTYPE html>
|
||||||
<html lang="de">
|
<html lang="de">
|
||||||
|
|
@ -1744,6 +1793,8 @@ async def help_page():
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<title>Hilfe & FAQ — Ban Yaro</title>
|
<title>Hilfe & FAQ — Ban Yaro</title>
|
||||||
<meta name="description" content="Antworten zu Ban Yaro und Ban Yaro Go: Installation, Standort, Account, Features.">
|
<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">
|
<link rel="icon" href="/icons/icon-180.png">
|
||||||
<style>
|
<style>
|
||||||
:root {{
|
:root {{
|
||||||
|
|
@ -1814,6 +1865,11 @@ async def help_page():
|
||||||
.contact p {{ margin: .25rem 0; color: var(--c-text-sec); }}
|
.contact p {{ margin: .25rem 0; color: var(--c-text-sec); }}
|
||||||
nav.top {{ margin-bottom: 1.5rem; }}
|
nav.top {{ margin-bottom: 1.5rem; }}
|
||||||
</style>
|
</style>
|
||||||
|
<script type="application/ld+json">{{
|
||||||
|
"@context": "https://schema.org",
|
||||||
|
"@type": "FAQPage",
|
||||||
|
"mainEntity": {faq_json_ld}
|
||||||
|
}}</script>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<nav class="top"><a href="/">← banyaro.app</a></nav>
|
<nav class="top"><a href="/">← banyaro.app</a></nav>
|
||||||
|
|
@ -1851,6 +1907,8 @@ async def konto_loeschen():
|
||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<title>Konto löschen — Ban Yaro</title>
|
<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">
|
<link rel="stylesheet" href="/css/design-system.css">
|
||||||
<style>
|
<style>
|
||||||
body { font-family: var(--font-sans); background: var(--c-bg); color: var(--c-text);
|
body { font-family: var(--font-sans); background: var(--c-bg); color: var(--c-text);
|
||||||
|
|
@ -1900,6 +1958,7 @@ async def force_update():
|
||||||
from fastapi.responses import HTMLResponse
|
from fastapi.responses import HTMLResponse
|
||||||
html = """<!DOCTYPE html><html><head><meta charset="UTF-8">
|
html = """<!DOCTYPE html><html><head><meta charset="UTF-8">
|
||||||
<meta name="viewport" content="width=device-width,initial-scale=1">
|
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||||||
|
<meta name="robots" content="noindex, nofollow">
|
||||||
<title>Ban Yaro — Update</title>
|
<title>Ban Yaro — Update</title>
|
||||||
<style>body{font-family:sans-serif;display:flex;align-items:center;justify-content:center;
|
<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}
|
height:100vh;margin:0;background:#0f1623;color:#fff;flex-direction:column;gap:16px}
|
||||||
|
|
@ -1971,6 +2030,8 @@ async def partner_landing():
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<title>Ban Yaro Partner — Werde Teil der ersten 100</title>
|
<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.">
|
<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: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: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">
|
<meta property="og:image" content="https://banyaro.app/icons/icon-512.png">
|
||||||
|
|
@ -2256,6 +2317,7 @@ async def passport_share_page(token: str):
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<meta name="robots" content="noindex">
|
||||||
<title>Hundepass — {dog['name']}</title>
|
<title>Hundepass — {dog['name']}</title>
|
||||||
<style>
|
<style>
|
||||||
* {{ box-sizing: border-box; margin: 0; padding: 0; }}
|
* {{ box-sizing: border-box; margin: 0; padding: 0; }}
|
||||||
|
|
@ -2329,6 +2391,119 @@ async def passport_share_page(token: str):
|
||||||
return HTMLResponse(html)
|
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
|
# SPA Fallback — ALLE nicht-API-Routen gehen zur index.html
|
||||||
@app.get("/{full_path:path}")
|
@app.get("/{full_path:path}")
|
||||||
async def spa_fallback(full_path: str):
|
async def spa_fallback(full_path: str):
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,7 @@ pydantic[email]==2.10.6
|
||||||
bcrypt==4.3.0
|
bcrypt==4.3.0
|
||||||
PyJWT==2.10.1
|
PyJWT==2.10.1
|
||||||
httpx==0.28.1
|
httpx==0.28.1
|
||||||
|
cryptography==44.0.0
|
||||||
openai==1.59.2
|
openai==1.59.2
|
||||||
anthropic==0.49.0
|
anthropic==0.49.0
|
||||||
pywebpush==2.0.0
|
pywebpush==2.0.0
|
||||||
|
|
|
||||||
|
|
@ -586,6 +586,25 @@ async def toggle_like(data: LikeBody, user=Depends(get_current_user)):
|
||||||
return {"liked": liked, "count": count}
|
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
|
# POST /api/forum/report
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,11 @@
|
||||||
"""
|
"""
|
||||||
BAN YARO — OSM/Overpass POI-Cache + Community-Pins
|
BAN YARO — OSM POI-Daten + Community-Pins
|
||||||
Cacht OSM-Daten lokal, erlaubt Nutzern eigene Marker und Meldungen.
|
Liest OSM-POIs aus der lokalen Tabelle osm_pois (monatlicher Offline-Import,
|
||||||
|
tools/osm-extract/), erlaubt Nutzern eigene Marker und Meldungen.
|
||||||
|
|
||||||
|
Build 4: Live-Scannen gegen overpass-api.de ist DEAKTIVIERT (war Bann-Quelle).
|
||||||
|
Die Overpass-Hilfsfunktionen unten sind ungenutzt und können später entfernt
|
||||||
|
werden. /geocode nutzt weiterhin Nominatim für die Adresssuche (geringe Last).
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import math
|
import math
|
||||||
|
|
@ -191,17 +196,9 @@ async def get_pois(
|
||||||
fetched_fresh = False
|
fetched_fresh = False
|
||||||
|
|
||||||
if type in OSM_QUERIES:
|
if type in OSM_QUERIES:
|
||||||
tiles = _covering_tiles(south, west, north, east, CACHE_ZOOM)
|
# Scanner deaktiviert (Build 4): keine Live-Overpass-Abfragen mehr.
|
||||||
stale = _stale_tiles(type, tiles)
|
# POIs stammen aus dem monatlichen Offline-Import in die Tabelle
|
||||||
|
# osm_pois (tools/osm-extract/). Hier wird nur noch daraus gelesen.
|
||||||
if stale and not fast:
|
|
||||||
async def _bg_fetch(poi_type, stale_tiles):
|
|
||||||
for (x, y) in stale_tiles:
|
|
||||||
await _fetch_and_store_tile(poi_type, x, y)
|
|
||||||
task = asyncio.create_task(_bg_fetch(type, stale))
|
|
||||||
_bg_tasks.add(task)
|
|
||||||
task.add_done_callback(_bg_tasks.discard)
|
|
||||||
|
|
||||||
with db() as conn:
|
with db() as conn:
|
||||||
reported = {
|
reported = {
|
||||||
row[0] for row in conn.execute(
|
row[0] for row in conn.execute(
|
||||||
|
|
@ -364,24 +361,17 @@ async def report_poi(body: ReportIn, user = Depends(get_current_user)):
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
@router.post('/analyze')
|
@router.post('/analyze')
|
||||||
async def analyze_region(
|
async def analyze_region(
|
||||||
background_tasks: BackgroundTasks,
|
|
||||||
south: float = Query(...),
|
south: float = Query(...),
|
||||||
west: float = Query(...),
|
west: float = Query(...),
|
||||||
north: float = Query(...),
|
north: float = Query(...),
|
||||||
east: float = Query(...),
|
east: float = Query(...),
|
||||||
):
|
):
|
||||||
tiles = _covering_tiles(south, west, north, east, CACHE_ZOOM)
|
# Scanner deaktiviert (Build 4): kein Live-Overpass-Warmup mehr. POIs
|
||||||
|
# kommen aus dem monatlichen Offline-Import (tools/osm-extract/). Endpoint
|
||||||
async def _warmup():
|
# bleibt als No-Op erhalten, damit bestehende Frontends nicht 404 laufen.
|
||||||
tasks = [
|
return {'status': 'offline-import',
|
||||||
_fetch_and_store_tile(pt, x, y)
|
'message': 'POIs werden monatlich offline importiert — kein Live-Scan nötig.',
|
||||||
for pt in OSM_QUERIES
|
'types': list(OSM_QUERIES.keys())}
|
||||||
for (x, y) in _stale_tiles(pt, tiles)
|
|
||||||
]
|
|
||||||
await asyncio.gather(*tasks)
|
|
||||||
|
|
||||||
background_tasks.add_task(_warmup)
|
|
||||||
return {'status': 'gestartet', 'tiles': len(tiles), 'types': list(OSM_QUERIES.keys())}
|
|
||||||
|
|
||||||
|
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
|
|
@ -423,3 +413,65 @@ async def submit_poi_edit(osm_id: str, data: PoiEditCreate,
|
||||||
poi[data.field], data.new_value.strip(), user["id"])
|
poi[data.field], data.new_value.strip(), user["id"])
|
||||||
)
|
)
|
||||||
return {"status": "pending", "message": "Korrektur wurde zur Prüfung eingereicht."}
|
return {"status": "pending", "message": "Korrektur wurde zur Prüfung eingereicht."}
|
||||||
|
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# Geocoding-Proxy GET /api/osm/geocode?q=…
|
||||||
|
# Nominatim-Rate-Limit: 1 req/s — serverseitig throttled
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
_nominatim_sem = asyncio.Semaphore(1)
|
||||||
|
_nominatim_last = 0.0
|
||||||
|
|
||||||
|
@router.get('/geocode')
|
||||||
|
async def geocode_search(q: str = Query(..., min_length=2, max_length=200)):
|
||||||
|
import time
|
||||||
|
global _nominatim_last
|
||||||
|
async with _nominatim_sem:
|
||||||
|
wait = 1.1 - (time.monotonic() - _nominatim_last)
|
||||||
|
if wait > 0:
|
||||||
|
await asyncio.sleep(wait)
|
||||||
|
_nominatim_last = time.monotonic()
|
||||||
|
try:
|
||||||
|
async with httpx.AsyncClient(timeout=6.0) as client:
|
||||||
|
resp = await client.get(
|
||||||
|
'https://nominatim.openstreetmap.org/search',
|
||||||
|
params={
|
||||||
|
'q': q,
|
||||||
|
'format': 'jsonv2',
|
||||||
|
'limit': 6,
|
||||||
|
'countrycodes': 'de,at,ch',
|
||||||
|
'addressdetails': 1,
|
||||||
|
'accept-language': 'de',
|
||||||
|
},
|
||||||
|
headers={
|
||||||
|
'User-Agent': _OVERPASS_UA,
|
||||||
|
'Referer': 'https://banyaro.app/',
|
||||||
|
}
|
||||||
|
)
|
||||||
|
resp.raise_for_status()
|
||||||
|
data = resp.json()
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning("Nominatim-Fehler: %s", e)
|
||||||
|
raise HTTPException(502, "Geocoding nicht verfügbar")
|
||||||
|
|
||||||
|
out = []
|
||||||
|
for r in data[:6]:
|
||||||
|
addr = r.get('address', {})
|
||||||
|
short = (
|
||||||
|
addr.get('amenity') or addr.get('shop') or addr.get('leisure') or
|
||||||
|
addr.get('road') or addr.get('village') or addr.get('town') or
|
||||||
|
addr.get('city') or r.get('name') or
|
||||||
|
r.get('display_name', '').split(',')[0]
|
||||||
|
)
|
||||||
|
city = addr.get('city') or addr.get('town') or addr.get('village') or addr.get('municipality') or ''
|
||||||
|
state = addr.get('state', '')
|
||||||
|
subtitle = ', '.join(filter(None, [city, state]))
|
||||||
|
out.append({
|
||||||
|
'lat': float(r['lat']),
|
||||||
|
'lon': float(r['lon']),
|
||||||
|
'name': short,
|
||||||
|
'subtitle': subtitle,
|
||||||
|
'full': r.get('display_name', ''),
|
||||||
|
'type': r.get('type', ''),
|
||||||
|
})
|
||||||
|
return out
|
||||||
|
|
|
||||||
167
backend/routes/osm_auth.py
Normal file
167
backend/routes/osm_auth.py
Normal file
|
|
@ -0,0 +1,167 @@
|
||||||
|
"""
|
||||||
|
OSM-Account-Verknüpfung via OAuth2 (Modell A: Beiträge laufen unter dem
|
||||||
|
eigenen OSM-Account des Nutzers). Basis fürs spätere "Hund war willkommen"
|
||||||
|
(dog=yes) + Gamification/Pro-Freischaltung.
|
||||||
|
|
||||||
|
Flow:
|
||||||
|
1. Frontend ruft (eingeloggt) GET /api/osm-auth/authorize → bekommt die
|
||||||
|
OSM-Authorize-URL inkl. signiertem `state` (trägt die banyaro-user_id +
|
||||||
|
CSRF-Nonce, 10 Min gültig) und leitet den Browser dorthin.
|
||||||
|
2. OSM leitet zurück auf GET /api/osm-auth/callback?code=&state= (ohne JWT —
|
||||||
|
daher die user_id aus `state`). Token-Tausch, OSM-Name holen, Token
|
||||||
|
verschlüsselt in user_osm speichern, zurück in die App leiten.
|
||||||
|
3. GET /status zeigt Verknüpfungsstatus, POST /unlink trennt.
|
||||||
|
|
||||||
|
ENV: OSM_CLIENT_ID, OSM_CLIENT_SECRET, OSM_REDIRECT_URI, OSM_POST_LINK_REDIRECT.
|
||||||
|
Token-Schlüssel wird aus JWT_SECRET abgeleitet (oder OSM_TOKEN_KEY überschreibt).
|
||||||
|
"""
|
||||||
|
import os
|
||||||
|
import base64
|
||||||
|
import hashlib
|
||||||
|
import logging
|
||||||
|
from urllib.parse import urlencode
|
||||||
|
from datetime import datetime, timezone, timedelta
|
||||||
|
|
||||||
|
import jwt
|
||||||
|
import httpx
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||||
|
from fastapi.responses import RedirectResponse
|
||||||
|
from cryptography.fernet import Fernet, InvalidToken
|
||||||
|
|
||||||
|
from database import db
|
||||||
|
from auth import get_current_user, JWT_SECRET, JWT_ALGO
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
# --- OSM-OAuth2-Endpunkte ---
|
||||||
|
OSM_AUTHORIZE = "https://www.openstreetmap.org/oauth2/authorize"
|
||||||
|
OSM_TOKEN = "https://www.openstreetmap.org/oauth2/token"
|
||||||
|
OSM_USER_API = "https://api.openstreetmap.org/api/0.6/user/details.json"
|
||||||
|
OSM_SCOPES = "read_prefs write_api"
|
||||||
|
|
||||||
|
CLIENT_ID = os.getenv("OSM_CLIENT_ID", "")
|
||||||
|
CLIENT_SECRET = os.getenv("OSM_CLIENT_SECRET", "")
|
||||||
|
REDIRECT_URI = os.getenv("OSM_REDIRECT_URI", "https://staging.banyaro.app/api/osm-auth/callback")
|
||||||
|
POST_LINK_REDIRECT = os.getenv("OSM_POST_LINK_REDIRECT", "/?osm=verknuepft")
|
||||||
|
|
||||||
|
_STATE_TTL_MIN = 10
|
||||||
|
|
||||||
|
# Fernet-Schlüssel zur Token-Verschlüsselung: dediziertes OSM_TOKEN_KEY oder
|
||||||
|
# deterministisch aus JWT_SECRET abgeleitet (kein zusätzliches Secret nötig).
|
||||||
|
def _fernet() -> Fernet:
|
||||||
|
raw = os.getenv("OSM_TOKEN_KEY")
|
||||||
|
if raw:
|
||||||
|
return Fernet(raw.encode() if isinstance(raw, str) else raw)
|
||||||
|
key = base64.urlsafe_b64encode(hashlib.sha256(JWT_SECRET.encode()).digest())
|
||||||
|
return Fernet(key)
|
||||||
|
|
||||||
|
def _encrypt(token: str) -> str:
|
||||||
|
return _fernet().encrypt(token.encode()).decode()
|
||||||
|
|
||||||
|
def _decrypt(token_enc: str) -> str:
|
||||||
|
return _fernet().decrypt(token_enc.encode()).decode()
|
||||||
|
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# GET /authorize — liefert die OSM-Authorize-URL (Frontend redirectet dorthin)
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
@router.get("/authorize")
|
||||||
|
async def authorize(user=Depends(get_current_user)):
|
||||||
|
if not CLIENT_ID:
|
||||||
|
raise HTTPException(503, "OSM-Anbindung nicht konfiguriert (OSM_CLIENT_ID fehlt).")
|
||||||
|
state = jwt.encode(
|
||||||
|
{"uid": user["id"],
|
||||||
|
"exp": datetime.now(timezone.utc) + timedelta(minutes=_STATE_TTL_MIN),
|
||||||
|
"purpose": "osm-link"},
|
||||||
|
JWT_SECRET, algorithm=JWT_ALGO,
|
||||||
|
)
|
||||||
|
params = {
|
||||||
|
"response_type": "code",
|
||||||
|
"client_id": CLIENT_ID,
|
||||||
|
"redirect_uri": REDIRECT_URI,
|
||||||
|
"scope": OSM_SCOPES,
|
||||||
|
"state": state,
|
||||||
|
}
|
||||||
|
url = OSM_AUTHORIZE + "?" + urlencode(params)
|
||||||
|
return {"authorize_url": url}
|
||||||
|
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# GET /callback — OSM leitet hierher zurück (Browser-Redirect, kein JWT)
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
@router.get("/callback")
|
||||||
|
async def callback(code: str = Query(...), state: str = Query(...)):
|
||||||
|
# 1) state verifizieren → banyaro-user_id (CSRF + Zuordnung)
|
||||||
|
try:
|
||||||
|
payload = jwt.decode(state, JWT_SECRET, algorithms=[JWT_ALGO])
|
||||||
|
if payload.get("purpose") != "osm-link":
|
||||||
|
raise ValueError("falscher state-Zweck")
|
||||||
|
uid = int(payload["uid"])
|
||||||
|
except Exception:
|
||||||
|
raise HTTPException(400, "Ungültiger oder abgelaufener Verknüpfungs-Link.")
|
||||||
|
|
||||||
|
# 2) code → access_token tauschen
|
||||||
|
async with httpx.AsyncClient(timeout=15) as client:
|
||||||
|
tok = await client.post(OSM_TOKEN, data={
|
||||||
|
"grant_type": "authorization_code",
|
||||||
|
"code": code,
|
||||||
|
"redirect_uri": REDIRECT_URI,
|
||||||
|
"client_id": CLIENT_ID,
|
||||||
|
"client_secret": CLIENT_SECRET,
|
||||||
|
})
|
||||||
|
if tok.status_code != 200:
|
||||||
|
logger.warning("OSM-Token-Tausch fehlgeschlagen: %s %s", tok.status_code, tok.text[:200])
|
||||||
|
raise HTTPException(502, "OSM-Token-Tausch fehlgeschlagen.")
|
||||||
|
access_token = tok.json().get("access_token")
|
||||||
|
if not access_token:
|
||||||
|
raise HTTPException(502, "OSM lieferte kein access_token.")
|
||||||
|
|
||||||
|
# 3) OSM-Identität holen (uid + Anzeigename)
|
||||||
|
me = await client.get(OSM_USER_API, headers={"Authorization": f"Bearer {access_token}"})
|
||||||
|
if me.status_code != 200:
|
||||||
|
raise HTTPException(502, "OSM-Nutzerdaten konnten nicht geladen werden.")
|
||||||
|
u = me.json().get("user", {})
|
||||||
|
osm_uid, osm_name = u.get("id"), u.get("display_name")
|
||||||
|
if not (osm_uid and osm_name):
|
||||||
|
raise HTTPException(502, "OSM-Nutzerdaten unvollständig.")
|
||||||
|
|
||||||
|
# 4) verschlüsselt speichern (eine Verknüpfung pro banyaro-User)
|
||||||
|
with db() as conn:
|
||||||
|
conn.execute(
|
||||||
|
"""INSERT INTO user_osm (user_id, osm_uid, osm_name, token_enc, scopes, linked_at)
|
||||||
|
VALUES (?, ?, ?, ?, ?, datetime('now'))
|
||||||
|
ON CONFLICT(user_id) DO UPDATE SET
|
||||||
|
osm_uid=excluded.osm_uid, osm_name=excluded.osm_name,
|
||||||
|
token_enc=excluded.token_enc, scopes=excluded.scopes,
|
||||||
|
linked_at=excluded.linked_at""",
|
||||||
|
(uid, osm_uid, osm_name, _encrypt(access_token), OSM_SCOPES),
|
||||||
|
)
|
||||||
|
logger.info("OSM verknüpft: banyaro-user %s ↔ OSM '%s' (%s)", uid, osm_name, osm_uid)
|
||||||
|
return RedirectResponse(POST_LINK_REDIRECT, status_code=302)
|
||||||
|
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# GET /status — Verknüpfungsstatus des eingeloggten Nutzers
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
@router.get("/status")
|
||||||
|
async def status(user=Depends(get_current_user)):
|
||||||
|
with db() as conn:
|
||||||
|
row = conn.execute(
|
||||||
|
"SELECT osm_name, osm_uid, linked_at FROM user_osm WHERE user_id=?",
|
||||||
|
(user["id"],)
|
||||||
|
).fetchone()
|
||||||
|
if not row:
|
||||||
|
return {"linked": False}
|
||||||
|
return {"linked": True, "osm_name": row["osm_name"],
|
||||||
|
"osm_uid": row["osm_uid"], "linked_at": row["linked_at"]}
|
||||||
|
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# POST /unlink — Verknüpfung trennen (Token lokal löschen)
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
@router.post("/unlink")
|
||||||
|
async def unlink(user=Depends(get_current_user)):
|
||||||
|
with db() as conn:
|
||||||
|
conn.execute("DELETE FROM user_osm WHERE user_id=?", (user["id"],))
|
||||||
|
return {"status": "ok"}
|
||||||
|
|
@ -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 */
|
/* FAB-Gruppe rechts unten — direkt über dem Zurück-Button */
|
||||||
.map-fabs {
|
.map-fabs {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
|
|
|
||||||
|
|
@ -86,14 +86,14 @@
|
||||||
<title>Ban Yaro</title>
|
<title>Ban Yaro</title>
|
||||||
|
|
||||||
<!-- Theme + theme-color Statusleiste vor CSS setzen -->
|
<!-- 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 -->
|
<!-- CSS: Reihenfolge ist wichtig — ?v= zwingt Browser zur Neuladung -->
|
||||||
<link rel="stylesheet" href="/css/design-system.css?v=1141">
|
<link rel="stylesheet" href="/css/design-system.css?v=1155">
|
||||||
<link rel="stylesheet" href="/css/layout.css?v=1141">
|
<link rel="stylesheet" href="/css/layout.css?v=1155">
|
||||||
<link rel="stylesheet" href="/css/components.css?v=1141">
|
<link rel="stylesheet" href="/css/components.css?v=1155">
|
||||||
<link rel="stylesheet" href="/css/utilities.css?v=1141">
|
<link rel="stylesheet" href="/css/utilities.css?v=1155">
|
||||||
<link rel="stylesheet" href="/css/lists.css?v=1141">
|
<link rel="stylesheet" href="/css/lists.css?v=1155">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
|
||||||
|
|
@ -617,11 +617,11 @@
|
||||||
<div id="modal-container"></div>
|
<div id="modal-container"></div>
|
||||||
|
|
||||||
<!-- JS: Reihenfolge ist wichtig — erst Basis, dann Features -->
|
<!-- JS: Reihenfolge ist wichtig — erst Basis, dann Features -->
|
||||||
<script src="/js/api.js?v=1141"></script>
|
<script src="/js/api.js?v=1155"></script>
|
||||||
<script src="/js/ui.js?v=1141"></script>
|
<script src="/js/ui.js?v=1155"></script>
|
||||||
<script src="/js/app.js?v=1141"></script>
|
<script src="/js/app.js?v=1155"></script>
|
||||||
<script src="/js/worlds.js?v=1141"></script>
|
<script src="/js/worlds.js?v=1155"></script>
|
||||||
<script src="/js/offline-indicator.js?v=1141"></script>
|
<script src="/js/offline-indicator.js?v=1155"></script>
|
||||||
|
|
||||||
<!-- Feature-Seiten werden lazy geladen -->
|
<!-- Feature-Seiten werden lazy geladen -->
|
||||||
|
|
||||||
|
|
@ -631,7 +631,7 @@
|
||||||
|
|
||||||
|
|
||||||
<!-- Boot: Offline-Banner + SW-Registration (extrahiert für CSP) -->
|
<!-- 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>
|
</body>
|
||||||
|
|
|
||||||
|
|
@ -45,9 +45,12 @@ const API = (() => {
|
||||||
throw new APIError(msg, 0, 'network');
|
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');
|
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._byUpdatePending = true;
|
||||||
window._byNewVersion = serverVer;
|
window._byNewVersion = serverVer;
|
||||||
}
|
}
|
||||||
|
|
@ -439,6 +442,9 @@ const API = (() => {
|
||||||
like(targetType, targetId) {
|
like(targetType, targetId) {
|
||||||
return post('/forum/like', { target_type: targetType, target_id: targetId });
|
return post('/forum/like', { target_type: targetType, target_id: targetId });
|
||||||
},
|
},
|
||||||
|
likers(targetType, targetId) {
|
||||||
|
return get(`/forum/likes/${targetType}/${targetId}`);
|
||||||
|
},
|
||||||
report(targetType, targetId, grund) {
|
report(targetType, targetId, grund) {
|
||||||
return post('/forum/report', { target_type: targetType, target_id: targetId, grund });
|
return post('/forum/report', { target_type: targetType, target_id: targetId, grund });
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,7 @@
|
||||||
Router, State-Management, Navigation, Initialisierung.
|
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
|
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_VER = APP_VER; // global verfügbar für andere Module (z.B. offline-indicator)
|
||||||
window.APP_VERSION = APP_VERSION;
|
window.APP_VERSION = APP_VERSION;
|
||||||
|
|
|
||||||
|
|
@ -57,7 +57,10 @@ if ('serviceWorker' in navigator) {
|
||||||
if (!sw) return;
|
if (!sw) return;
|
||||||
sw.addEventListener('statechange', function() {
|
sw.addEventListener('statechange', function() {
|
||||||
if (sw.state === 'activated') {
|
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());
|
window.location.replace('/?_t=' + Date.now());
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -1306,37 +1306,7 @@ window.Page_diary = (() => {
|
||||||
</div>
|
</div>
|
||||||
<div class="form-group" id="diary-location-group">
|
<div class="form-group" id="diary-location-group">
|
||||||
<label class="form-label">Ort <span class="text-secondary">(optional)</span></label>
|
<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>
|
</div>
|
||||||
${dogPickerHtml}
|
${dogPickerHtml}
|
||||||
<div class="form-group" style="margin-top:var(--space-5)">
|
<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 _locLat = (entry?.gps_lat != null) ? entry.gps_lat : null;
|
||||||
let _locLon = (entry?.gps_lon != null) ? entry.gps_lon : null;
|
let _locLon = (entry?.gps_lon != null) ? entry.gps_lon : null;
|
||||||
let _locName = entry?.location_name || 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>';
|
// Location Picker (gemeinsame UI-Komponente)
|
||||||
const _mkIcon = () => L.divIcon({ html: _pinSvg, className: '', iconSize: [32,40], iconAnchor: [16,40] });
|
setTimeout(() => {
|
||||||
|
const _diaryPicker = UI.locationPicker({
|
||||||
function _setName(name) {
|
containerId: 'diary-location-picker',
|
||||||
_locName = name;
|
onSelect: (lat, lon, name) => { _locLat = lat; _locLon = lon; _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 () => {
|
document.getElementById('diary-form-delete')?.addEventListener('click', async () => {
|
||||||
const ok = await UI.modal.confirm({
|
const ok = await UI.modal.confirm({
|
||||||
|
|
|
||||||
|
|
@ -640,6 +640,17 @@ function _fmtDate(iso) {
|
||||||
} catch (err) { UI.toast.error(err.message); }
|
} 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
|
// Report thread
|
||||||
document.getElementById('thread-report-btn')?.addEventListener('click', () => {
|
document.getElementById('thread-report-btn')?.addEventListener('click', () => {
|
||||||
_showReportForm('thread', thread.id);
|
_showReportForm('thread', thread.id);
|
||||||
|
|
@ -812,9 +823,9 @@ function _fmtDate(iso) {
|
||||||
// Like
|
// Like
|
||||||
container.querySelectorAll('.forum-post-like:not([data-bound])').forEach(btn => {
|
container.querySelectorAll('.forum-post-like:not([data-bound])').forEach(btn => {
|
||||||
btn.dataset.bound = '1';
|
btn.dataset.bound = '1';
|
||||||
|
const postId = parseInt(btn.dataset.postId);
|
||||||
btn.addEventListener('click', async () => {
|
btn.addEventListener('click', async () => {
|
||||||
if (!uid) { UI.toast.info('Bitte erst anmelden.'); return; }
|
if (!uid) { UI.toast.info('Bitte erst anmelden.'); return; }
|
||||||
const postId = parseInt(btn.dataset.postId);
|
|
||||||
try {
|
try {
|
||||||
const res = await API.forum.like('post', postId);
|
const res = await API.forum.like('post', postId);
|
||||||
btn.classList.toggle('active', res.liked);
|
btn.classList.toggle('active', res.liked);
|
||||||
|
|
@ -822,6 +833,16 @@ function _fmtDate(iso) {
|
||||||
if (countEl) countEl.textContent = res.count;
|
if (countEl) countEl.textContent = res.count;
|
||||||
} catch (err) { UI.toast.error(err.message); }
|
} 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
|
// 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
|
// Report-Formular
|
||||||
// ----------------------------------------------------------
|
// ----------------------------------------------------------
|
||||||
|
|
|
||||||
|
|
@ -59,6 +59,7 @@ window.Page_map = (() => {
|
||||||
treffpunkt: [],
|
treffpunkt: [],
|
||||||
community: [],
|
community: [],
|
||||||
zuechter: [],
|
zuechter: [],
|
||||||
|
hotel: [],
|
||||||
};
|
};
|
||||||
|
|
||||||
const VISIBLE_KEY = 'by_map_visible_v1';
|
const VISIBLE_KEY = 'by_map_visible_v1';
|
||||||
|
|
@ -130,6 +131,10 @@ window.Page_map = (() => {
|
||||||
interactive: false,
|
interactive: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Orts-Suche
|
||||||
|
let _searchTimer = null;
|
||||||
|
let _searchMarker = null;
|
||||||
|
|
||||||
let _overpassTimer = null;
|
let _overpassTimer = null;
|
||||||
let _overpassActive = false;
|
let _overpassActive = false;
|
||||||
let _ringClosing = false;
|
let _ringClosing = false;
|
||||||
|
|
@ -210,13 +215,50 @@ window.Page_map = (() => {
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="map-fabs">
|
<!-- Orts-Suche Panel (von oben einschiebend, geschlossen per default) -->
|
||||||
<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>
|
<div class="map-search-wrap" id="map-search-wrap">
|
||||||
${App.hasPro(_appState?.user) ? `
|
<div class="map-search-row">
|
||||||
<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>
|
<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>
|
||||||
<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>
|
<input type="search" id="map-search-input" class="map-search-input"
|
||||||
` : ''}
|
placeholder="Ort oder Adresse…" autocomplete="off" autocorrect="off" spellcheck="false">
|
||||||
<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>
|
<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>
|
||||||
|
|
||||||
<div class="map-statusbar" id="map-statusbar">
|
<div class="map-statusbar" id="map-statusbar">
|
||||||
|
|
@ -289,7 +331,19 @@ window.Page_map = (() => {
|
||||||
_saveVisible();
|
_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', () => {
|
document.getElementById('map-locate-btn').addEventListener('click', () => {
|
||||||
|
_sdEl?.classList.remove('open');
|
||||||
if (_userPos) {
|
if (_userPos) {
|
||||||
_map?.setView([_userPos.lat, _userPos.lon], 16);
|
_map?.setView([_userPos.lat, _userPos.lon], 16);
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -297,9 +351,54 @@ window.Page_map = (() => {
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
document.getElementById('map-pin-btn').addEventListener('click', _togglePlacementMode);
|
document.getElementById('map-pin-btn').addEventListener('click', () => {
|
||||||
document.getElementById('map-radar-btn')?.addEventListener('click', _toggleRadar);
|
_sdEl?.classList.remove('open');
|
||||||
document.getElementById('map-temp-btn')?.addEventListener('click', _toggleTemp);
|
_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 });
|
const params = new URLSearchParams({ type: osmType, ...bbox });
|
||||||
try {
|
try {
|
||||||
const pois = await fetch(`/api/osm/pois?${params}`).then(r => r.json());
|
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);
|
if (pois.length !== osmCount) _replaceOsmMarkers(layerKey, pois);
|
||||||
_done++;
|
_done++;
|
||||||
const pct = Math.round(20 + _done / _total * 80);
|
const pct = Math.round(20 + _done / _total * 80);
|
||||||
|
|
@ -919,11 +1018,14 @@ window.Page_map = (() => {
|
||||||
const pct = Math.round(20 + _done / _total * 80);
|
const pct = Math.round(20 + _done / _total * 80);
|
||||||
const total = Object.values(_layers).flat().filter(m => !m._ownPlace).length;
|
const total = Object.values(_layers).flat().filter(m => !m._ownPlace).length;
|
||||||
_setOsmStatus(pct < 100 ? `Scanne…` : `${total} Marker`, pct);
|
_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);
|
try {
|
||||||
_overpassActive = false;
|
await Promise.all(freshTasks);
|
||||||
|
} finally {
|
||||||
|
_overpassActive = false;
|
||||||
|
}
|
||||||
|
|
||||||
const totalLoaded = Object.values(_layers).flat().filter(m => !m._ownPlace).length;
|
const totalLoaded = Object.values(_layers).flat().filter(m => !m._ownPlace).length;
|
||||||
const allHidden = Object.keys(OSM_LAYER_MAP).every(k => _visible[k] === false);
|
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);
|
_setOsmStatus('Layer deaktiviert — Liste antippen', 100);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Wenn 0 OSM-Marker: Hintergrund-Fetch läuft noch — max 3× automatisch nachfragen
|
// Wenn 0 OSM-Marker: Hintergrund-Overpass-Fetch läuft noch — bis zu 8× nachfragen
|
||||||
if (totalLoaded === 0 && zoom >= 14 && _autoRetryCount < 3) {
|
// Overpass für alle Layer sequential: bis zu ~4min → Retries müssen das abdecken
|
||||||
|
if (totalLoaded === 0 && zoom >= 14 && _autoRetryCount < 8) {
|
||||||
_autoRetryCount++;
|
_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…`);
|
_setOsmStatus(`Neue Umgebung – Daten werden geladen…`);
|
||||||
setTimeout(() => { if (!_overpassActive) _scheduleOsmLoad(); }, delay);
|
setTimeout(() => { if (!_overpassActive) _scheduleOsmLoad(); }, delay);
|
||||||
}
|
}
|
||||||
|
|
@ -1944,6 +2049,92 @@ window.Page_map = (() => {
|
||||||
} catch { /* still */ }
|
} 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 };
|
return { init, refresh, onDogChange, startRecording: _startRecording, stopRecording: _stopRecording, isRecording: () => _recActive };
|
||||||
|
|
||||||
})();
|
})();
|
||||||
|
|
|
||||||
|
|
@ -1698,6 +1698,10 @@ window.Page_routes = (() => {
|
||||||
center: [mid.lat, mid.lon], zoom: 15,
|
center: [mid.lat, mid.lon], zoom: 15,
|
||||||
zoomControl: false, attributionControl: false,
|
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)
|
// Route-Polylines: erledigt (grün) + ausstehend (orange)
|
||||||
const doneLine = L.polyline([], { color: '#22c55e', weight: 5, opacity: 0.85 }).addTo(_navMap);
|
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] });
|
_navMap.fitBounds(remainLine.getBounds(), { padding: [20, 20] });
|
||||||
_addRouteArrows(_navMap, track, '#3b82f6');
|
_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)
|
// Start/End-Marker (als Variable damit Reverse sie neu setzen kann)
|
||||||
const mkPin = (p, color) => L.circleMarker([p.lat, p.lon], {
|
const mkPin = (p, color) => L.circleMarker([p.lat, p.lon], {
|
||||||
radius: 8, color: '#fff', weight: 2, fillColor: color, fillOpacity: 1
|
radius: 8, color: '#fff', weight: 2, fillColor: color, fillOpacity: 1
|
||||||
|
|
|
||||||
|
|
@ -672,6 +672,13 @@ window.Page_settings = (() => {
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="card mb-4">
|
||||||
|
<div class="by-card-section-header">OpenStreetMap – die Karte mitverbessern</div>
|
||||||
|
<div id="settings-osm-body" class="p-4">
|
||||||
|
<div class="text-sm-muted">Lädt…</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="card mb-4">
|
<div class="card mb-4">
|
||||||
<div class="card-body" style="padding:0">
|
<div class="card-body" style="padding:0">
|
||||||
<div class="sidebar-item" data-page="dog-profile"
|
<div class="sidebar-item" data-page="dog-profile"
|
||||||
|
|
@ -925,6 +932,54 @@ window.Page_settings = (() => {
|
||||||
});
|
});
|
||||||
}).catch(() => {});
|
}).catch(() => {});
|
||||||
|
|
||||||
|
// OSM-Account-Verknüpfung (Modell A) — Status laden + Buttons verdrahten
|
||||||
|
(function _osmLink() {
|
||||||
|
const el = document.getElementById('settings-osm-body');
|
||||||
|
if (!el) return;
|
||||||
|
API.get('/osm-auth/status').then(st => {
|
||||||
|
if (st.linked) {
|
||||||
|
el.innerHTML = `
|
||||||
|
<div style="display:flex;align-items:center;gap:8px;flex-wrap:wrap">
|
||||||
|
<svg class="ph-icon" style="color:var(--c-success)" aria-hidden="true"><use href="/icons/phosphor.svg#check-circle"></use></svg>
|
||||||
|
<span style="font-size:var(--text-sm)">Verknüpft als <strong>${UI.escape(st.osm_name)}</strong></span>
|
||||||
|
</div>
|
||||||
|
<button id="settings-osm-unlink"
|
||||||
|
style="margin-top:var(--space-3);background:none;border:none;
|
||||||
|
color:var(--c-text-muted);font-size:var(--text-xs);cursor:pointer">
|
||||||
|
Verknüpfung trennen
|
||||||
|
</button>`;
|
||||||
|
el.querySelector('#settings-osm-unlink').addEventListener('click', async () => {
|
||||||
|
try { await API.post('/osm-auth/unlink', {}); } catch (e) {}
|
||||||
|
_osmLink();
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
el.innerHTML = `
|
||||||
|
<p class="text-sm-muted" style="margin:0 0 var(--space-3);line-height:1.45">
|
||||||
|
Du kennst die hundefreundlichen Orte besser als jede Karte. Verknüpfe deinen
|
||||||
|
kostenlosen OpenStreetMap-Account und trag mit einem Tap ein, wo dein Hund
|
||||||
|
willkommen war – das hilft jedem Hundehalter nach dir. Kostenlos, gemeinnützig,
|
||||||
|
keine Werbung.
|
||||||
|
</p>
|
||||||
|
<button id="settings-osm-link"
|
||||||
|
style="display:flex;align-items:center;justify-content:center;gap:var(--space-2);
|
||||||
|
padding:var(--space-3) var(--space-4);border-radius:var(--radius-md);
|
||||||
|
border:none;background:var(--c-primary);color:#fff;
|
||||||
|
font-size:var(--text-sm);font-weight:600;cursor:pointer">
|
||||||
|
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#map-trifold"></use></svg>
|
||||||
|
OSM-Konto verknüpfen
|
||||||
|
</button>`;
|
||||||
|
el.querySelector('#settings-osm-link').addEventListener('click', async () => {
|
||||||
|
try {
|
||||||
|
const r = await API.get('/osm-auth/authorize');
|
||||||
|
if (r.authorize_url) window.location.href = r.authorize_url;
|
||||||
|
} catch (e) {
|
||||||
|
UI.toast?.('OSM-Anbindung noch nicht konfiguriert.');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}).catch(() => { el.innerHTML = '<div class="text-sm-muted">OSM-Status nicht verfügbar.</div>'; });
|
||||||
|
})();
|
||||||
|
|
||||||
// Achievements laden (Streak + Stats + Badges)
|
// Achievements laden (Streak + Stats + Badges)
|
||||||
API.get('/achievements/me').then(a => {
|
API.get('/achievements/me').then(a => {
|
||||||
const statsEl = document.getElementById('settings-stats-body');
|
const statsEl = document.getElementById('settings-stats-body');
|
||||||
|
|
|
||||||
|
|
@ -897,8 +897,6 @@ window.Page_walks = (() => {
|
||||||
let _locLon = v.lon != null ? parseFloat(v.lon) : null;
|
let _locLon = v.lon != null ? parseFloat(v.lon) : null;
|
||||||
let _locName = v.ort_name || 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 = `
|
const body = `
|
||||||
<form id="walk-form" autocomplete="off">
|
<form id="walk-form" autocomplete="off">
|
||||||
|
|
||||||
|
|
@ -924,48 +922,7 @@ window.Page_walks = (() => {
|
||||||
|
|
||||||
<div class="form-group" id="wf-location-group">
|
<div class="form-group" id="wf-location-group">
|
||||||
<label class="form-label">Treffpunkt</label>
|
<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>
|
||||||
|
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
|
|
@ -996,157 +953,16 @@ window.Page_walks = (() => {
|
||||||
|
|
||||||
document.getElementById('wf-cancel')?.addEventListener('click', UI.modal.close);
|
document.getElementById('wf-cancel')?.addEventListener('click', UI.modal.close);
|
||||||
|
|
||||||
// --- Mini-Karte ---
|
// Location Picker
|
||||||
let _miniMap = null, _miniMarker = null, _mapEditing = false;
|
let _wfPicker = null;
|
||||||
|
setTimeout(() => {
|
||||||
const _mkIcon = () => L.divIcon({ html: _pinSvg, className: '', iconSize: [28, 36], iconAnchor: [14, 36] });
|
_wfPicker = UI.locationPicker({
|
||||||
|
containerId: 'wf-location-picker',
|
||||||
function _placeMarker(lat, lon) {
|
onSelect: (lat, lon, name) => { _locLat = lat; _locLon = lon; _locName = name; },
|
||||||
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';
|
|
||||||
});
|
});
|
||||||
}
|
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
|
// Formular absenden
|
||||||
document.getElementById('walk-form')?.addEventListener('submit', async e => {
|
document.getElementById('walk-form')?.addEventListener('submit', async e => {
|
||||||
|
|
|
||||||
|
|
@ -453,6 +453,10 @@ const UI = (() => {
|
||||||
const isDark = document.documentElement.dataset.theme === 'dark';
|
const isDark = document.documentElement.dataset.theme === 'dark';
|
||||||
if (isDark) tiles.getContainer().style.filter = 'brightness(0.7) invert(1) contrast(0.9) hue-rotate(200deg)';
|
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;
|
return m;
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|
@ -873,12 +877,35 @@ const UI = (() => {
|
||||||
coordsClear: `${p}-coords-clear`,
|
coordsClear: `${p}-coords-clear`,
|
||||||
suggestions: `${p}-suggestions`,
|
suggestions: `${p}-suggestions`,
|
||||||
pinHere: `${p}-pin-here`,
|
pinHere: `${p}-pin-here`,
|
||||||
|
geoInput: `${p}-geo-input`,
|
||||||
|
geoClear: `${p}-geo-clear`,
|
||||||
|
geoResults: `${p}-geo-results`,
|
||||||
};
|
};
|
||||||
|
|
||||||
// HTML in den Container rendern
|
// HTML in den Container rendern
|
||||||
function _render(container) {
|
function _render(container) {
|
||||||
container.innerHTML = `
|
container.innerHTML = `
|
||||||
<div style="position:relative">
|
<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>
|
<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="
|
<button type="button" id="${ids.pinHere}" style="
|
||||||
position:absolute;bottom:10px;left:50%;transform:translateX(-50%);
|
position:absolute;bottom:10px;left:50%;transform:translateX(-50%);
|
||||||
|
|
@ -1102,6 +1129,75 @@ const UI = (() => {
|
||||||
}
|
}
|
||||||
|
|
||||||
_getEl(ids.locBtn)?.addEventListener('click', _showSuggestions);
|
_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
|
// Container initialisieren
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,7 @@
|
||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<meta name="color-scheme" content="light dark">
|
<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>
|
<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="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">
|
<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>
|
||||||
|
|
||||||
|
<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>
|
<style>
|
||||||
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -3,5 +3,10 @@ Disallow: /api/
|
||||||
Disallow: /ausweis/
|
Disallow: /ausweis/
|
||||||
Disallow: /teilen/
|
Disallow: /teilen/
|
||||||
Disallow: /media/
|
Disallow: /media/
|
||||||
|
Disallow: /force-update
|
||||||
|
Disallow: /pass/
|
||||||
|
Disallow: /widget
|
||||||
|
Disallow: /litters
|
||||||
|
Disallow: /?_t
|
||||||
|
|
||||||
Sitemap: https://banyaro.app/sitemap.xml
|
Sitemap: https://banyaro.app/sitemap.xml
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,7 @@
|
||||||
============================================================ */
|
============================================================ */
|
||||||
|
|
||||||
// ← EINZIGE Stelle für die Version — STATIC_ASSETS und CACHE_VERSION leiten sich ab
|
// ← 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_VERSION = `by-v${VER}`;
|
||||||
const CACHE_STATIC = `${CACHE_VERSION}-static`;
|
const CACHE_STATIC = `${CACHE_VERSION}-static`;
|
||||||
const CACHE_TILES = 'ban-yaro-tiles-v1'; // bleibt über SW-Updates erhalten
|
const CACHE_TILES = 'ban-yaro-tiles-v1'; // bleibt über SW-Updates erhalten
|
||||||
|
|
|
||||||
16
docker-compose.osm.yml
Normal file
16
docker-compose.osm.yml
Normal file
|
|
@ -0,0 +1,16 @@
|
||||||
|
# Monatlicher OSM-POI-Refresh (Build 4) — NICHT Teil des Default-Stacks.
|
||||||
|
# Wird manuell oder vom DSM-Aufgabenplaner getriggert:
|
||||||
|
# docker compose -f docker-compose.osm.yml run --rm osm-refresh
|
||||||
|
# Schreibt in dieselbe SQLite-DB wie der App-Container (./data:/data).
|
||||||
|
services:
|
||||||
|
osm-refresh:
|
||||||
|
build: ./tools/osm-extract
|
||||||
|
image: banyaro-osm-refresh
|
||||||
|
container_name: banyaro-osm-refresh
|
||||||
|
mem_limit: 4g # Schutzschranke gegen die anderen Container
|
||||||
|
volumes:
|
||||||
|
- ./data:/data # gleiche DB wie die App (/data/banyaro.db)
|
||||||
|
environment:
|
||||||
|
- DB_PATH=/data/banyaro.db
|
||||||
|
# - COUNTRIES=switzerland austria germany # bei Bedarf überschreiben
|
||||||
|
restart: "no"
|
||||||
BIN
flyer/flyer_a5_rueckseite.pdf
Normal file
BIN
flyer/flyer_a5_rueckseite.pdf
Normal file
Binary file not shown.
BIN
flyer/flyer_a5_vorderseite.pdf
Normal file
BIN
flyer/flyer_a5_vorderseite.pdf
Normal file
Binary file not shown.
292
promotion/flyer_a5_allgemein.html
Normal file
292
promotion/flyer_a5_allgemein.html
Normal file
File diff suppressed because one or more lines are too long
383
promotion/flyer_a5_rueckseite.html
Normal file
383
promotion/flyer_a5_rueckseite.html
Normal file
File diff suppressed because one or more lines are too long
BIN
promotion/flyer_a5_rueckseite.pdf
Normal file
BIN
promotion/flyer_a5_rueckseite.pdf
Normal file
Binary file not shown.
BIN
promotion/flyer_a5_vorderseite.pdf
Normal file
BIN
promotion/flyer_a5_vorderseite.pdf
Normal file
Binary file not shown.
5
tools/osm-extract/.gitignore
vendored
Normal file
5
tools/osm-extract/.gitignore
vendored
Normal file
|
|
@ -0,0 +1,5 @@
|
||||||
|
# Große/temporäre Datendateien — nie committen
|
||||||
|
*.osm.pbf
|
||||||
|
*.sqlite
|
||||||
|
*.db
|
||||||
|
*.log
|
||||||
14
tools/osm-extract/Dockerfile
Normal file
14
tools/osm-extract/Dockerfile
Normal file
|
|
@ -0,0 +1,14 @@
|
||||||
|
FROM python:3.12-slim-bookworm
|
||||||
|
|
||||||
|
# osmium-tool = RAM-schonende tags-filter-Vorstufe (C++, streaming),
|
||||||
|
# pyosmium = Extraktion + Schwerpunktberechnung.
|
||||||
|
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||||
|
osmium-tool curl ca-certificates \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
RUN pip install --no-cache-dir osmium
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
COPY extract_osm_pois.py load_into_prod.py refresh.sh /app/
|
||||||
|
RUN chmod +x /app/refresh.sh
|
||||||
|
|
||||||
|
ENTRYPOINT ["/app/refresh.sh"]
|
||||||
74
tools/osm-extract/INSTALL.md
Normal file
74
tools/osm-extract/INSTALL.md
Normal file
|
|
@ -0,0 +1,74 @@
|
||||||
|
# Build 4 — POI-Offline-Umstellung auf der DiskStation (DSM-Upload)
|
||||||
|
|
||||||
|
Ziel: Live-Overpass-Scanner abschalten + die 1,45 Mio DACH-POIs in die
|
||||||
|
Produktiv-DB migrieren. Ohne 5,7-GB-Download (die fertige `dach.sqlite` wird
|
||||||
|
mit hochgeladen).
|
||||||
|
|
||||||
|
App-Verzeichnis auf der DS: **`/volume1/docker/banyaro/`** (im File Station:
|
||||||
|
`docker` → `banyaro`).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Schritt 1 — Code hochladen (File Station)
|
||||||
|
|
||||||
|
1. `build4-osm-code.zip` in den Ordner **`docker/banyaro`** hochladen.
|
||||||
|
2. Rechtsklick → **Entpacken → Hierher entpacken**. Das überschreibt
|
||||||
|
`backend/routes/osm.py` (Scanner aus) und legt `tools/osm-extract/` +
|
||||||
|
`docker-compose.osm.yml` an. Andere Dateien bleiben unberührt.
|
||||||
|
|
||||||
|
## Schritt 2 — Vorbereiteten POI-Extrakt hochladen
|
||||||
|
|
||||||
|
3. `dach.sqlite` (181 MB) in den Ordner **`docker/banyaro/data`** hochladen.
|
||||||
|
(Liegt dann im Container als `/data/dach.sqlite`.)
|
||||||
|
|
||||||
|
## Schritt 3 — Migration + Deploy (SSH-Terminal: `ssh ds`)
|
||||||
|
|
||||||
|
```sh
|
||||||
|
cd /volume1/docker/banyaro
|
||||||
|
|
||||||
|
# Refresh-Image bauen (einmalig)
|
||||||
|
docker compose -f docker-compose.osm.yml build
|
||||||
|
|
||||||
|
# MIGRATION: lädt dach.sqlite in die Produktiv-DB (Backup wird vorher angelegt,
|
||||||
|
# user_edited-POIs + Community-Marker bleiben geschützt)
|
||||||
|
docker compose -f docker-compose.osm.yml run --rm \
|
||||||
|
-e PREBUILT_SQLITE=/data/dach.sqlite osm-refresh
|
||||||
|
|
||||||
|
# DEPLOY: baut die App neu → scanner-lose osm.py geht live
|
||||||
|
docker compose up -d --build
|
||||||
|
```
|
||||||
|
|
||||||
|
> Reihenfolge bewusst: erst Daten laden, dann App neu bauen → kein Fenster mit
|
||||||
|
> leerer Karte.
|
||||||
|
|
||||||
|
## Schritt 4 — Prüfen
|
||||||
|
|
||||||
|
```sh
|
||||||
|
docker compose -f docker-compose.osm.yml run --rm --entrypoint python3 \
|
||||||
|
osm-refresh -c "import sqlite3; c=sqlite3.connect('/data/banyaro.db'); \
|
||||||
|
print('osm_pois:', c.execute('select count(*) from osm_pois').fetchone()[0]); \
|
||||||
|
print(c.execute('select type,count(*) from osm_pois group by type order by 2 desc').fetchall())"
|
||||||
|
```
|
||||||
|
Erwartung: ~1.452.675 POIs (bank ~1,0 Mio, restaurant ~60k …). In der App die
|
||||||
|
Karte öffnen → Marker laden ohne Overpass.
|
||||||
|
|
||||||
|
## Aufräumen (optional)
|
||||||
|
|
||||||
|
`dach.sqlite` aus `docker/banyaro/data` kann nach erfolgreicher Migration weg.
|
||||||
|
|
||||||
|
## Monatlicher Auto-Refresh (DSM-Aufgabenplaner)
|
||||||
|
|
||||||
|
Systemsteuerung → Aufgabenplaner → Erstellen → Geplante Aufgabe →
|
||||||
|
Benutzerdefiniertes Skript. Benutzer `root`, monatlich z. B. 1. um 04:00:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
cd /volume1/docker/banyaro && \
|
||||||
|
docker compose -f docker-compose.osm.yml run --rm osm-refresh \
|
||||||
|
>> /volume1/docker/banyaro/data/osm-refresh.log 2>&1
|
||||||
|
```
|
||||||
|
(ohne `PREBUILT_SQLITE` → holt frisch von Geofabrik, ~1–2 GB RAM dank tags-filter)
|
||||||
|
|
||||||
|
## Rollback
|
||||||
|
|
||||||
|
Vor jedem Lauf entsteht `data/banyaro.pre-osm-JJJJMMTT.db`. Im Notfall:
|
||||||
|
App stoppen, diese Datei auf `banyaro.db` zurückkopieren, App starten.
|
||||||
55
tools/osm-extract/README.md
Normal file
55
tools/osm-extract/README.md
Normal file
|
|
@ -0,0 +1,55 @@
|
||||||
|
# OSM-POI Offline-Refresh (Build 4)
|
||||||
|
|
||||||
|
Ersetzt das Live-Scannen gegen `overpass-api.de` (war wiederholt OSM-Bann-Quelle)
|
||||||
|
durch einen **monatlichen Offline-Batch**: POIs werden aus den Geofabrik-OSM-Daten
|
||||||
|
extrahiert und in die Produktiv-DB geladen. Danach keine OSM-Live-Last mehr.
|
||||||
|
|
||||||
|
## Bestandteile
|
||||||
|
|
||||||
|
| Datei | Zweck |
|
||||||
|
|---|---|
|
||||||
|
| `extract_osm_pois.py` | pbf → `osm_pois`-Schema (pyosmium). Kanonisches Kategorie→Tag-Mapping. |
|
||||||
|
| `load_into_prod.py` | Extrakt → Produktiv-DB. Schützt `user_edited=1`, ersetzt den Rest. |
|
||||||
|
| `refresh.sh` | Orchestrierung im Container: download → tags-filter → extract → load. |
|
||||||
|
| `Dockerfile` | Image mit osmium-tool + pyosmium. |
|
||||||
|
| `../../docker-compose.osm.yml` | Eigener Service, **nicht** im Default-Stack. |
|
||||||
|
|
||||||
|
## Was es tut
|
||||||
|
|
||||||
|
- Lädt CH/AT/DE von Geofabrik (~5,7 GB), dampft sie mit `osmium tags-filter`
|
||||||
|
(streaming, <500 MB RAM) auf die relevanten Objekte ein, extrahiert die 9
|
||||||
|
Ban-Yaro-Kategorien und lädt sie in `/data/banyaro.db`.
|
||||||
|
- **Sicherheitskopie** der DB vor jedem Lauf (letzte 3 bleiben).
|
||||||
|
- `user_edited=1`-POIs und `user_map_pois` (Community-Marker) bleiben unberührt.
|
||||||
|
- Peak-RAM ~1–2 GB, hartes `mem_limit: 4g` als Schutzschranke.
|
||||||
|
- Aktuelle Größenordnung: DACH ~1,45 Mio POIs, ~180 MB.
|
||||||
|
|
||||||
|
## Manuell ausführen (Test)
|
||||||
|
|
||||||
|
```sh
|
||||||
|
cd /pfad/zu/banyaro # dort, wo docker-compose.yml liegt
|
||||||
|
docker compose -f docker-compose.osm.yml build
|
||||||
|
docker compose -f docker-compose.osm.yml run --rm osm-refresh
|
||||||
|
```
|
||||||
|
|
||||||
|
## Monatlich per DSM-Aufgabenplaner
|
||||||
|
|
||||||
|
1. **Systemsteuerung → Aufgabenplaner → Erstellen → Geplante Aufgabe → Benutzerdefiniertes Skript**
|
||||||
|
2. Benutzer: `root` (für Docker-Zugriff)
|
||||||
|
3. Zeitplan: **monatlich**, z. B. am 1. um 04:00 (lastarm)
|
||||||
|
4. Aufgabeneinstellungen → Benutzerdefiniertes Skript:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
cd /volume1/docker/banyaro && \
|
||||||
|
/usr/local/bin/docker compose -f docker-compose.osm.yml run --rm osm-refresh \
|
||||||
|
>> /volume1/docker/banyaro/data/osm-refresh.log 2>&1
|
||||||
|
```
|
||||||
|
|
||||||
|
> Pfad `/volume1/docker/banyaro` an euer Compose-Verzeichnis anpassen.
|
||||||
|
> Docker-Binary auf DSM ist meist `/usr/local/bin/docker` (`docker compose`),
|
||||||
|
> bei älteren DSM ggf. `docker-compose`.
|
||||||
|
|
||||||
|
## Rollback
|
||||||
|
|
||||||
|
Vor jedem Lauf wird `banyaro.pre-osm-JJJJMMTT.db` neben der DB abgelegt. Im
|
||||||
|
Notfall App stoppen, diese Datei auf `banyaro.db` zurückkopieren, App starten.
|
||||||
140
tools/osm-extract/extract_osm_pois.py
Normal file
140
tools/osm-extract/extract_osm_pois.py
Normal file
|
|
@ -0,0 +1,140 @@
|
||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
Offline-Extraktion der Ban-Yaro-POIs aus einer OSM-.pbf-Datei.
|
||||||
|
|
||||||
|
Ersetzt das Live-Scannen gegen overpass-api.de (backend/routes/osm.py) durch
|
||||||
|
einen einmaligen/periodischen Batch-Lauf — keine OSM-Live-Last, kein Bann mehr.
|
||||||
|
|
||||||
|
Das Kategorie→Tag-Mapping ist 1:1 aus OSM_QUERIES in backend/routes/osm.py
|
||||||
|
übernommen. Schreibt ins selbe Schema wie die Produktiv-Tabelle `osm_pois`
|
||||||
|
(osm_id, type, lat, lon, name, opening_hours, phone, website, cached_at).
|
||||||
|
|
||||||
|
Aufruf:
|
||||||
|
python3 extract_osm_pois.py <input.osm.pbf> <output.sqlite>
|
||||||
|
"""
|
||||||
|
import sys
|
||||||
|
import sqlite3
|
||||||
|
import osmium
|
||||||
|
|
||||||
|
|
||||||
|
# --- Kategorie-Klassifikation (1:1 aus OSM_QUERIES, backend/routes/osm.py) ---
|
||||||
|
def classify(t) -> list[str]:
|
||||||
|
"""Gibt alle Ban-Yaro-Typen zurück, die auf dieses OSM-Objekt passen."""
|
||||||
|
types: list[str] = []
|
||||||
|
a = t.get("amenity")
|
||||||
|
shop = t.get("shop")
|
||||||
|
craft = t.get("craft")
|
||||||
|
leisure = t.get("leisure")
|
||||||
|
tourism = t.get("tourism")
|
||||||
|
dog = t.get("dog")
|
||||||
|
outdoor = t.get("outdoor_seating")
|
||||||
|
# "hundefreundlich, breiter gefasst": explizit erlaubt ODER Terrasse
|
||||||
|
dog_ok = dog in ("yes", "allowed", "leashed")
|
||||||
|
|
||||||
|
if a == "waste_basket":
|
||||||
|
types.append("waste_basket")
|
||||||
|
if a == "drinking_water":
|
||||||
|
types.append("drinking_water")
|
||||||
|
if a == "veterinary":
|
||||||
|
types.append("tierarzt")
|
||||||
|
if a == "bench":
|
||||||
|
types.append("bank")
|
||||||
|
if leisure == "dog_park" or (leisure == "park" and dog == "yes"):
|
||||||
|
types.append("dog_park")
|
||||||
|
if shop == "pet":
|
||||||
|
types.append("shop")
|
||||||
|
if shop == "pet_grooming" or craft == "pet_grooming":
|
||||||
|
types.append("hundesalon")
|
||||||
|
if (a in ("restaurant", "cafe") and (dog_ok or outdoor == "yes")) or a == "biergarten":
|
||||||
|
types.append("restaurant")
|
||||||
|
if tourism in ("hotel", "guest_house", "hostel") and dog_ok:
|
||||||
|
types.append("hotel")
|
||||||
|
return types
|
||||||
|
|
||||||
|
|
||||||
|
SCHEMA = """
|
||||||
|
CREATE TABLE IF NOT EXISTS osm_pois (
|
||||||
|
osm_id INTEGER NOT NULL,
|
||||||
|
type TEXT NOT NULL,
|
||||||
|
lat REAL NOT NULL,
|
||||||
|
lon REAL NOT NULL,
|
||||||
|
name TEXT,
|
||||||
|
opening_hours TEXT,
|
||||||
|
phone TEXT,
|
||||||
|
website TEXT,
|
||||||
|
user_edited INTEGER NOT NULL DEFAULT 0,
|
||||||
|
cached_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||||
|
PRIMARY KEY (osm_id, type)
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_osm_pois_loc ON osm_pois(type, lat, lon);
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
class PoiHandler(osmium.SimpleHandler):
|
||||||
|
def __init__(self, conn):
|
||||||
|
super().__init__()
|
||||||
|
self.conn = conn
|
||||||
|
self.rows = 0
|
||||||
|
self.objs = 0
|
||||||
|
|
||||||
|
def _save(self, osm_id, tags, lat, lon):
|
||||||
|
types = classify(tags)
|
||||||
|
if not types:
|
||||||
|
return
|
||||||
|
self.objs += 1
|
||||||
|
name = tags.get("name")
|
||||||
|
oh = tags.get("opening_hours")
|
||||||
|
phone = tags.get("phone") or tags.get("contact:phone")
|
||||||
|
web = tags.get("website") or tags.get("contact:website")
|
||||||
|
for ty in types:
|
||||||
|
self.conn.execute(
|
||||||
|
"INSERT OR REPLACE INTO osm_pois "
|
||||||
|
"(osm_id, type, lat, lon, name, opening_hours, phone, website, cached_at) "
|
||||||
|
"VALUES (?,?,?,?,?,?,?,?, datetime('now'))",
|
||||||
|
(osm_id, ty, lat, lon, name, oh, phone, web),
|
||||||
|
)
|
||||||
|
self.rows += 1
|
||||||
|
|
||||||
|
def node(self, n):
|
||||||
|
if n.tags:
|
||||||
|
self._save(n.id, n.tags, n.location.lat, n.location.lon)
|
||||||
|
|
||||||
|
def way(self, w):
|
||||||
|
# Wege (z. B. Tierarzt im Gebäude) → Schwerpunkt aus Knoten ("out center")
|
||||||
|
if not w.tags:
|
||||||
|
return
|
||||||
|
lats, lons = [], []
|
||||||
|
for nd in w.nodes:
|
||||||
|
if nd.location.valid():
|
||||||
|
lats.append(nd.location.lat)
|
||||||
|
lons.append(nd.location.lon)
|
||||||
|
if lats:
|
||||||
|
self._save(w.id, w.tags, sum(lats) / len(lats), sum(lons) / len(lons))
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
if len(sys.argv) != 3:
|
||||||
|
print(__doc__)
|
||||||
|
sys.exit(1)
|
||||||
|
src, dst = sys.argv[1], sys.argv[2]
|
||||||
|
|
||||||
|
conn = sqlite3.connect(dst)
|
||||||
|
conn.executescript(SCHEMA)
|
||||||
|
|
||||||
|
h = PoiHandler(conn)
|
||||||
|
# locations=True: Knoten-Koordinaten im Speicher halten, damit Wege einen
|
||||||
|
# Schwerpunkt bekommen. flex_mem skaliert bis Länder-Extrakte.
|
||||||
|
h.apply_file(src, locations=True, idx="flex_mem")
|
||||||
|
|
||||||
|
conn.commit()
|
||||||
|
print(f"\nObjekte mit Treffer: {h.objs:,} eingefügte Zeilen: {h.rows:,}")
|
||||||
|
print("\nPro Typ:")
|
||||||
|
for ty, cnt in conn.execute(
|
||||||
|
"SELECT type, COUNT(*) FROM osm_pois GROUP BY type ORDER BY 2 DESC"
|
||||||
|
):
|
||||||
|
print(f" {ty:16s} {cnt:>8,}")
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
71
tools/osm-extract/load_into_prod.py
Normal file
71
tools/osm-extract/load_into_prod.py
Normal file
|
|
@ -0,0 +1,71 @@
|
||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
Lädt die offline extrahierten POIs (dach.sqlite) in die Produktiv-DB.
|
||||||
|
|
||||||
|
Semantik des Monats-Refresh:
|
||||||
|
* Alle nicht-editierten OSM-POIs (user_edited=0) werden ersetzt → POIs, die
|
||||||
|
aus OSM verschwunden sind, fallen sauber raus.
|
||||||
|
* Von Nutzern korrigierte POIs (user_edited=1, via Moderation) bleiben
|
||||||
|
UNANGETASTET — INSERT OR IGNORE überspringt sie bei Kollision.
|
||||||
|
* Community-Marker (Tabelle user_map_pois) sind separat und werden nie
|
||||||
|
berührt.
|
||||||
|
|
||||||
|
Läuft in EINER Transaktion. Bei Fehler bleibt die alte DB unverändert.
|
||||||
|
|
||||||
|
Aufruf:
|
||||||
|
python3 load_into_prod.py <extract.sqlite> <ziel/banyaro.db>
|
||||||
|
"""
|
||||||
|
import sys
|
||||||
|
import sqlite3
|
||||||
|
|
||||||
|
COLS = "osm_id, type, lat, lon, name, opening_hours, phone, website, user_edited, cached_at"
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
if len(sys.argv) != 3:
|
||||||
|
print(__doc__)
|
||||||
|
sys.exit(1)
|
||||||
|
extract_path, prod_path = sys.argv[1], sys.argv[2]
|
||||||
|
|
||||||
|
# timeout/busy_timeout: die App schreibt evtl. parallel — auf Lock warten,
|
||||||
|
# statt sofort zu scheitern. Der Load läuft in EINER Transaktion.
|
||||||
|
conn = sqlite3.connect(prod_path, timeout=120)
|
||||||
|
conn.execute("PRAGMA busy_timeout=120000")
|
||||||
|
conn.execute("PRAGMA foreign_keys=ON")
|
||||||
|
conn.execute(f"ATTACH DATABASE ? AS ext", (extract_path,))
|
||||||
|
|
||||||
|
before = conn.execute("SELECT COUNT(*) FROM osm_pois").fetchone()[0]
|
||||||
|
edited = conn.execute("SELECT COUNT(*) FROM osm_pois WHERE user_edited=1").fetchone()[0]
|
||||||
|
incoming = conn.execute("SELECT COUNT(*) FROM ext.osm_pois").fetchone()[0]
|
||||||
|
|
||||||
|
try:
|
||||||
|
conn.execute("BEGIN")
|
||||||
|
# 1) nicht-editierte OSM-POIs verwerfen (editierte bleiben stehen)
|
||||||
|
conn.execute("DELETE FROM osm_pois WHERE user_edited=0")
|
||||||
|
# 2) frische Extraktion einspielen; editierte Survivor nicht überschreiben
|
||||||
|
conn.execute(
|
||||||
|
f"INSERT OR IGNORE INTO osm_pois ({COLS}) "
|
||||||
|
f"SELECT {COLS} FROM ext.osm_pois"
|
||||||
|
)
|
||||||
|
conn.execute("COMMIT")
|
||||||
|
except Exception:
|
||||||
|
conn.execute("ROLLBACK")
|
||||||
|
raise
|
||||||
|
|
||||||
|
after = conn.execute("SELECT COUNT(*) FROM osm_pois").fetchone()[0]
|
||||||
|
print(f"Vorher: {before:>10,}")
|
||||||
|
print(f"davon user_edited:{edited:>10,} (geschützt)")
|
||||||
|
print(f"Eingespielt: {incoming:>10,}")
|
||||||
|
print(f"Nachher: {after:>10,}")
|
||||||
|
print("\nPro Typ (nachher):")
|
||||||
|
for ty, cnt in conn.execute(
|
||||||
|
"SELECT type, COUNT(*) FROM osm_pois GROUP BY type ORDER BY 2 DESC"
|
||||||
|
):
|
||||||
|
print(f" {ty:16s} {cnt:>9,}")
|
||||||
|
|
||||||
|
conn.execute("DETACH DATABASE ext")
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
66
tools/osm-extract/refresh.sh
Normal file
66
tools/osm-extract/refresh.sh
Normal file
|
|
@ -0,0 +1,66 @@
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
#
|
||||||
|
# Monatlicher POI-Refresh (Build 4) — läuft im Docker-Container auf der Synology.
|
||||||
|
# download → tags-filter (RAM-schonend) → extract → load in die Produktiv-DB.
|
||||||
|
# Ersetzt das Live-Overpass-Scannen (war Bann-Quelle).
|
||||||
|
#
|
||||||
|
# Erst-Migration ohne 5,7-GB-Download: vorab gebaute dach.sqlite mitliefern und
|
||||||
|
# PREBUILT_SQLITE=/data/dach.sqlite setzen → überspringt download/extract.
|
||||||
|
#
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
DB="${DB_PATH:-/data/banyaro.db}"
|
||||||
|
WORK="${WORK_DIR:-/work}"
|
||||||
|
COUNTRIES="${COUNTRIES:-switzerland austria germany}"
|
||||||
|
GEOFABRIK="${GEOFABRIK_BASE:-https://download.geofabrik.de/europe}"
|
||||||
|
KEEP_BACKUPS="${KEEP_BACKUPS:-3}"
|
||||||
|
PREBUILT_SQLITE="${PREBUILT_SQLITE:-}"
|
||||||
|
|
||||||
|
# OSM-Tags für die 9 Ban-Yaro-Kategorien (Superset; finale Klassifikation macht
|
||||||
|
# extract_osm_pois.py). nw/ = node+way, referenzierte Knoten bleiben für die
|
||||||
|
# Weg-Geometrie automatisch erhalten.
|
||||||
|
FILTER=(
|
||||||
|
"nw/amenity=waste_basket,drinking_water,veterinary,bench,biergarten,restaurant,cafe"
|
||||||
|
"nw/leisure=dog_park,park"
|
||||||
|
"nw/shop=pet,pet_grooming"
|
||||||
|
"nw/craft=pet_grooming"
|
||||||
|
"nw/tourism=hotel,guest_house,hostel"
|
||||||
|
)
|
||||||
|
|
||||||
|
mkdir -p "$WORK"; cd "$WORK"
|
||||||
|
echo "[$(date -u)] POI-Refresh start → $DB"
|
||||||
|
|
||||||
|
# 1) Sicherheitskopie der Produktiv-DB (nur die letzten N behalten)
|
||||||
|
if [ -f "$DB" ]; then
|
||||||
|
bak="${DB%.db}.pre-osm-$(date -u +%Y%m%d).db"
|
||||||
|
cp -p "$DB" "$bak"
|
||||||
|
echo "Backup: $bak"
|
||||||
|
ls -1t "${DB%.db}".pre-osm-*.db 2>/dev/null | tail -n +$((KEEP_BACKUPS + 1)) | xargs -r rm -f
|
||||||
|
fi
|
||||||
|
|
||||||
|
# 2a) Schnellweg: vorab gebauten Extrakt direkt laden (kein Download/Extract)
|
||||||
|
if [ -n "$PREBUILT_SQLITE" ]; then
|
||||||
|
[ -f "$PREBUILT_SQLITE" ] || { echo "FEHLER: $PREBUILT_SQLITE nicht gefunden"; exit 1; }
|
||||||
|
echo "[$(date -u)] PREBUILT_SQLITE=$PREBUILT_SQLITE → überspringe download/extract"
|
||||||
|
python3 /app/load_into_prod.py "$PREBUILT_SQLITE" "$DB"
|
||||||
|
echo "[$(date -u)] POI-Refresh (prebuilt) fertig."
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
# 2b) Regulärer Monatslauf: frisch holen + extrahieren
|
||||||
|
rm -f dach.sqlite
|
||||||
|
for c in $COUNTRIES; do
|
||||||
|
echo "[$(date -u)] $c: download"
|
||||||
|
curl -fSL --retry 3 -o "$c.osm.pbf" "$GEOFABRIK/$c-latest.osm.pbf"
|
||||||
|
echo "[$(date -u)] $c: tags-filter"
|
||||||
|
osmium tags-filter --overwrite -o "$c.f.osm.pbf" "$c.osm.pbf" "${FILTER[@]}"
|
||||||
|
rm -f "$c.osm.pbf"
|
||||||
|
echo "[$(date -u)] $c: extract"
|
||||||
|
python3 /app/extract_osm_pois.py "$c.f.osm.pbf" dach.sqlite
|
||||||
|
rm -f "$c.f.osm.pbf"
|
||||||
|
done
|
||||||
|
|
||||||
|
echo "[$(date -u)] load → Produktiv-DB"
|
||||||
|
python3 /app/load_into_prod.py dach.sqlite "$DB"
|
||||||
|
rm -f dach.sqlite
|
||||||
|
echo "[$(date -u)] POI-Refresh fertig."
|
||||||
Loading…
Add table
Add a link
Reference in a new issue