Compare commits
No commits in common. "46caa05020d64b95c5151d8d74fd8e481986116a" and "2d907f6370af1248ec903b90ca5038a75e177540" have entirely different histories.
46caa05020
...
2d907f6370
38 changed files with 431 additions and 2313 deletions
1
.gitignore
vendored
1
.gitignore
vendored
|
|
@ -12,4 +12,3 @@ __pycache__/
|
||||||
/icons/
|
/icons/
|
||||||
.claude/worktrees/
|
.claude/worktrees/
|
||||||
Ban Yaro - Google Play package/
|
Ban Yaro - Google Play package/
|
||||||
/unsplash/
|
|
||||||
|
|
|
||||||
72
MARKETING.md
72
MARKETING.md
|
|
@ -1,72 +0,0 @@
|
||||||
# 🐾 Ban Yaro — Marketing-Cockpit
|
|
||||||
|
|
||||||
**Single Source of Truth fürs Marketing.** Vor jeder Aktion hier prüfen, danach updaten — so wird nichts doppelt gemacht, vergessen oder übersehen. Pflege: René + Claude.
|
|
||||||
|
|
||||||
_Stand: 2026-06-03_
|
|
||||||
|
|
||||||
> Diese Datei = Planung & Checkliste. Für **Live-Daten** (User-Meilenstein, Kanal-Tracking) lohnt zusätzlich ein Marketing-Tab im **Admin-Bereich** — siehe „Ausbau" unten.
|
|
||||||
|
|
||||||
## 📊 Kanal-Überblick
|
|
||||||
| Kanal / Bereich | Status | Nächster Schritt |
|
|
||||||
|---|---|---|
|
|
||||||
| Flyer Print | 🟢 1000 gedruckt (03.06.) | lokal verteilen |
|
|
||||||
| Flyer Digital | 💡 Idee | Doppelseiten-PDF + Empfehlungs-QR |
|
|
||||||
| Lokal (Ebersberg) | ⬜ offen | Tierärzte, Hundeschulen, Futterläden, Tierheim |
|
|
||||||
| Online-Communities | ⬜ offen | FB-Gruppen Landkreis EBE + nebenan.de |
|
|
||||||
| Empfehlung / Referral | 🟡 Infra da (`referral_code`) | Empfehlungs-QR + Tracking sichtbar machen |
|
|
||||||
| Influencer | 🟡 2 Runden (Mai), kaum Resonanz | Runde 3 erst ab ~50 aktiven Usern |
|
|
||||||
| Presse / Blogs | 🟡 1 Runde, kaum Resonanz | keine Massenwelle; Nische zuerst |
|
|
||||||
| Verzeichnisse / Listings | ⬜ offen | Product Hunt, PWA-Dirs, Google Business EBE |
|
|
||||||
| SEO / KI-Auffindbarkeit | 🟡 technisch optimiert | Backlinks (Blog-Testberichte) |
|
|
||||||
| Landing Page | 🟡 Redesign-Briefing da | 3 Einstiege, Outcomes statt Features |
|
|
||||||
| App Store (iOS) | 🟢 in Review (1.0 (3), 03.06.) | Freigabe abwarten |
|
|
||||||
| Play Store (Android) | 🔴 ON HOLD | 12 Closed-Tester / 14 Tage fehlen |
|
|
||||||
| Merch / NFC-Halsband | 💡 recherchiert | 20 Tags für Beta (~33 €) |
|
|
||||||
|
|
||||||
Legende: 🟢 läuft/erledigt · 🟡 angefangen · ⬜ offen · 💡 Idee · 🔴 blockiert
|
|
||||||
|
|
||||||
## ⏳ Gates / Trigger (nicht zu früh starten)
|
|
||||||
- **Influencer & Presse Runde 3** erst ab **~50 aktiven Usern** — vorher zu früh (Großredaktionen fragen zuerst nach Zahlen). → Bei jeder Session aktuelle User-Zahl checken.
|
|
||||||
- iOS-App ist nativ gebaut & in Review — **überholt** die alte „iOS erst ab 10k via Rork/PWABuilder"-Strategie.
|
|
||||||
|
|
||||||
## 📋 Backlog (konkret als Nächstes)
|
|
||||||
- [ ] **Flyer lokal verteilen (Ebersberg)** — Tierärzte (Wartezimmer), Hundeschulen/Welpengruppen, Futterläden, Hundesalons, Tierheim, Hundewiesen-Aushänge, hundefreundliche Cafés. Persönlich erklären; Aufhänger: Giftköder-Radar + „Daten in Deutschland". **Lokal bündeln, nicht streuen** (Community-Dichte für Gassi-Treffen/Giftköder).
|
|
||||||
- [ ] **Digitaler Doppelseiten-Flyer (PDF)** mit **Empfehlungs-QR** für Online-/Gruppen-Verteilung. Quelle: `promotion/flyer_a5_*.html`. _Offene Frage: generischer `?ref=empfehlung`-Link vs. pro-User `referral_code`._
|
|
||||||
- [ ] **Lokale FB-Gruppen + nebenan.de** — Flyer-Foto + Link posten.
|
|
||||||
- [ ] **Verzeichnisse** — Product Hunt, progressivewebappstore.com, pwafire.org/directory, Google Business (Ebersberg).
|
|
||||||
- [ ] **Landing-Page-Redesign** nach Briefing (3 Zielgruppen-Einstiege Hundebesitzer/Züchter/Welpenkäufer, Outcomes statt Features, Züchter-SaaS prominent, Datenschutz als Argument, Gründer-Story + Foto).
|
|
||||||
- [ ] **Messung einbauen** — „Wie hast du von uns gehört?" im Onboarding + QR-refs pro Kanal.
|
|
||||||
|
|
||||||
## ✅ Erledigt
|
|
||||||
- [x] 1000 Flyer A5 (zweiseitig) gedruckt — 03.06.2026
|
|
||||||
- [x] iOS-App nativ gebaut + eingereicht (1.0 (3), in Review) — Details im Repo `banyaro-ios`
|
|
||||||
- [x] Influencer-Outreach Runde 1 (5) + Runde 2 (13) — Mai 2026
|
|
||||||
- [x] SEO-Grundlagen (llms.txt, Landing About-Section)
|
|
||||||
|
|
||||||
## 📈 Messung — was bringt wirklich Nutzer?
|
|
||||||
- **Onboarding-Frage „Wie hast du von uns gehört?"** (1 Klick) = billigste & wichtigste Kontrolle. _(noch einzubauen)_
|
|
||||||
- **QR-refs pro Ort/Kanal** (z. B. `banyaro.app/?ref=tierarzt-grafing`) → ab nächster Flyer-Charge.
|
|
||||||
- **`referral_code`** (in DB, `routes/auth.py`) → Empfehlungen zählbar.
|
|
||||||
- Aktive User aktuell: _[aus Admin eintragen]_
|
|
||||||
|
|
||||||
## 🗂 Details je Kanal
|
|
||||||
|
|
||||||
### Influencer
|
|
||||||
2 Runden im Mai gesendet (`partner@banyaro.app`; DKIM/SPF/DMARC aktiv), **kaum Resonanz** — zu früh (wenige User), teils falsche Adressen (z. B. GEO → richtig `chefredaktion@geo.de`).
|
|
||||||
**Runde 3:** keine Massenwelle ohne PR-Agentur; **Hundeschulen/-trainer zuerst** (kleines Netzwerk, empfehlen aktiv Tools, Trainingsfeature ist stark), persönliche Mails, Aufhänger = neue Features + echte Nutzerzahlen.
|
|
||||||
→ **Wer schon kontaktiert wurde:** AI-Memory `project_influencer_outreach` (Runde 1: verpinscht, missyminzi, wanderlust_samoyed, viviundholly, doguniversity, dogstv; Runde 2: nami.and.tommy, brina.explores, heimatherzen, pfotentick, flummis_diary, verwolft, wildwildwilli, knutini_, ninja.vom.wolfstor, pupsonality, osman_theparson, babybearyuki, dogswiss). **Vor neuer Runde dort prüfen.**
|
|
||||||
|
|
||||||
### Play Store (Android TWA)
|
|
||||||
PWABuilder-Paket fertig (`Ban Yaro - Google Play package/`, Package `app.banyaro.twa`). **BLOCKER:** Google verlangt 12 Closed-Tester über 14 Tage — Tester fehlen (Engpass, nicht die Technik). assetlinks.json + Play-Console-Eintrag stehen bereit. Nicht priorisieren bis Tester da.
|
|
||||||
|
|
||||||
### Merch / NFC-Halsband
|
|
||||||
Tag recherchiert: **HID Laundry Tag 16 mm** (shopnfc, SKU RE-ICO2-16, ~1 €/Stk ab 500), für `banyaro.app/hund/{id}`. Beta: 20 Stk (~33 €) an erste Nutzer.
|
|
||||||
|
|
||||||
### Flyer
|
|
||||||
Print: A5 zweiseitig, Quelle `promotion/flyer_a5_allgemein.html` + `flyer_a5_rueckseite.html`, QR → banyaro.app. Vorderseite = alle Hundebesitzer, Rückseite stark Züchter-fokussiert.
|
|
||||||
|
|
||||||
## 🚀 Ausbau: Live-Tool im Admin-Bereich (optional)
|
|
||||||
Diese Datei deckt Planung/Checkliste ab (Claude pflegt sie). Der **Admin-Bereich** lohnt sich für die Teile mit echten Daten:
|
|
||||||
- **User-Meilenstein-Anzeige** (aktive User) → blendet automatisch den „Outreach Runde 3"-Hinweis ein, sobald ~50 erreicht.
|
|
||||||
- **Kanal-Tracking**: Auswertung „Wie gehört?" + QR-ref-Zähler + `referral_code`-Statistik.
|
|
||||||
- Optional: das Kanal-Board (Status/Backlog) als editierbare Admin-Seite.
|
|
||||||
2
VERSION
2
VERSION
|
|
@ -1 +1 @@
|
||||||
1155
|
1141
|
||||||
|
|
@ -356,18 +356,6 @@ 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,
|
||||||
|
|
|
||||||
197
backend/main.py
197
backend/main.py
|
|
@ -227,7 +227,6 @@ 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
|
||||||
|
|
@ -293,7 +292,6 @@ 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"])
|
||||||
|
|
@ -513,11 +511,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/wurfboerse", "daily", "0.8"),
|
("https://banyaro.app/info", "monthly", "0.8"),
|
||||||
|
("https://banyaro.app/presse", "monthly", "0.7"),
|
||||||
("https://banyaro.app/wiki/rassen", "weekly", "0.8"),
|
("https://banyaro.app/wiki/rassen", "weekly", "0.8"),
|
||||||
("https://banyaro.app/help", "monthly", "0.7"),
|
|
||||||
("https://banyaro.app/knigge", "monthly", "0.7"),
|
("https://banyaro.app/knigge", "monthly", "0.7"),
|
||||||
("https://banyaro.app/partner", "monthly", "0.6"),
|
("https://banyaro.app/wurfboerse", "daily", "0.8"),
|
||||||
]
|
]
|
||||||
|
|
||||||
try:
|
try:
|
||||||
|
|
@ -528,6 +526,12 @@ 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 "
|
||||||
|
|
@ -1344,47 +1348,12 @@ 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):
|
||||||
from fastapi.responses import HTMLResponse
|
return FileResponse(f"{STATIC_DIR}/index.html", headers={"Cache-Control": "no-store, no-cache"})
|
||||||
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):
|
||||||
from fastapi.responses import HTMLResponse
|
return FileResponse(f"{STATIC_DIR}/index.html", headers={"Cache-Control": "no-store, no-cache"})
|
||||||
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")
|
||||||
|
|
@ -1502,7 +1471,6 @@ 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; }}
|
||||||
|
|
@ -1689,14 +1657,6 @@ async def knigge_page():
|
||||||
footer{background:#1a1a1a;color:#aaa;text-align:center;padding:1.5rem;font-size:.82rem;margin-top:2rem}
|
footer{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>
|
||||||
|
|
@ -1767,9 +1727,7 @@ 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(
|
||||||
|
|
@ -1778,13 +1736,6 @@ 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">
|
||||||
|
|
@ -1793,8 +1744,6 @@ 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 {{
|
||||||
|
|
@ -1865,11 +1814,6 @@ 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>
|
||||||
|
|
@ -1907,8 +1851,6 @@ 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);
|
||||||
|
|
@ -1958,7 +1900,6 @@ 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}
|
||||||
|
|
@ -2030,8 +1971,6 @@ 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">
|
||||||
|
|
@ -2317,7 +2256,6 @@ 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; }}
|
||||||
|
|
@ -2391,119 +2329,6 @@ 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,7 +7,6 @@ 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,25 +586,6 @@ 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,11 +1,6 @@
|
||||||
"""
|
"""
|
||||||
BAN YARO — OSM POI-Daten + Community-Pins
|
BAN YARO — OSM/Overpass POI-Cache + Community-Pins
|
||||||
Liest OSM-POIs aus der lokalen Tabelle osm_pois (monatlicher Offline-Import,
|
Cacht OSM-Daten lokal, erlaubt Nutzern eigene Marker und Meldungen.
|
||||||
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
|
||||||
|
|
@ -196,9 +191,17 @@ async def get_pois(
|
||||||
fetched_fresh = False
|
fetched_fresh = False
|
||||||
|
|
||||||
if type in OSM_QUERIES:
|
if type in OSM_QUERIES:
|
||||||
# Scanner deaktiviert (Build 4): keine Live-Overpass-Abfragen mehr.
|
tiles = _covering_tiles(south, west, north, east, CACHE_ZOOM)
|
||||||
# POIs stammen aus dem monatlichen Offline-Import in die Tabelle
|
stale = _stale_tiles(type, tiles)
|
||||||
# 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(
|
||||||
|
|
@ -361,17 +364,24 @@ 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(...),
|
||||||
):
|
):
|
||||||
# Scanner deaktiviert (Build 4): kein Live-Overpass-Warmup mehr. POIs
|
tiles = _covering_tiles(south, west, north, east, CACHE_ZOOM)
|
||||||
# kommen aus dem monatlichen Offline-Import (tools/osm-extract/). Endpoint
|
|
||||||
# bleibt als No-Op erhalten, damit bestehende Frontends nicht 404 laufen.
|
async def _warmup():
|
||||||
return {'status': 'offline-import',
|
tasks = [
|
||||||
'message': 'POIs werden monatlich offline importiert — kein Live-Scan nötig.',
|
_fetch_and_store_tile(pt, x, y)
|
||||||
'types': list(OSM_QUERIES.keys())}
|
for pt in OSM_QUERIES
|
||||||
|
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())}
|
||||||
|
|
||||||
|
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
|
|
@ -413,65 +423,3 @@ 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
|
|
||||||
|
|
|
||||||
|
|
@ -1,167 +0,0 @@
|
||||||
"""
|
|
||||||
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,182 +3256,6 @@ html.modal-open {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Orts-Suche — Panel schiebt von oben rein wenn aktiv */
|
|
||||||
.map-search-wrap {
|
|
||||||
position: absolute;
|
|
||||||
top: 0;
|
|
||||||
left: 0;
|
|
||||||
right: 0;
|
|
||||||
z-index: 1002;
|
|
||||||
padding: 8px 12px 4px;
|
|
||||||
background: rgba(255,255,255,0.97);
|
|
||||||
backdrop-filter: blur(8px);
|
|
||||||
box-shadow: 0 3px 14px rgba(0,0,0,0.18);
|
|
||||||
transform: translateY(-110%);
|
|
||||||
transition: transform 0.22s ease;
|
|
||||||
pointer-events: none;
|
|
||||||
}
|
|
||||||
.map-search-wrap.active {
|
|
||||||
transform: translateY(0);
|
|
||||||
pointer-events: auto;
|
|
||||||
}
|
|
||||||
:root[data-theme="dark"] .map-search-wrap { background: rgba(22,22,24,0.97); }
|
|
||||||
.map-search-row {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 8px;
|
|
||||||
background: var(--c-bg, #fff);
|
|
||||||
border-radius: var(--radius-full);
|
|
||||||
border: 1px solid var(--c-border, #e5e7eb);
|
|
||||||
padding: 8px 12px;
|
|
||||||
}
|
|
||||||
.map-search-input {
|
|
||||||
flex: 1;
|
|
||||||
border: none;
|
|
||||||
outline: none;
|
|
||||||
font-size: 15px;
|
|
||||||
font-family: inherit;
|
|
||||||
background: transparent;
|
|
||||||
color: var(--c-text);
|
|
||||||
min-width: 0;
|
|
||||||
}
|
|
||||||
.map-search-input::placeholder { color: #aaa; }
|
|
||||||
.map-search-clear {
|
|
||||||
background: none;
|
|
||||||
border: none;
|
|
||||||
padding: 4px;
|
|
||||||
cursor: pointer;
|
|
||||||
color: #999;
|
|
||||||
line-height: 1;
|
|
||||||
flex-shrink: 0;
|
|
||||||
border-radius: 50%;
|
|
||||||
}
|
|
||||||
.map-search-clear:hover { color: var(--c-text); background: var(--c-bg-subtle); }
|
|
||||||
.map-search-results {
|
|
||||||
background: var(--c-bg, #fff);
|
|
||||||
border-radius: 12px;
|
|
||||||
border: 1px solid var(--c-border, #e5e7eb);
|
|
||||||
margin-top: 6px;
|
|
||||||
margin-bottom: 4px;
|
|
||||||
overflow: hidden;
|
|
||||||
max-height: 240px;
|
|
||||||
overflow-y: auto;
|
|
||||||
}
|
|
||||||
.map-search-item {
|
|
||||||
padding: 10px 14px;
|
|
||||||
cursor: pointer;
|
|
||||||
border-bottom: 1px solid var(--c-border-light, rgba(0,0,0,0.05));
|
|
||||||
}
|
|
||||||
.map-search-item:last-child { border-bottom: none; }
|
|
||||||
.map-search-item:hover,
|
|
||||||
.map-search-item:active { background: var(--c-primary-subtle, #fef3c7); }
|
|
||||||
.map-search-item-name {
|
|
||||||
font-size: 13px;
|
|
||||||
font-weight: 600;
|
|
||||||
color: var(--c-text);
|
|
||||||
white-space: nowrap;
|
|
||||||
overflow: hidden;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
}
|
|
||||||
.map-search-item-sub {
|
|
||||||
font-size: 11px;
|
|
||||||
color: var(--c-text-secondary);
|
|
||||||
margin-top: 1px;
|
|
||||||
white-space: nowrap;
|
|
||||||
overflow: hidden;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
}
|
|
||||||
.map-search-loading,
|
|
||||||
.map-search-empty {
|
|
||||||
padding: 12px 14px;
|
|
||||||
font-size: 13px;
|
|
||||||
color: var(--c-text-secondary);
|
|
||||||
text-align: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Speed Dial — Ein Trigger-Button, Sub-Buttons fächern nach oben auf */
|
|
||||||
.map-speed-dial {
|
|
||||||
position: absolute;
|
|
||||||
bottom: calc(var(--safe-bottom) + 82px);
|
|
||||||
right: 20px;
|
|
||||||
z-index: 1000;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
align-items: flex-end;
|
|
||||||
gap: var(--space-2);
|
|
||||||
}
|
|
||||||
.map-sd-items {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column-reverse; /* unterste Item = erstes im DOM */
|
|
||||||
align-items: flex-end;
|
|
||||||
gap: var(--space-2);
|
|
||||||
pointer-events: none;
|
|
||||||
}
|
|
||||||
.map-speed-dial.open .map-sd-items { pointer-events: auto; }
|
|
||||||
|
|
||||||
.map-sd-item {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 10px;
|
|
||||||
opacity: 0;
|
|
||||||
transform: translateY(8px) scale(0.88);
|
|
||||||
transition: opacity 0.16s ease, transform 0.16s ease;
|
|
||||||
}
|
|
||||||
.map-speed-dial.open .map-sd-item { opacity: 1; transform: translateY(0) scale(1); }
|
|
||||||
.map-speed-dial.open .map-sd-item:nth-child(1) { transition-delay: 0ms; }
|
|
||||||
.map-speed-dial.open .map-sd-item:nth-child(2) { transition-delay: 50ms; }
|
|
||||||
.map-speed-dial.open .map-sd-item:nth-child(3) { transition-delay: 100ms; }
|
|
||||||
.map-speed-dial.open .map-sd-item:nth-child(4) { transition-delay: 150ms; }
|
|
||||||
.map-speed-dial.open .map-sd-item:nth-child(5) { transition-delay: 200ms; }
|
|
||||||
|
|
||||||
.map-sd-label {
|
|
||||||
background: rgba(20,20,20,0.72);
|
|
||||||
color: #fff;
|
|
||||||
font-size: 12px;
|
|
||||||
font-weight: 600;
|
|
||||||
padding: 5px 11px;
|
|
||||||
border-radius: var(--radius-full);
|
|
||||||
white-space: nowrap;
|
|
||||||
backdrop-filter: blur(4px);
|
|
||||||
pointer-events: none;
|
|
||||||
letter-spacing: 0.01em;
|
|
||||||
}
|
|
||||||
.map-sd-btn {
|
|
||||||
width: 46px;
|
|
||||||
height: 46px;
|
|
||||||
border-radius: 50%;
|
|
||||||
background: #fff;
|
|
||||||
color: #C4843A;
|
|
||||||
border: 2px solid rgba(196,132,58,0.25);
|
|
||||||
box-shadow: 0 2px 8px rgba(0,0,0,0.22);
|
|
||||||
cursor: pointer;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
font-size: 1.1rem;
|
|
||||||
flex-shrink: 0;
|
|
||||||
transition: background 0.12s, color 0.12s;
|
|
||||||
-webkit-tap-highlight-color: transparent;
|
|
||||||
}
|
|
||||||
.map-sd-btn:hover,
|
|
||||||
.map-sd-btn:active { background: #fef3c7; }
|
|
||||||
.map-sd-btn.active { background: #C4843A; color: #fff; border-color: #C4843A; }
|
|
||||||
.map-sd-btn.map-fab--pin.active { background: var(--c-danger); border-color: var(--c-danger); color: #fff; }
|
|
||||||
#map-radar-btn.active { background: #1d4ed8; color: #fff; border-color: #1d4ed8; }
|
|
||||||
#map-temp-btn.active { background: #dc2626; color: #fff; border-color: #dc2626; }
|
|
||||||
|
|
||||||
.map-sd-trigger {
|
|
||||||
transition: background 0.15s, transform 0.2s ease;
|
|
||||||
}
|
|
||||||
.map-speed-dial.open .map-sd-trigger {
|
|
||||||
background: #6b4a20;
|
|
||||||
transform: rotate(90deg);
|
|
||||||
}
|
|
||||||
.map-sd-icon-open { display: block; }
|
|
||||||
.map-sd-icon-close { display: none; }
|
|
||||||
.map-speed-dial.open .map-sd-icon-open { display: none; }
|
|
||||||
.map-speed-dial.open .map-sd-icon-close { display: block; }
|
|
||||||
|
|
||||||
/* FAB-Gruppe rechts unten — direkt über dem Zurück-Button */
|
/* 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=1155"></script>
|
<script src="/js/boot-early.js?v=1141"></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=1155">
|
<link rel="stylesheet" href="/css/design-system.css?v=1141">
|
||||||
<link rel="stylesheet" href="/css/layout.css?v=1155">
|
<link rel="stylesheet" href="/css/layout.css?v=1141">
|
||||||
<link rel="stylesheet" href="/css/components.css?v=1155">
|
<link rel="stylesheet" href="/css/components.css?v=1141">
|
||||||
<link rel="stylesheet" href="/css/utilities.css?v=1155">
|
<link rel="stylesheet" href="/css/utilities.css?v=1141">
|
||||||
<link rel="stylesheet" href="/css/lists.css?v=1155">
|
<link rel="stylesheet" href="/css/lists.css?v=1141">
|
||||||
</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=1155"></script>
|
<script src="/js/api.js?v=1141"></script>
|
||||||
<script src="/js/ui.js?v=1155"></script>
|
<script src="/js/ui.js?v=1141"></script>
|
||||||
<script src="/js/app.js?v=1155"></script>
|
<script src="/js/app.js?v=1141"></script>
|
||||||
<script src="/js/worlds.js?v=1155"></script>
|
<script src="/js/worlds.js?v=1141"></script>
|
||||||
<script src="/js/offline-indicator.js?v=1155"></script>
|
<script src="/js/offline-indicator.js?v=1141"></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=1155"></script>
|
<script src="/js/boot.js?v=1141"></script>
|
||||||
|
|
||||||
|
|
||||||
</body>
|
</body>
|
||||||
|
|
|
||||||
|
|
@ -45,12 +45,9 @@ const API = (() => {
|
||||||
throw new APIError(msg, 0, 'network');
|
throw new APIError(msg, 0, 'network');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Versions-Check: Server meldet neue Version → beim nächsten navigate() aktualisieren.
|
// Versions-Check: Server meldet neue Version → Banner anzeigen (einmalig)
|
||||||
// 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 && !window._BY_SW_RELOAD) {
|
if (serverVer && serverVer !== APP_VER && !window._byUpdatePending) {
|
||||||
window._byUpdatePending = true;
|
window._byUpdatePending = true;
|
||||||
window._byNewVersion = serverVer;
|
window._byNewVersion = serverVer;
|
||||||
}
|
}
|
||||||
|
|
@ -442,9 +439,6 @@ 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 = '1155'; // ← bei jedem Deploy mit Frontend-Änderungen erhöhen
|
const APP_VER = '1141'; // ← bei jedem Deploy mit Frontend-Änderungen erhöhen
|
||||||
const APP_VERSION = '1.6.0'; // ← semantische Version, wird bei make release gesetzt
|
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,10 +57,7 @@ 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')) {
|
if (sessionStorage.getItem('by_skip_sw_reload')) return;
|
||||||
sessionStorage.removeItem('by_skip_sw_reload'); // einmalig konsumieren
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
window.location.replace('/?_t=' + Date.now());
|
window.location.replace('/?_t=' + Date.now());
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -1306,7 +1306,37 @@ 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)">
|
||||||
|
|
@ -1508,15 +1538,140 @@ 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;
|
||||||
|
|
||||||
// Location Picker (gemeinsame UI-Komponente)
|
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>';
|
||||||
setTimeout(() => {
|
const _mkIcon = () => L.divIcon({ html: _pinSvg, className: '', iconSize: [32,40], iconAnchor: [16,40] });
|
||||||
const _diaryPicker = UI.locationPicker({
|
|
||||||
containerId: 'diary-location-picker',
|
function _setName(name) {
|
||||||
onSelect: (lat, lon, name) => { _locLat = lat; _locLon = lon; _locName = name; },
|
_locName = name;
|
||||||
|
document.getElementById('diary-location-label').textContent = name;
|
||||||
|
document.getElementById('diary-location-chip-wrap').style.display = '';
|
||||||
|
document.getElementById('diary-location-suggestions').style.display = 'none';
|
||||||
|
}
|
||||||
|
|
||||||
|
function _placeMarker(lat, lon) {
|
||||||
|
if (_miniMarker) { _miniMarker.setLatLng([lat, lon]); return; }
|
||||||
|
_miniMarker = L.marker([lat, lon], { draggable: false, icon: _mkIcon() }).addTo(_miniMap);
|
||||||
|
_miniMarker.on('dragend', () => {
|
||||||
|
const p = _miniMarker.getLatLng(); _locLat = p.lat; _locLon = p.lng;
|
||||||
|
document.getElementById('diary-location-btn-label').textContent = 'POI suchen';
|
||||||
});
|
});
|
||||||
if (_locLat != null) _diaryPicker.setValue(_locLat, _locLon, _locName);
|
}
|
||||||
}, 50);
|
|
||||||
|
document.getElementById('diary-location-clear')?.addEventListener('click', () => {
|
||||||
|
_locName = null;
|
||||||
|
document.getElementById('diary-location-chip-wrap').style.display = 'none';
|
||||||
|
});
|
||||||
|
const _clearBtn = document.getElementById('diary-coords-clear');
|
||||||
|
let _clearPending = false;
|
||||||
|
_clearBtn?.addEventListener('click', () => {
|
||||||
|
if (!_clearPending) {
|
||||||
|
_clearPending = true;
|
||||||
|
_clearBtn.textContent = 'Wirklich entfernen?';
|
||||||
|
_clearBtn.style.color = 'var(--c-danger)';
|
||||||
|
setTimeout(() => {
|
||||||
|
if (_clearPending) {
|
||||||
|
_clearPending = false;
|
||||||
|
_clearBtn.textContent = 'Ort entfernen';
|
||||||
|
_clearBtn.style.color = 'var(--c-text-muted)';
|
||||||
|
}
|
||||||
|
}, 3000);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
_clearPending = false;
|
||||||
|
_clearBtn.textContent = 'Ort entfernen';
|
||||||
|
_clearBtn.style.color = 'var(--c-text-muted)';
|
||||||
|
_locLat = null; _locLon = null; _locName = null;
|
||||||
|
document.getElementById('diary-location-chip-wrap').style.display = 'none';
|
||||||
|
document.getElementById('diary-location-suggestions').style.display = 'none';
|
||||||
|
document.getElementById('diary-location-btn-label').textContent = 'POI suchen';
|
||||||
|
if (_miniMarker) { _miniMarker.remove(); _miniMarker = null; }
|
||||||
|
if (_miniMap) { _miniMap.setView([48.0, 11.9], 7); _setMapEditing(false); }
|
||||||
|
});
|
||||||
|
|
||||||
|
let _mapEditing = false;
|
||||||
|
|
||||||
|
function _setMapEditing(on) {
|
||||||
|
_mapEditing = on;
|
||||||
|
const lbl = document.getElementById('diary-map-edit-label');
|
||||||
|
if (lbl) lbl.textContent = on ? 'Fertig' : 'Position ändern';
|
||||||
|
if (!_miniMap) return;
|
||||||
|
if (on) {
|
||||||
|
if (_miniMarker) _miniMarker.dragging.enable();
|
||||||
|
} else {
|
||||||
|
if (_miniMarker) _miniMarker.dragging.disable();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
document.getElementById('diary-map-edit-btn')?.addEventListener('click', () => {
|
||||||
|
_setMapEditing(!_mapEditing);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Karte beim Formular-Open automatisch laden
|
||||||
|
UI.loadLeaflet().then(() => {
|
||||||
|
setTimeout(() => {
|
||||||
|
const lat = _locLat || 48.0, lon = _locLon || 11.9, zoom = _locLat ? 15 : 7;
|
||||||
|
_miniMap = L.map('diary-map-wrap', {
|
||||||
|
zoomControl: true, attributionControl: false,
|
||||||
|
dragging: true, scrollWheelZoom: false,
|
||||||
|
}).setView([lat, lon], zoom);
|
||||||
|
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', { maxZoom: 19 })
|
||||||
|
.addTo(_miniMap);
|
||||||
|
_miniMap.invalidateSize();
|
||||||
|
if (_locLat) {
|
||||||
|
_placeMarker(lat, lon);
|
||||||
|
_miniMarker.dragging.disable(); // Lesemodus: kein Drag
|
||||||
|
}
|
||||||
|
// Klick nur im Edit-Modus
|
||||||
|
_miniMap.on('click', e => {
|
||||||
|
if (!_mapEditing) return;
|
||||||
|
_locLat = e.latlng.lat; _locLon = e.latlng.lng;
|
||||||
|
_placeMarker(_locLat, _locLon);
|
||||||
|
if (!_mapEditing) _miniMarker.dragging.disable();
|
||||||
|
document.getElementById('diary-location-btn-label').textContent = 'POI suchen';
|
||||||
|
});
|
||||||
|
}, 150);
|
||||||
|
});
|
||||||
|
|
||||||
|
async function _showSuggestions() {
|
||||||
|
const btn = document.getElementById('diary-location-btn');
|
||||||
|
UI.setLoading(btn, true);
|
||||||
|
try {
|
||||||
|
let lat = _locLat, lon = _locLon;
|
||||||
|
if (lat == null || lon == null) {
|
||||||
|
const pos = await API.getLocation();
|
||||||
|
lat = pos.lat; lon = pos.lon;
|
||||||
|
_locLat = lat; _locLon = lon;
|
||||||
|
if (_miniMap) { _miniMap.setView([lat, lon], 15); _placeMarker(lat, lon); }
|
||||||
|
document.getElementById('diary-location-btn-label').textContent = 'POI suchen';
|
||||||
|
}
|
||||||
|
const suggestions = await API.diary.nearby(_appState.activeDog.id, lat, lon);
|
||||||
|
const sugEl = document.getElementById('diary-location-suggestions');
|
||||||
|
if (suggestions.length === 0) {
|
||||||
|
sugEl.innerHTML = '<p style="font-size:var(--text-sm);color:var(--c-text-secondary);padding:var(--space-2) 0">Keine Orte in der Nähe gefunden.</p>';
|
||||||
|
} else {
|
||||||
|
sugEl.innerHTML = suggestions.map(s => `
|
||||||
|
<button type="button" class="diary-location-suggestion"
|
||||||
|
data-name="${UI.escape(s.name)}" data-lat="${s.lat}" data-lon="${s.lon}">
|
||||||
|
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#${_sourceIcon(s.source)}"></use></svg>
|
||||||
|
<span>${UI.escape(s.name)}</span>
|
||||||
|
<small>${s.distance_m < 1000 ? s.distance_m + ' m' : (s.distance_m / 1000).toFixed(1) + ' km'}</small>
|
||||||
|
</button>`).join('');
|
||||||
|
sugEl.querySelectorAll('.diary-location-suggestion').forEach(el => {
|
||||||
|
el.addEventListener('click', () => _setName(el.dataset.name));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
sugEl.style.display = '';
|
||||||
|
} catch (err) {
|
||||||
|
UI.toast.error(err?.message?.includes('GPS') || lat == null
|
||||||
|
? 'GPS nicht verfügbar.' : 'Ortssuche fehlgeschlagen.');
|
||||||
|
} finally {
|
||||||
|
UI.setLoading(btn, false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
document.getElementById('diary-location-btn')?.addEventListener('click', _showSuggestions);
|
||||||
|
|
||||||
document.getElementById('diary-form-delete')?.addEventListener('click', async () => {
|
document.getElementById('diary-form-delete')?.addEventListener('click', async () => {
|
||||||
const ok = await UI.modal.confirm({
|
const ok = await UI.modal.confirm({
|
||||||
|
|
|
||||||
|
|
@ -640,17 +640,6 @@ 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);
|
||||||
|
|
@ -823,9 +812,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);
|
||||||
|
|
@ -833,16 +822,6 @@ 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
|
||||||
|
|
@ -895,28 +874,6 @@ function _fmtDate(iso) {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// ----------------------------------------------------------
|
|
||||||
// Liker-Liste — wer hat geliked?
|
|
||||||
// ----------------------------------------------------------
|
|
||||||
async function _showLikers(targetType, targetId) {
|
|
||||||
try {
|
|
||||||
const likers = await API.forum.likers(targetType, targetId);
|
|
||||||
if (!likers.length) { UI.toast.info('Noch keine Likes.'); return; }
|
|
||||||
const rows = likers.map(l => `
|
|
||||||
<div style="display:flex;align-items:center;gap:var(--space-2);padding:var(--space-2) 0;border-bottom:1px solid var(--c-border-light)">
|
|
||||||
<div class="forum-avatar forum-avatar--sm">${UI.escape(_initial(l.name))}</div>
|
|
||||||
<span style="font-size:0.9rem">${UI.escape(l.name || 'Unbekannt')}</span>
|
|
||||||
${l.founder_number ? `<span style="font-size:10px;font-weight:700;background:#7c3aed;color:#fff;padding:1px 5px;border-radius:4px;margin-left:auto">Gründer #${l.founder_number}</span>` : ''}
|
|
||||||
</div>`).join('');
|
|
||||||
UI.modal.open({
|
|
||||||
title: `${UI.icon('heart')} ${likers.length} ${likers.length === 1 ? 'Like' : 'Likes'}`,
|
|
||||||
body: `<div style="max-height:50vh;overflow-y:auto">${rows}</div>`,
|
|
||||||
footer: `<button type="button" class="btn btn-secondary w-full" id="likers-close">Schließen</button>`,
|
|
||||||
});
|
|
||||||
document.getElementById('likers-close')?.addEventListener('click', UI.modal.close);
|
|
||||||
} catch (err) { UI.toast.error(err.message); }
|
|
||||||
}
|
|
||||||
|
|
||||||
// ----------------------------------------------------------
|
// ----------------------------------------------------------
|
||||||
// Report-Formular
|
// Report-Formular
|
||||||
// ----------------------------------------------------------
|
// ----------------------------------------------------------
|
||||||
|
|
|
||||||
|
|
@ -59,7 +59,6 @@ window.Page_map = (() => {
|
||||||
treffpunkt: [],
|
treffpunkt: [],
|
||||||
community: [],
|
community: [],
|
||||||
zuechter: [],
|
zuechter: [],
|
||||||
hotel: [],
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const VISIBLE_KEY = 'by_map_visible_v1';
|
const VISIBLE_KEY = 'by_map_visible_v1';
|
||||||
|
|
@ -131,10 +130,6 @@ 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;
|
||||||
|
|
@ -215,50 +210,13 @@ window.Page_map = (() => {
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Orts-Suche Panel (von oben einschiebend, geschlossen per default) -->
|
<div class="map-fabs">
|
||||||
<div class="map-search-wrap" id="map-search-wrap">
|
<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-row">
|
${App.hasPro(_appState?.user) ? `
|
||||||
<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-radar-btn" title="Regenradar ein-/ausblenden"><svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#cloud-rain"></use></svg></button>
|
||||||
<input type="search" id="map-search-input" class="map-search-input"
|
<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>
|
||||||
placeholder="Ort oder Adresse…" autocomplete="off" autocorrect="off" spellcheck="false">
|
` : ''}
|
||||||
<button class="map-search-clear" id="map-search-clear" aria-label="Suche schließen">
|
<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>
|
||||||
<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">
|
||||||
|
|
@ -331,19 +289,7 @@ 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 {
|
||||||
|
|
@ -351,54 +297,9 @@ window.Page_map = (() => {
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
document.getElementById('map-pin-btn').addEventListener('click', () => {
|
document.getElementById('map-pin-btn').addEventListener('click', _togglePlacementMode);
|
||||||
_sdEl?.classList.remove('open');
|
document.getElementById('map-radar-btn')?.addEventListener('click', _toggleRadar);
|
||||||
_togglePlacementMode();
|
document.getElementById('map-temp-btn')?.addEventListener('click', _toggleTemp);
|
||||||
});
|
|
||||||
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();
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ----------------------------------------------------------
|
// ----------------------------------------------------------
|
||||||
|
|
@ -1006,7 +907,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);
|
||||||
|
|
@ -1018,14 +919,11 @@ 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;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
try {
|
await Promise.all(freshTasks);
|
||||||
await Promise.all(freshTasks);
|
_overpassActive = false;
|
||||||
} 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);
|
||||||
|
|
@ -1033,13 +931,10 @@ window.Page_map = (() => {
|
||||||
_setOsmStatus('Layer deaktiviert — Liste antippen', 100);
|
_setOsmStatus('Layer deaktiviert — Liste antippen', 100);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Wenn 0 OSM-Marker: Hintergrund-Overpass-Fetch läuft noch — bis zu 8× nachfragen
|
// Wenn 0 OSM-Marker: Hintergrund-Fetch läuft noch — max 3× automatisch nachfragen
|
||||||
// Overpass für alle Layer sequential: bis zu ~4min → Retries müssen das abdecken
|
if (totalLoaded === 0 && zoom >= 14 && _autoRetryCount < 3) {
|
||||||
if (totalLoaded === 0 && zoom >= 14 && _autoRetryCount < 8) {
|
|
||||||
_autoRetryCount++;
|
_autoRetryCount++;
|
||||||
// 10s, 20s, 35s, 50s, 70s, 90s, 120s, 150s
|
const delay = _autoRetryCount * 30000; // 30s, 60s, 90s
|
||||||
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);
|
||||||
}
|
}
|
||||||
|
|
@ -2049,92 +1944,6 @@ 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,10 +1698,6 @@ 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);
|
||||||
|
|
@ -1709,14 +1705,6 @@ 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,13 +672,6 @@ 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"
|
||||||
|
|
@ -932,54 +925,6 @@ 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,6 +897,8 @@ 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">
|
||||||
|
|
||||||
|
|
@ -922,7 +924,48 @@ 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">
|
||||||
|
|
@ -953,16 +996,157 @@ window.Page_walks = (() => {
|
||||||
|
|
||||||
document.getElementById('wf-cancel')?.addEventListener('click', UI.modal.close);
|
document.getElementById('wf-cancel')?.addEventListener('click', UI.modal.close);
|
||||||
|
|
||||||
// Location Picker
|
// --- Mini-Karte ---
|
||||||
let _wfPicker = null;
|
let _miniMap = null, _miniMarker = null, _mapEditing = false;
|
||||||
setTimeout(() => {
|
|
||||||
_wfPicker = UI.locationPicker({
|
|
||||||
containerId: 'wf-location-picker',
|
|
||||||
onSelect: (lat, lon, name) => { _locLat = lat; _locLon = lon; _locName = name; },
|
|
||||||
});
|
|
||||||
if (_locLat != null) _wfPicker.setValue(_locLat, _locLon, _locName);
|
|
||||||
}, 50);
|
|
||||||
|
|
||||||
|
const _mkIcon = () => L.divIcon({ html: _pinSvg, className: '', iconSize: [28, 36], iconAnchor: [14, 36] });
|
||||||
|
|
||||||
|
function _placeMarker(lat, lon) {
|
||||||
|
if (_miniMarker) { _miniMarker.setLatLng([lat, lon]); return; }
|
||||||
|
_miniMarker = L.marker([lat, lon], { draggable: true, icon: _mkIcon() }).addTo(_miniMap);
|
||||||
|
_miniMarker.on('dragend', () => {
|
||||||
|
const p = _miniMarker.getLatLng();
|
||||||
|
_locLat = p.lat; _locLon = p.lng;
|
||||||
|
document.getElementById('wf-lat').value = _locLat;
|
||||||
|
document.getElementById('wf-lon').value = _locLon;
|
||||||
|
document.getElementById('wf-location-btn-label').textContent = 'POI suchen';
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function _setCoords(lat, lon) {
|
||||||
|
_locLat = lat; _locLon = lon;
|
||||||
|
document.getElementById('wf-lat').value = lat;
|
||||||
|
document.getElementById('wf-lon').value = lon;
|
||||||
|
}
|
||||||
|
|
||||||
|
function _setName(name) {
|
||||||
|
_locName = name;
|
||||||
|
document.getElementById('wf-location-label').textContent = name;
|
||||||
|
document.getElementById('wf-location-chip-wrap').style.display = '';
|
||||||
|
document.getElementById('wf-ort-name').value = name;
|
||||||
|
document.getElementById('wf-location-suggestions').style.display = 'none';
|
||||||
|
}
|
||||||
|
|
||||||
|
UI.loadLeaflet().then(() => {
|
||||||
|
setTimeout(() => {
|
||||||
|
const lat = _locLat || 48.0, lon = _locLon || 11.9, zoom = _locLat ? 15 : 7;
|
||||||
|
_miniMap = L.map('wf-map-wrap', {
|
||||||
|
zoomControl: true, attributionControl: false,
|
||||||
|
dragging: true, scrollWheelZoom: false,
|
||||||
|
}).setView([lat, lon], zoom);
|
||||||
|
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', { maxZoom: 19 })
|
||||||
|
.addTo(_miniMap);
|
||||||
|
_miniMap.invalidateSize();
|
||||||
|
if (_locLat) _placeMarker(lat, lon);
|
||||||
|
_miniMap.on('click', e => {
|
||||||
|
_setCoords(e.latlng.lat, e.latlng.lng);
|
||||||
|
_placeMarker(_locLat, _locLon);
|
||||||
|
document.getElementById('wf-location-btn-label').textContent = 'POI suchen';
|
||||||
|
});
|
||||||
|
document.getElementById('wf-map-pin-here')?.addEventListener('click', () => {
|
||||||
|
const c = _miniMap.getCenter();
|
||||||
|
_setCoords(c.lat, c.lng);
|
||||||
|
_placeMarker(c.lat, c.lng);
|
||||||
|
document.getElementById('wf-location-btn-label').textContent = 'POI suchen';
|
||||||
|
});
|
||||||
|
}, 150);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Ort-Name-Chip entfernen
|
||||||
|
document.getElementById('wf-location-clear')?.addEventListener('click', () => {
|
||||||
|
_locName = null;
|
||||||
|
document.getElementById('wf-location-chip-wrap').style.display = 'none';
|
||||||
|
document.getElementById('wf-ort-name').value = '';
|
||||||
|
});
|
||||||
|
|
||||||
|
// Koordinaten + Name entfernen (Zwei-Klick)
|
||||||
|
const clearBtn = document.getElementById('wf-coords-clear');
|
||||||
|
let _clearPending = false;
|
||||||
|
clearBtn?.addEventListener('click', () => {
|
||||||
|
if (!_clearPending) {
|
||||||
|
_clearPending = true;
|
||||||
|
clearBtn.textContent = 'Wirklich entfernen?';
|
||||||
|
clearBtn.style.color = 'var(--c-danger)';
|
||||||
|
setTimeout(() => {
|
||||||
|
_clearPending = false;
|
||||||
|
if (clearBtn) {
|
||||||
|
clearBtn.textContent = 'Ort entfernen';
|
||||||
|
clearBtn.style.color = '';
|
||||||
|
}
|
||||||
|
}, 3000);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
_clearPending = false;
|
||||||
|
clearBtn.textContent = 'Ort entfernen';
|
||||||
|
clearBtn.style.color = '';
|
||||||
|
_locLat = null; _locLon = null; _locName = null;
|
||||||
|
document.getElementById('wf-lat').value = '';
|
||||||
|
document.getElementById('wf-lon').value = '';
|
||||||
|
document.getElementById('wf-ort-name').value = '';
|
||||||
|
document.getElementById('wf-location-chip-wrap').style.display = 'none';
|
||||||
|
document.getElementById('wf-location-suggestions').style.display = 'none';
|
||||||
|
document.getElementById('wf-location-btn-label').textContent = 'GPS → POI suchen';
|
||||||
|
if (_miniMarker) { _miniMarker.remove(); _miniMarker = null; }
|
||||||
|
if (_miniMap) { _miniMap.setView([48.0, 11.9], 7); }
|
||||||
|
});
|
||||||
|
|
||||||
|
// GPS → POI-Suche (wie diary.js)
|
||||||
|
async function _showSuggestions() {
|
||||||
|
const btn = document.getElementById('wf-location-btn');
|
||||||
|
UI.setLoading(btn, true);
|
||||||
|
try {
|
||||||
|
let lat = _locLat, lon = _locLon;
|
||||||
|
if (lat == null || lon == null) {
|
||||||
|
const pos = await API.getLocation({ enableHighAccuracy: true });
|
||||||
|
lat = pos.lat; lon = pos.lon;
|
||||||
|
_setCoords(lat, lon);
|
||||||
|
if (_miniMap) {
|
||||||
|
_miniMap.setView([lat, lon], 15);
|
||||||
|
_placeMarker(lat, lon);
|
||||||
|
if (_miniMarker) _miniMarker.dragging.disable();
|
||||||
|
}
|
||||||
|
document.getElementById('wf-location-btn-label').textContent = 'POI suchen';
|
||||||
|
}
|
||||||
|
|
||||||
|
const suggestions = _appState.user
|
||||||
|
? await API.walks.nearby(lat, lon)
|
||||||
|
: [];
|
||||||
|
|
||||||
|
const sugEl = document.getElementById('wf-location-suggestions');
|
||||||
|
if (!suggestions.length) {
|
||||||
|
sugEl.innerHTML = '<p style="font-size:var(--text-sm);color:var(--c-text-secondary);padding:var(--space-2) 0">Keine Orte in der Nähe gefunden.</p>';
|
||||||
|
} else {
|
||||||
|
sugEl.innerHTML = suggestions.map(s => `
|
||||||
|
<button type="button" class="diary-location-suggestion"
|
||||||
|
data-name="${UI.escape(s.name)}" data-lat="${s.lat}" data-lon="${s.lon}">
|
||||||
|
${UI.icon(_sourceIcon(s.source))}
|
||||||
|
<span>${UI.escape(s.name)}</span>
|
||||||
|
<small>${s.distance_m < 1000 ? s.distance_m + ' m' : (s.distance_m / 1000).toFixed(1) + ' km'}</small>
|
||||||
|
</button>`).join('');
|
||||||
|
sugEl.querySelectorAll('.diary-location-suggestion').forEach(el => {
|
||||||
|
el.addEventListener('click', () => {
|
||||||
|
const slat = parseFloat(el.dataset.lat);
|
||||||
|
const slon = parseFloat(el.dataset.lon);
|
||||||
|
_setCoords(slat, slon);
|
||||||
|
_setName(el.dataset.name);
|
||||||
|
if (_miniMap) {
|
||||||
|
_miniMap.setView([slat, slon], 16);
|
||||||
|
_placeMarker(slat, slon);
|
||||||
|
if (_miniMarker) _miniMarker.dragging.disable();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
sugEl.style.display = '';
|
||||||
|
} catch (err) {
|
||||||
|
UI.toast.error(err?.message?.includes('GPS') || _locLat == null
|
||||||
|
? 'GPS nicht verfügbar.' : 'Ortssuche fehlgeschlagen.');
|
||||||
|
} finally {
|
||||||
|
UI.setLoading(btn, false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
document.getElementById('wf-location-btn')?.addEventListener('click', _showSuggestions);
|
||||||
|
|
||||||
// Formular absenden
|
// Formular absenden
|
||||||
document.getElementById('walk-form')?.addEventListener('submit', async e => {
|
document.getElementById('walk-form')?.addEventListener('submit', async e => {
|
||||||
|
|
|
||||||
|
|
@ -453,10 +453,6 @@ 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;
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|
@ -877,35 +873,12 @@ 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%);
|
||||||
|
|
@ -1129,75 +1102,6 @@ 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=1155"></script>
|
<script src="/js/landing-init.js?v=1141"></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,25 +149,6 @@
|
||||||
}
|
}
|
||||||
</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,10 +3,5 @@ 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 = '1155';
|
const VER = '1141';
|
||||||
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
|
||||||
|
|
|
||||||
|
|
@ -1,16 +0,0 @@
|
||||||
# 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"
|
|
||||||
Binary file not shown.
Binary file not shown.
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
Binary file not shown.
Binary file not shown.
5
tools/osm-extract/.gitignore
vendored
5
tools/osm-extract/.gitignore
vendored
|
|
@ -1,5 +0,0 @@
|
||||||
# Große/temporäre Datendateien — nie committen
|
|
||||||
*.osm.pbf
|
|
||||||
*.sqlite
|
|
||||||
*.db
|
|
||||||
*.log
|
|
||||||
|
|
@ -1,14 +0,0 @@
|
||||||
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"]
|
|
||||||
|
|
@ -1,74 +0,0 @@
|
||||||
# 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.
|
|
||||||
|
|
@ -1,55 +0,0 @@
|
||||||
# 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.
|
|
||||||
|
|
@ -1,140 +0,0 @@
|
||||||
#!/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()
|
|
||||||
|
|
@ -1,71 +0,0 @@
|
||||||
#!/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()
|
|
||||||
|
|
@ -1,66 +0,0 @@
|
||||||
#!/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