Compare commits
4 commits
2d907f6370
...
46caa05020
| Author | SHA1 | Date | |
|---|---|---|---|
| 46caa05020 | |||
| 4bc7454258 | |||
| 214543559c | |||
| 10e39ed135 |
38 changed files with 2313 additions and 431 deletions
1
.gitignore
vendored
1
.gitignore
vendored
|
|
@ -12,3 +12,4 @@ __pycache__/
|
|||
/icons/
|
||||
.claude/worktrees/
|
||||
Ban Yaro - Google Play package/
|
||||
/unsplash/
|
||||
|
|
|
|||
72
MARKETING.md
Normal file
72
MARKETING.md
Normal file
|
|
@ -0,0 +1,72 @@
|
|||
# 🐾 Ban Yaro — Marketing-Cockpit
|
||||
|
||||
**Single Source of Truth fürs Marketing.** Vor jeder Aktion hier prüfen, danach updaten — so wird nichts doppelt gemacht, vergessen oder übersehen. Pflege: René + Claude.
|
||||
|
||||
_Stand: 2026-06-03_
|
||||
|
||||
> Diese Datei = Planung & Checkliste. Für **Live-Daten** (User-Meilenstein, Kanal-Tracking) lohnt zusätzlich ein Marketing-Tab im **Admin-Bereich** — siehe „Ausbau" unten.
|
||||
|
||||
## 📊 Kanal-Überblick
|
||||
| Kanal / Bereich | Status | Nächster Schritt |
|
||||
|---|---|---|
|
||||
| Flyer Print | 🟢 1000 gedruckt (03.06.) | lokal verteilen |
|
||||
| Flyer Digital | 💡 Idee | Doppelseiten-PDF + Empfehlungs-QR |
|
||||
| Lokal (Ebersberg) | ⬜ offen | Tierärzte, Hundeschulen, Futterläden, Tierheim |
|
||||
| Online-Communities | ⬜ offen | FB-Gruppen Landkreis EBE + nebenan.de |
|
||||
| Empfehlung / Referral | 🟡 Infra da (`referral_code`) | Empfehlungs-QR + Tracking sichtbar machen |
|
||||
| Influencer | 🟡 2 Runden (Mai), kaum Resonanz | Runde 3 erst ab ~50 aktiven Usern |
|
||||
| Presse / Blogs | 🟡 1 Runde, kaum Resonanz | keine Massenwelle; Nische zuerst |
|
||||
| Verzeichnisse / Listings | ⬜ offen | Product Hunt, PWA-Dirs, Google Business EBE |
|
||||
| SEO / KI-Auffindbarkeit | 🟡 technisch optimiert | Backlinks (Blog-Testberichte) |
|
||||
| Landing Page | 🟡 Redesign-Briefing da | 3 Einstiege, Outcomes statt Features |
|
||||
| App Store (iOS) | 🟢 in Review (1.0 (3), 03.06.) | Freigabe abwarten |
|
||||
| Play Store (Android) | 🔴 ON HOLD | 12 Closed-Tester / 14 Tage fehlen |
|
||||
| Merch / NFC-Halsband | 💡 recherchiert | 20 Tags für Beta (~33 €) |
|
||||
|
||||
Legende: 🟢 läuft/erledigt · 🟡 angefangen · ⬜ offen · 💡 Idee · 🔴 blockiert
|
||||
|
||||
## ⏳ Gates / Trigger (nicht zu früh starten)
|
||||
- **Influencer & Presse Runde 3** erst ab **~50 aktiven Usern** — vorher zu früh (Großredaktionen fragen zuerst nach Zahlen). → Bei jeder Session aktuelle User-Zahl checken.
|
||||
- iOS-App ist nativ gebaut & in Review — **überholt** die alte „iOS erst ab 10k via Rork/PWABuilder"-Strategie.
|
||||
|
||||
## 📋 Backlog (konkret als Nächstes)
|
||||
- [ ] **Flyer lokal verteilen (Ebersberg)** — Tierärzte (Wartezimmer), Hundeschulen/Welpengruppen, Futterläden, Hundesalons, Tierheim, Hundewiesen-Aushänge, hundefreundliche Cafés. Persönlich erklären; Aufhänger: Giftköder-Radar + „Daten in Deutschland". **Lokal bündeln, nicht streuen** (Community-Dichte für Gassi-Treffen/Giftköder).
|
||||
- [ ] **Digitaler Doppelseiten-Flyer (PDF)** mit **Empfehlungs-QR** für Online-/Gruppen-Verteilung. Quelle: `promotion/flyer_a5_*.html`. _Offene Frage: generischer `?ref=empfehlung`-Link vs. pro-User `referral_code`._
|
||||
- [ ] **Lokale FB-Gruppen + nebenan.de** — Flyer-Foto + Link posten.
|
||||
- [ ] **Verzeichnisse** — Product Hunt, progressivewebappstore.com, pwafire.org/directory, Google Business (Ebersberg).
|
||||
- [ ] **Landing-Page-Redesign** nach Briefing (3 Zielgruppen-Einstiege Hundebesitzer/Züchter/Welpenkäufer, Outcomes statt Features, Züchter-SaaS prominent, Datenschutz als Argument, Gründer-Story + Foto).
|
||||
- [ ] **Messung einbauen** — „Wie hast du von uns gehört?" im Onboarding + QR-refs pro Kanal.
|
||||
|
||||
## ✅ Erledigt
|
||||
- [x] 1000 Flyer A5 (zweiseitig) gedruckt — 03.06.2026
|
||||
- [x] iOS-App nativ gebaut + eingereicht (1.0 (3), in Review) — Details im Repo `banyaro-ios`
|
||||
- [x] Influencer-Outreach Runde 1 (5) + Runde 2 (13) — Mai 2026
|
||||
- [x] SEO-Grundlagen (llms.txt, Landing About-Section)
|
||||
|
||||
## 📈 Messung — was bringt wirklich Nutzer?
|
||||
- **Onboarding-Frage „Wie hast du von uns gehört?"** (1 Klick) = billigste & wichtigste Kontrolle. _(noch einzubauen)_
|
||||
- **QR-refs pro Ort/Kanal** (z. B. `banyaro.app/?ref=tierarzt-grafing`) → ab nächster Flyer-Charge.
|
||||
- **`referral_code`** (in DB, `routes/auth.py`) → Empfehlungen zählbar.
|
||||
- Aktive User aktuell: _[aus Admin eintragen]_
|
||||
|
||||
## 🗂 Details je Kanal
|
||||
|
||||
### Influencer
|
||||
2 Runden im Mai gesendet (`partner@banyaro.app`; DKIM/SPF/DMARC aktiv), **kaum Resonanz** — zu früh (wenige User), teils falsche Adressen (z. B. GEO → richtig `chefredaktion@geo.de`).
|
||||
**Runde 3:** keine Massenwelle ohne PR-Agentur; **Hundeschulen/-trainer zuerst** (kleines Netzwerk, empfehlen aktiv Tools, Trainingsfeature ist stark), persönliche Mails, Aufhänger = neue Features + echte Nutzerzahlen.
|
||||
→ **Wer schon kontaktiert wurde:** AI-Memory `project_influencer_outreach` (Runde 1: verpinscht, missyminzi, wanderlust_samoyed, viviundholly, doguniversity, dogstv; Runde 2: nami.and.tommy, brina.explores, heimatherzen, pfotentick, flummis_diary, verwolft, wildwildwilli, knutini_, ninja.vom.wolfstor, pupsonality, osman_theparson, babybearyuki, dogswiss). **Vor neuer Runde dort prüfen.**
|
||||
|
||||
### Play Store (Android TWA)
|
||||
PWABuilder-Paket fertig (`Ban Yaro - Google Play package/`, Package `app.banyaro.twa`). **BLOCKER:** Google verlangt 12 Closed-Tester über 14 Tage — Tester fehlen (Engpass, nicht die Technik). assetlinks.json + Play-Console-Eintrag stehen bereit. Nicht priorisieren bis Tester da.
|
||||
|
||||
### Merch / NFC-Halsband
|
||||
Tag recherchiert: **HID Laundry Tag 16 mm** (shopnfc, SKU RE-ICO2-16, ~1 €/Stk ab 500), für `banyaro.app/hund/{id}`. Beta: 20 Stk (~33 €) an erste Nutzer.
|
||||
|
||||
### Flyer
|
||||
Print: A5 zweiseitig, Quelle `promotion/flyer_a5_allgemein.html` + `flyer_a5_rueckseite.html`, QR → banyaro.app. Vorderseite = alle Hundebesitzer, Rückseite stark Züchter-fokussiert.
|
||||
|
||||
## 🚀 Ausbau: Live-Tool im Admin-Bereich (optional)
|
||||
Diese Datei deckt Planung/Checkliste ab (Claude pflegt sie). Der **Admin-Bereich** lohnt sich für die Teile mit echten Daten:
|
||||
- **User-Meilenstein-Anzeige** (aktive User) → blendet automatisch den „Outreach Runde 3"-Hinweis ein, sobald ~50 erreicht.
|
||||
- **Kanal-Tracking**: Auswertung „Wie gehört?" + QR-ref-Zähler + `referral_code`-Statistik.
|
||||
- Optional: das Kanal-Board (Status/Backlog) als editierbare Admin-Seite.
|
||||
2
VERSION
2
VERSION
|
|
@ -1 +1 @@
|
|||
1141
|
||||
1155
|
||||
|
|
@ -356,6 +356,18 @@ def init_db():
|
|||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_osm_pois_loc ON osm_pois(type, lat, lon);
|
||||
|
||||
-- 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
|
||||
CREATE TABLE IF NOT EXISTS lost_dogs (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
|
|
|
|||
199
backend/main.py
199
backend/main.py
|
|
@ -227,6 +227,7 @@ from routes.walks import router as walks_router
|
|||
from routes.events import router as events_router
|
||||
from routes.sitting import router as sitting_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.lost import router as lost_router
|
||||
from routes.knigge import router as knigge_router
|
||||
|
|
@ -292,6 +293,7 @@ app.include_router(walks_router, prefix="/api/walks", tags=["Gassi-Tre
|
|||
app.include_router(events_router, prefix="/api/events", tags=["Events"])
|
||||
app.include_router(sitting_router, prefix="/api/sitting", tags=["Sitting"])
|
||||
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(social_router, prefix="/api/social", tags=["Social"])
|
||||
app.include_router(forum_router, prefix="/api/forum", tags=["Forum"])
|
||||
|
|
@ -511,11 +513,11 @@ async def sitemap():
|
|||
urls = [
|
||||
("https://banyaro.app/", "weekly", "1.0"),
|
||||
("https://banyaro.app/zuechter", "weekly", "0.9"),
|
||||
("https://banyaro.app/info", "monthly", "0.8"),
|
||||
("https://banyaro.app/presse", "monthly", "0.7"),
|
||||
("https://banyaro.app/wiki/rassen", "weekly", "0.8"),
|
||||
("https://banyaro.app/knigge", "monthly", "0.7"),
|
||||
("https://banyaro.app/wurfboerse", "daily", "0.8"),
|
||||
("https://banyaro.app/wiki/rassen", "weekly", "0.8"),
|
||||
("https://banyaro.app/help", "monthly", "0.7"),
|
||||
("https://banyaro.app/knigge", "monthly", "0.7"),
|
||||
("https://banyaro.app/partner", "monthly", "0.6"),
|
||||
]
|
||||
|
||||
try:
|
||||
|
|
@ -526,12 +528,6 @@ async def sitemap():
|
|||
for r in rassen:
|
||||
urls.append((f"https://banyaro.app/wiki/rasse/{r['slug']}", "monthly", "0.7"))
|
||||
|
||||
events = conn.execute(
|
||||
"SELECT id FROM events WHERE datum >= date('now') LIMIT 200"
|
||||
).fetchall()
|
||||
for e in events:
|
||||
urls.append((f"https://banyaro.app/api/events/{e['id']}", "weekly", "0.5"))
|
||||
|
||||
# Öffentliche Züchter-Profile
|
||||
breeders = conn.execute(
|
||||
"SELECT bp.zwingername FROM breeder_profiles bp "
|
||||
|
|
@ -1348,12 +1344,47 @@ async def public_dog_page(dog_id: int):
|
|||
# ------------------------------------------------------------------
|
||||
@app.get("/teilen/{token}")
|
||||
async def invite_page(token: str):
|
||||
return FileResponse(f"{STATIC_DIR}/index.html", headers={"Cache-Control": "no-store, no-cache"})
|
||||
from fastapi.responses import HTMLResponse
|
||||
with open(f"{STATIC_DIR}/index.html", encoding="utf-8") as _f:
|
||||
_html = _f.read()
|
||||
_html = _html.replace(
|
||||
'<link rel="canonical" href="https://banyaro.app/">',
|
||||
'<meta name="robots" content="noindex"><link rel="canonical" href="https://banyaro.app/">'
|
||||
)
|
||||
return HTMLResponse(content=_html, headers={"Cache-Control": "no-store, no-cache"})
|
||||
|
||||
|
||||
@app.get("/breeder/{zwingername}")
|
||||
async def breeder_profile_page(zwingername: str):
|
||||
return FileResponse(f"{STATIC_DIR}/index.html", headers={"Cache-Control": "no-store, no-cache"})
|
||||
from fastapi.responses import HTMLResponse
|
||||
from urllib.parse import unquote
|
||||
from database import db as _db
|
||||
import html as _html_mod
|
||||
name = unquote(zwingername)
|
||||
desc = f"Hundezüchter {_html_mod.escape(name)} auf Ban Yaro — Wurfbörse, Stammbaum und mehr."
|
||||
try:
|
||||
with _db() as conn:
|
||||
bp = conn.execute(
|
||||
"SELECT bp.rasse, bp.beschreibung FROM breeder_profiles bp "
|
||||
"JOIN users u ON u.id = bp.user_id WHERE bp.zwingername=? AND u.rolle='breeder' LIMIT 1",
|
||||
(name,)
|
||||
).fetchone()
|
||||
if bp and bp["beschreibung"]:
|
||||
desc = _html_mod.escape(bp["beschreibung"][:160])
|
||||
except Exception:
|
||||
pass
|
||||
with open(f"{STATIC_DIR}/index.html", encoding="utf-8") as _f:
|
||||
_page = _f.read()
|
||||
_page = _page.replace(
|
||||
'<link rel="canonical" href="https://banyaro.app/">',
|
||||
f'<link rel="canonical" href="https://banyaro.app/breeder/{_html_mod.escape(zwingername)}">'
|
||||
).replace(
|
||||
'<title>Ban Yaro</title>',
|
||||
f'<title>{_html_mod.escape(name)} — Hundezüchter auf Ban Yaro</title>'
|
||||
f'\n <meta name="description" content="{desc}">'
|
||||
f'\n <meta name="robots" content="index, follow">'
|
||||
)
|
||||
return HTMLResponse(content=_page, headers={"Cache-Control": "no-store, no-cache"})
|
||||
|
||||
|
||||
@app.get("/litters")
|
||||
|
|
@ -1471,6 +1502,7 @@ async def ausweis_page(dog_id: int, request: Request):
|
|||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<meta name="robots" content="noindex">
|
||||
<title>Heimtierausweis – {esc(dog["name"])}</title>
|
||||
<style>
|
||||
* {{ box-sizing: border-box; margin: 0; padding: 0; }}
|
||||
|
|
@ -1657,6 +1689,14 @@ async def knigge_page():
|
|||
footer{background:#1a1a1a;color:#aaa;text-align:center;padding:1.5rem;font-size:.82rem;margin-top:2rem}
|
||||
footer a{color:#C4843A}
|
||||
</style>
|
||||
<script type="application/ld+json">
|
||||
{{"@context":"https://schema.org","@type":"FAQPage","mainEntity":[
|
||||
{{"@type":"Question","name":"Muss mein Hund in der Öffentlichkeit an der Leine?","acceptedAnswer":{{"@type":"Answer","text":"In Deutschland gilt Leinenpflicht in Innenstädten, Parks, auf Kinderspielplätzen und in Tiergehegen. In ländlichen Gebieten gibt es je nach Bundesland Ausnahmen. In der Brut- und Setzzeit (März–Juli) besteht vielerorts erweiterte Leinenpflicht auch auf Feldwegen."}}}},
|
||||
{{"@type":"Question","name":"Darf ich meinen Hund im öffentlichen Nahverkehr mitnehmen?","acceptedAnswer":{{"@type":"Answer","text":"Kleine Hunde in einer Transporttasche fahren in der Regel kostenlos. Größere Hunde benötigen oft einen Kinderfahrschein und müssen angeleint und mit Maulkorb reisen. Die Regeln variieren je nach Verkehrsbetrieb."}}}},
|
||||
{{"@type":"Question","name":"Wie verhalte ich mich bei der Begegnung mit anderen Hunden?","acceptedAnswer":{{"@type":"Answer","text":"Beim Aufeinandertreffen von Hunden: immer den anderen Hundehalter fragen, ob eine Begegnung erwünscht ist. Leinenstress vermeiden, indem du Abstand hältst oder ausweichst. Einen ängstlichen oder aggressiven Hund nie bedrängen lassen."}}}},
|
||||
{{"@type":"Question","name":"Muss ich den Kot meines Hundes beseitigen?","acceptedAnswer":{{"@type":"Answer","text":"Ja, in Deutschland ist die Beseitigung von Hundekot auf öffentlichen Flächen gesetzlich vorgeschrieben. Bei Verstoß drohen Bußgelder von 25–300 €. Bitte immer Kotbeutel dabeihaben."}}}},
|
||||
{{"@type":"Question","name":"Brauche ich eine Haftpflichtversicherung für meinen Hund?","acceptedAnswer":{{"@type":"Answer","text":"In den meisten deutschen Bundesländern ist eine Hundehaftpflichtversicherung Pflicht. Sie deckt Schäden ab, die dein Hund an Personen oder Sachen verursacht. Ausnahme: In Bayern ist sie freiwillig, wird aber dringend empfohlen."}}}}
|
||||
]}}</script>
|
||||
</head>
|
||||
<body>
|
||||
<header>
|
||||
|
|
@ -1727,7 +1767,9 @@ async def help_page():
|
|||
k for k in by_kat.keys() if k not in KAT_LABEL
|
||||
]
|
||||
|
||||
import json as _json
|
||||
sections_html = ""
|
||||
faq_items = []
|
||||
for kat in kat_order:
|
||||
label = KAT_LABEL.get(kat, kat.replace("_", " ").title())
|
||||
items = "".join(
|
||||
|
|
@ -1736,6 +1778,13 @@ async def help_page():
|
|||
for a in by_kat[kat]
|
||||
)
|
||||
sections_html += f'<section><h2>{_html.escape(label)}</h2>{items}</section>'
|
||||
for a in by_kat[kat]:
|
||||
faq_items.append({
|
||||
"@type": "Question",
|
||||
"name": a["frage"],
|
||||
"acceptedAnswer": {"@type": "Answer", "text": a["antwort"]}
|
||||
})
|
||||
faq_json_ld = _json.dumps(faq_items, ensure_ascii=False)
|
||||
|
||||
html = f"""<!DOCTYPE html>
|
||||
<html lang="de">
|
||||
|
|
@ -1744,6 +1793,8 @@ async def help_page():
|
|||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Hilfe & FAQ — Ban Yaro</title>
|
||||
<meta name="description" content="Antworten zu Ban Yaro und Ban Yaro Go: Installation, Standort, Account, Features.">
|
||||
<link rel="canonical" href="https://banyaro.app/help">
|
||||
<meta name="robots" content="index, follow">
|
||||
<link rel="icon" href="/icons/icon-180.png">
|
||||
<style>
|
||||
:root {{
|
||||
|
|
@ -1814,6 +1865,11 @@ async def help_page():
|
|||
.contact p {{ margin: .25rem 0; color: var(--c-text-sec); }}
|
||||
nav.top {{ margin-bottom: 1.5rem; }}
|
||||
</style>
|
||||
<script type="application/ld+json">{{
|
||||
"@context": "https://schema.org",
|
||||
"@type": "FAQPage",
|
||||
"mainEntity": {faq_json_ld}
|
||||
}}</script>
|
||||
</head>
|
||||
<body>
|
||||
<nav class="top"><a href="/">← banyaro.app</a></nav>
|
||||
|
|
@ -1851,6 +1907,8 @@ async def konto_loeschen():
|
|||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Konto löschen — Ban Yaro</title>
|
||||
<link rel="canonical" href="https://banyaro.app/konto-loeschen">
|
||||
<meta name="robots" content="noindex">
|
||||
<link rel="stylesheet" href="/css/design-system.css">
|
||||
<style>
|
||||
body { font-family: var(--font-sans); background: var(--c-bg); color: var(--c-text);
|
||||
|
|
@ -1900,6 +1958,7 @@ async def force_update():
|
|||
from fastapi.responses import HTMLResponse
|
||||
html = """<!DOCTYPE html><html><head><meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<meta name="robots" content="noindex, nofollow">
|
||||
<title>Ban Yaro — Update</title>
|
||||
<style>body{font-family:sans-serif;display:flex;align-items:center;justify-content:center;
|
||||
height:100vh;margin:0;background:#0f1623;color:#fff;flex-direction:column;gap:16px}
|
||||
|
|
@ -1971,6 +2030,8 @@ async def partner_landing():
|
|||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Ban Yaro Partner — Werde Teil der ersten 100</title>
|
||||
<meta name="description" content="Werde Ban Yaro Partner. Gib deiner Community exklusive Gründer-Lizenzen — nur 100 Plätze weltweit, nie wieder erhältlich.">
|
||||
<link rel="canonical" href="https://banyaro.app/partner">
|
||||
<meta name="robots" content="index, follow">
|
||||
<meta property="og:title" content="Ban Yaro Partner">
|
||||
<meta property="og:description" content="Gib deiner Community etwas Besonderes. 100 Gründer-Plätze. Exklusiv. Für immer.">
|
||||
<meta property="og:image" content="https://banyaro.app/icons/icon-512.png">
|
||||
|
|
@ -2256,6 +2317,7 @@ async def passport_share_page(token: str):
|
|||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<meta name="robots" content="noindex">
|
||||
<title>Hundepass — {dog['name']}</title>
|
||||
<style>
|
||||
* {{ box-sizing: border-box; margin: 0; padding: 0; }}
|
||||
|
|
@ -2329,6 +2391,119 @@ async def passport_share_page(token: str):
|
|||
return HTMLResponse(html)
|
||||
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Wurfbörse /wurfboerse — SSR-Seite mit korrektem Canonical
|
||||
# ------------------------------------------------------------------
|
||||
@app.get("/wurfboerse")
|
||||
async def wurfboerse_page():
|
||||
from fastapi.responses import HTMLResponse
|
||||
from database import db as _db
|
||||
import html as _h
|
||||
|
||||
import json as _json
|
||||
litters_html = ""
|
||||
rows = []
|
||||
ld_items = []
|
||||
try:
|
||||
with _db() as conn:
|
||||
rows = conn.execute(
|
||||
"""SELECT l.id, l.welpen_verfuegbar, l.preis_spanne, l.status,
|
||||
bp.zwingername, bp.rasse,
|
||||
wr.name AS rasse_name
|
||||
FROM litters l
|
||||
JOIN breeder_profiles bp ON bp.id = l.breeder_id
|
||||
JOIN users u ON u.id = bp.user_id
|
||||
LEFT JOIN wiki_rassen wr ON wr.id = bp.breed_id
|
||||
WHERE l.sichtbar=1 AND u.rolle='breeder'
|
||||
AND (l.sichtbar_bis IS NULL OR l.sichtbar_bis >= date('now'))
|
||||
ORDER BY l.created_at DESC LIMIT 60"""
|
||||
).fetchall()
|
||||
for i, r in enumerate(rows, 1):
|
||||
rasse_label = _h.escape(r["rasse_name"] or r["rasse"] or "")
|
||||
zw = _h.escape(r["zwingername"] or "")
|
||||
verfueg = r["welpen_verfuegbar"]
|
||||
preis = _h.escape(r["preis_spanne"] or "")
|
||||
status_map = {"geplant": "Geplant", "geboren": "Geboren", "verfuegbar": "Verfügbar"}
|
||||
status_label = status_map.get(r["status"], r["status"])
|
||||
litters_html += f"""<div class="litter-card">
|
||||
<div class="litter-breed">{rasse_label or "Unbekannte Rasse"}</div>
|
||||
<div class="litter-breeder">Züchter: <a href="/breeder/{_h.escape(r['zwingername'] or '')}">{zw}</a></div>
|
||||
<div class="litter-meta">
|
||||
<span class="badge">{status_label}</span>
|
||||
{f'<span>{verfueg} Welpen verfügbar</span>' if verfueg else ''}
|
||||
{f'<span>{preis}</span>' if preis else ''}
|
||||
</div>
|
||||
</div>"""
|
||||
ld_items.append({
|
||||
"@type": "ListItem",
|
||||
"position": i,
|
||||
"name": f"{r['rasse_name'] or r['rasse'] or 'Welpen'} — {r['zwingername'] or 'Züchter'}",
|
||||
"url": f"https://banyaro.app/breeder/{r['zwingername']}"
|
||||
})
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
count_text = f"{len(rows)} Würfe" if litters_html else "Aktuell keine Würfe eingetragen"
|
||||
ld_json = _json.dumps({
|
||||
"@context": "https://schema.org",
|
||||
"@type": "ItemList",
|
||||
"name": "Wurfbörse — Hundewelpen bei Ban Yaro",
|
||||
"description": "Aktuelle Würfe von geprüften Züchtern auf Ban Yaro",
|
||||
"url": "https://banyaro.app/wurfboerse",
|
||||
"numberOfItems": len(rows),
|
||||
"itemListElement": ld_items
|
||||
}, ensure_ascii=False)
|
||||
|
||||
html = f"""<!DOCTYPE html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Wurfbörse — Hundewelpen bei Ban Yaro</title>
|
||||
<meta name="description" content="Seriöse Hundewelpen von geprüften Züchtern auf Ban Yaro. Jetzt Welpen finden, Züchter kontaktieren und Stammbaum einsehen.">
|
||||
<link rel="canonical" href="https://banyaro.app/wurfboerse">
|
||||
<meta name="robots" content="index, follow">
|
||||
<meta property="og:title" content="Wurfbörse — Hundewelpen bei Ban Yaro">
|
||||
<meta property="og:description" content="Seriöse Hundewelpen von geprüften Züchtern auf Ban Yaro.">
|
||||
<meta property="og:image" content="https://banyaro.app/icons/icon-512.png">
|
||||
<link rel="icon" href="/icons/icon-180.png">
|
||||
<script type="application/ld+json">{ld_json}</script>
|
||||
<style>
|
||||
*,*::before,*::after{{box-sizing:border-box;margin:0;padding:0}}
|
||||
body{{font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;background:#fbfaf6;color:#1c1917;padding:0 0 4rem}}
|
||||
.hero{{background:linear-gradient(135deg,#C4843A,#e8a857);color:#fff;padding:2.5rem 1.5rem;text-align:center}}
|
||||
.hero h1{{font-size:clamp(1.6rem,4vw,2.2rem);font-weight:800;margin-bottom:.5rem}}
|
||||
.hero p{{opacity:.9;font-size:1rem}}
|
||||
.container{{max-width:720px;margin:2rem auto;padding:0 1rem}}
|
||||
.count{{font-size:.85rem;color:#78716c;margin-bottom:1.5rem}}
|
||||
.litter-card{{background:#fff;border-radius:12px;padding:1.2rem 1.4rem;margin-bottom:1rem;
|
||||
box-shadow:0 1px 4px rgba(0,0,0,.08);border-left:4px solid #C4843A}}
|
||||
.litter-breed{{font-size:1.05rem;font-weight:700;color:#1c1917;margin-bottom:.3rem}}
|
||||
.litter-breeder{{font-size:.875rem;color:#57534e;margin-bottom:.5rem}}
|
||||
.litter-breeder a{{color:#C4843A;text-decoration:none}}
|
||||
.litter-meta{{display:flex;gap:.6rem;flex-wrap:wrap;font-size:.8rem;color:#78716c}}
|
||||
.badge{{background:#fef3c7;color:#92400e;border-radius:100px;padding:.1rem .6rem;font-weight:600}}
|
||||
.cta{{display:block;text-align:center;margin-top:2.5rem}}
|
||||
.cta a{{background:#C4843A;color:#fff;border-radius:100px;padding:.85rem 2rem;
|
||||
font-size:1rem;font-weight:700;text-decoration:none;display:inline-block}}
|
||||
.empty{{text-align:center;padding:3rem 1rem;color:#78716c}}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="hero">
|
||||
<h1>Wurfbörse</h1>
|
||||
<p>Hundewelpen von geprüften Züchtern</p>
|
||||
</div>
|
||||
<div class="container">
|
||||
<p class="count">{count_text}</p>
|
||||
{litters_html or '<div class="empty"><p>Aktuell keine Würfe eingetragen.<br>Schau bald wieder vorbei!</p></div>'}
|
||||
<div class="cta"><a href="/">Zur Ban Yaro App</a></div>
|
||||
</div>
|
||||
</body>
|
||||
</html>"""
|
||||
return HTMLResponse(content=html, headers={"Cache-Control": "max-age=1800"})
|
||||
|
||||
|
||||
# SPA Fallback — ALLE nicht-API-Routen gehen zur index.html
|
||||
@app.get("/{full_path:path}")
|
||||
async def spa_fallback(full_path: str):
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ pydantic[email]==2.10.6
|
|||
bcrypt==4.3.0
|
||||
PyJWT==2.10.1
|
||||
httpx==0.28.1
|
||||
cryptography==44.0.0
|
||||
openai==1.59.2
|
||||
anthropic==0.49.0
|
||||
pywebpush==2.0.0
|
||||
|
|
|
|||
|
|
@ -586,6 +586,25 @@ async def toggle_like(data: LikeBody, user=Depends(get_current_user)):
|
|||
return {"liked": liked, "count": count}
|
||||
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# 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
|
||||
# ------------------------------------------------------------------
|
||||
|
|
|
|||
|
|
@ -1,6 +1,11 @@
|
|||
"""
|
||||
BAN YARO — OSM/Overpass POI-Cache + Community-Pins
|
||||
Cacht OSM-Daten lokal, erlaubt Nutzern eigene Marker und Meldungen.
|
||||
BAN YARO — OSM POI-Daten + Community-Pins
|
||||
Liest OSM-POIs aus der lokalen Tabelle osm_pois (monatlicher Offline-Import,
|
||||
tools/osm-extract/), erlaubt Nutzern eigene Marker und Meldungen.
|
||||
|
||||
Build 4: Live-Scannen gegen overpass-api.de ist DEAKTIVIERT (war Bann-Quelle).
|
||||
Die Overpass-Hilfsfunktionen unten sind ungenutzt und können später entfernt
|
||||
werden. /geocode nutzt weiterhin Nominatim für die Adresssuche (geringe Last).
|
||||
"""
|
||||
|
||||
import math
|
||||
|
|
@ -191,17 +196,9 @@ async def get_pois(
|
|||
fetched_fresh = False
|
||||
|
||||
if type in OSM_QUERIES:
|
||||
tiles = _covering_tiles(south, west, north, east, CACHE_ZOOM)
|
||||
stale = _stale_tiles(type, tiles)
|
||||
|
||||
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)
|
||||
|
||||
# Scanner deaktiviert (Build 4): keine Live-Overpass-Abfragen mehr.
|
||||
# POIs stammen aus dem monatlichen Offline-Import in die Tabelle
|
||||
# osm_pois (tools/osm-extract/). Hier wird nur noch daraus gelesen.
|
||||
with db() as conn:
|
||||
reported = {
|
||||
row[0] for row in conn.execute(
|
||||
|
|
@ -364,24 +361,17 @@ async def report_poi(body: ReportIn, user = Depends(get_current_user)):
|
|||
# ------------------------------------------------------------------
|
||||
@router.post('/analyze')
|
||||
async def analyze_region(
|
||||
background_tasks: BackgroundTasks,
|
||||
south: float = Query(...),
|
||||
west: float = Query(...),
|
||||
north: float = Query(...),
|
||||
east: float = Query(...),
|
||||
):
|
||||
tiles = _covering_tiles(south, west, north, east, CACHE_ZOOM)
|
||||
|
||||
async def _warmup():
|
||||
tasks = [
|
||||
_fetch_and_store_tile(pt, x, y)
|
||||
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())}
|
||||
# Scanner deaktiviert (Build 4): kein Live-Overpass-Warmup mehr. POIs
|
||||
# kommen aus dem monatlichen Offline-Import (tools/osm-extract/). Endpoint
|
||||
# bleibt als No-Op erhalten, damit bestehende Frontends nicht 404 laufen.
|
||||
return {'status': 'offline-import',
|
||||
'message': 'POIs werden monatlich offline importiert — kein Live-Scan nötig.',
|
||||
'types': list(OSM_QUERIES.keys())}
|
||||
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
|
|
@ -423,3 +413,65 @@ async def submit_poi_edit(osm_id: str, data: PoiEditCreate,
|
|||
poi[data.field], data.new_value.strip(), user["id"])
|
||||
)
|
||||
return {"status": "pending", "message": "Korrektur wurde zur Prüfung eingereicht."}
|
||||
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Geocoding-Proxy GET /api/osm/geocode?q=…
|
||||
# Nominatim-Rate-Limit: 1 req/s — serverseitig throttled
|
||||
# ------------------------------------------------------------------
|
||||
_nominatim_sem = asyncio.Semaphore(1)
|
||||
_nominatim_last = 0.0
|
||||
|
||||
@router.get('/geocode')
|
||||
async def geocode_search(q: str = Query(..., min_length=2, max_length=200)):
|
||||
import time
|
||||
global _nominatim_last
|
||||
async with _nominatim_sem:
|
||||
wait = 1.1 - (time.monotonic() - _nominatim_last)
|
||||
if wait > 0:
|
||||
await asyncio.sleep(wait)
|
||||
_nominatim_last = time.monotonic()
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=6.0) as client:
|
||||
resp = await client.get(
|
||||
'https://nominatim.openstreetmap.org/search',
|
||||
params={
|
||||
'q': q,
|
||||
'format': 'jsonv2',
|
||||
'limit': 6,
|
||||
'countrycodes': 'de,at,ch',
|
||||
'addressdetails': 1,
|
||||
'accept-language': 'de',
|
||||
},
|
||||
headers={
|
||||
'User-Agent': _OVERPASS_UA,
|
||||
'Referer': 'https://banyaro.app/',
|
||||
}
|
||||
)
|
||||
resp.raise_for_status()
|
||||
data = resp.json()
|
||||
except Exception as e:
|
||||
logger.warning("Nominatim-Fehler: %s", e)
|
||||
raise HTTPException(502, "Geocoding nicht verfügbar")
|
||||
|
||||
out = []
|
||||
for r in data[:6]:
|
||||
addr = r.get('address', {})
|
||||
short = (
|
||||
addr.get('amenity') or addr.get('shop') or addr.get('leisure') or
|
||||
addr.get('road') or addr.get('village') or addr.get('town') or
|
||||
addr.get('city') or r.get('name') or
|
||||
r.get('display_name', '').split(',')[0]
|
||||
)
|
||||
city = addr.get('city') or addr.get('town') or addr.get('village') or addr.get('municipality') or ''
|
||||
state = addr.get('state', '')
|
||||
subtitle = ', '.join(filter(None, [city, state]))
|
||||
out.append({
|
||||
'lat': float(r['lat']),
|
||||
'lon': float(r['lon']),
|
||||
'name': short,
|
||||
'subtitle': subtitle,
|
||||
'full': r.get('display_name', ''),
|
||||
'type': r.get('type', ''),
|
||||
})
|
||||
return out
|
||||
|
|
|
|||
167
backend/routes/osm_auth.py
Normal file
167
backend/routes/osm_auth.py
Normal file
|
|
@ -0,0 +1,167 @@
|
|||
"""
|
||||
OSM-Account-Verknüpfung via OAuth2 (Modell A: Beiträge laufen unter dem
|
||||
eigenen OSM-Account des Nutzers). Basis fürs spätere "Hund war willkommen"
|
||||
(dog=yes) + Gamification/Pro-Freischaltung.
|
||||
|
||||
Flow:
|
||||
1. Frontend ruft (eingeloggt) GET /api/osm-auth/authorize → bekommt die
|
||||
OSM-Authorize-URL inkl. signiertem `state` (trägt die banyaro-user_id +
|
||||
CSRF-Nonce, 10 Min gültig) und leitet den Browser dorthin.
|
||||
2. OSM leitet zurück auf GET /api/osm-auth/callback?code=&state= (ohne JWT —
|
||||
daher die user_id aus `state`). Token-Tausch, OSM-Name holen, Token
|
||||
verschlüsselt in user_osm speichern, zurück in die App leiten.
|
||||
3. GET /status zeigt Verknüpfungsstatus, POST /unlink trennt.
|
||||
|
||||
ENV: OSM_CLIENT_ID, OSM_CLIENT_SECRET, OSM_REDIRECT_URI, OSM_POST_LINK_REDIRECT.
|
||||
Token-Schlüssel wird aus JWT_SECRET abgeleitet (oder OSM_TOKEN_KEY überschreibt).
|
||||
"""
|
||||
import os
|
||||
import base64
|
||||
import hashlib
|
||||
import logging
|
||||
from urllib.parse import urlencode
|
||||
from datetime import datetime, timezone, timedelta
|
||||
|
||||
import jwt
|
||||
import httpx
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from fastapi.responses import RedirectResponse
|
||||
from cryptography.fernet import Fernet, InvalidToken
|
||||
|
||||
from database import db
|
||||
from auth import get_current_user, JWT_SECRET, JWT_ALGO
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter()
|
||||
|
||||
# --- OSM-OAuth2-Endpunkte ---
|
||||
OSM_AUTHORIZE = "https://www.openstreetmap.org/oauth2/authorize"
|
||||
OSM_TOKEN = "https://www.openstreetmap.org/oauth2/token"
|
||||
OSM_USER_API = "https://api.openstreetmap.org/api/0.6/user/details.json"
|
||||
OSM_SCOPES = "read_prefs write_api"
|
||||
|
||||
CLIENT_ID = os.getenv("OSM_CLIENT_ID", "")
|
||||
CLIENT_SECRET = os.getenv("OSM_CLIENT_SECRET", "")
|
||||
REDIRECT_URI = os.getenv("OSM_REDIRECT_URI", "https://staging.banyaro.app/api/osm-auth/callback")
|
||||
POST_LINK_REDIRECT = os.getenv("OSM_POST_LINK_REDIRECT", "/?osm=verknuepft")
|
||||
|
||||
_STATE_TTL_MIN = 10
|
||||
|
||||
# Fernet-Schlüssel zur Token-Verschlüsselung: dediziertes OSM_TOKEN_KEY oder
|
||||
# deterministisch aus JWT_SECRET abgeleitet (kein zusätzliches Secret nötig).
|
||||
def _fernet() -> Fernet:
|
||||
raw = os.getenv("OSM_TOKEN_KEY")
|
||||
if raw:
|
||||
return Fernet(raw.encode() if isinstance(raw, str) else raw)
|
||||
key = base64.urlsafe_b64encode(hashlib.sha256(JWT_SECRET.encode()).digest())
|
||||
return Fernet(key)
|
||||
|
||||
def _encrypt(token: str) -> str:
|
||||
return _fernet().encrypt(token.encode()).decode()
|
||||
|
||||
def _decrypt(token_enc: str) -> str:
|
||||
return _fernet().decrypt(token_enc.encode()).decode()
|
||||
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# GET /authorize — liefert die OSM-Authorize-URL (Frontend redirectet dorthin)
|
||||
# ------------------------------------------------------------------
|
||||
@router.get("/authorize")
|
||||
async def authorize(user=Depends(get_current_user)):
|
||||
if not CLIENT_ID:
|
||||
raise HTTPException(503, "OSM-Anbindung nicht konfiguriert (OSM_CLIENT_ID fehlt).")
|
||||
state = jwt.encode(
|
||||
{"uid": user["id"],
|
||||
"exp": datetime.now(timezone.utc) + timedelta(minutes=_STATE_TTL_MIN),
|
||||
"purpose": "osm-link"},
|
||||
JWT_SECRET, algorithm=JWT_ALGO,
|
||||
)
|
||||
params = {
|
||||
"response_type": "code",
|
||||
"client_id": CLIENT_ID,
|
||||
"redirect_uri": REDIRECT_URI,
|
||||
"scope": OSM_SCOPES,
|
||||
"state": state,
|
||||
}
|
||||
url = OSM_AUTHORIZE + "?" + urlencode(params)
|
||||
return {"authorize_url": url}
|
||||
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# GET /callback — OSM leitet hierher zurück (Browser-Redirect, kein JWT)
|
||||
# ------------------------------------------------------------------
|
||||
@router.get("/callback")
|
||||
async def callback(code: str = Query(...), state: str = Query(...)):
|
||||
# 1) state verifizieren → banyaro-user_id (CSRF + Zuordnung)
|
||||
try:
|
||||
payload = jwt.decode(state, JWT_SECRET, algorithms=[JWT_ALGO])
|
||||
if payload.get("purpose") != "osm-link":
|
||||
raise ValueError("falscher state-Zweck")
|
||||
uid = int(payload["uid"])
|
||||
except Exception:
|
||||
raise HTTPException(400, "Ungültiger oder abgelaufener Verknüpfungs-Link.")
|
||||
|
||||
# 2) code → access_token tauschen
|
||||
async with httpx.AsyncClient(timeout=15) as client:
|
||||
tok = await client.post(OSM_TOKEN, data={
|
||||
"grant_type": "authorization_code",
|
||||
"code": code,
|
||||
"redirect_uri": REDIRECT_URI,
|
||||
"client_id": CLIENT_ID,
|
||||
"client_secret": CLIENT_SECRET,
|
||||
})
|
||||
if tok.status_code != 200:
|
||||
logger.warning("OSM-Token-Tausch fehlgeschlagen: %s %s", tok.status_code, tok.text[:200])
|
||||
raise HTTPException(502, "OSM-Token-Tausch fehlgeschlagen.")
|
||||
access_token = tok.json().get("access_token")
|
||||
if not access_token:
|
||||
raise HTTPException(502, "OSM lieferte kein access_token.")
|
||||
|
||||
# 3) OSM-Identität holen (uid + Anzeigename)
|
||||
me = await client.get(OSM_USER_API, headers={"Authorization": f"Bearer {access_token}"})
|
||||
if me.status_code != 200:
|
||||
raise HTTPException(502, "OSM-Nutzerdaten konnten nicht geladen werden.")
|
||||
u = me.json().get("user", {})
|
||||
osm_uid, osm_name = u.get("id"), u.get("display_name")
|
||||
if not (osm_uid and osm_name):
|
||||
raise HTTPException(502, "OSM-Nutzerdaten unvollständig.")
|
||||
|
||||
# 4) verschlüsselt speichern (eine Verknüpfung pro banyaro-User)
|
||||
with db() as conn:
|
||||
conn.execute(
|
||||
"""INSERT INTO user_osm (user_id, osm_uid, osm_name, token_enc, scopes, linked_at)
|
||||
VALUES (?, ?, ?, ?, ?, datetime('now'))
|
||||
ON CONFLICT(user_id) DO UPDATE SET
|
||||
osm_uid=excluded.osm_uid, osm_name=excluded.osm_name,
|
||||
token_enc=excluded.token_enc, scopes=excluded.scopes,
|
||||
linked_at=excluded.linked_at""",
|
||||
(uid, osm_uid, osm_name, _encrypt(access_token), OSM_SCOPES),
|
||||
)
|
||||
logger.info("OSM verknüpft: banyaro-user %s ↔ OSM '%s' (%s)", uid, osm_name, osm_uid)
|
||||
return RedirectResponse(POST_LINK_REDIRECT, status_code=302)
|
||||
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# GET /status — Verknüpfungsstatus des eingeloggten Nutzers
|
||||
# ------------------------------------------------------------------
|
||||
@router.get("/status")
|
||||
async def status(user=Depends(get_current_user)):
|
||||
with db() as conn:
|
||||
row = conn.execute(
|
||||
"SELECT osm_name, osm_uid, linked_at FROM user_osm WHERE user_id=?",
|
||||
(user["id"],)
|
||||
).fetchone()
|
||||
if not row:
|
||||
return {"linked": False}
|
||||
return {"linked": True, "osm_name": row["osm_name"],
|
||||
"osm_uid": row["osm_uid"], "linked_at": row["linked_at"]}
|
||||
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# POST /unlink — Verknüpfung trennen (Token lokal löschen)
|
||||
# ------------------------------------------------------------------
|
||||
@router.post("/unlink")
|
||||
async def unlink(user=Depends(get_current_user)):
|
||||
with db() as conn:
|
||||
conn.execute("DELETE FROM user_osm WHERE user_id=?", (user["id"],))
|
||||
return {"status": "ok"}
|
||||
|
|
@ -3256,6 +3256,182 @@ html.modal-open {
|
|||
}
|
||||
}
|
||||
|
||||
/* Orts-Suche — Panel schiebt von oben rein wenn aktiv */
|
||||
.map-search-wrap {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
z-index: 1002;
|
||||
padding: 8px 12px 4px;
|
||||
background: rgba(255,255,255,0.97);
|
||||
backdrop-filter: blur(8px);
|
||||
box-shadow: 0 3px 14px rgba(0,0,0,0.18);
|
||||
transform: translateY(-110%);
|
||||
transition: transform 0.22s ease;
|
||||
pointer-events: none;
|
||||
}
|
||||
.map-search-wrap.active {
|
||||
transform: translateY(0);
|
||||
pointer-events: auto;
|
||||
}
|
||||
:root[data-theme="dark"] .map-search-wrap { background: rgba(22,22,24,0.97); }
|
||||
.map-search-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
background: var(--c-bg, #fff);
|
||||
border-radius: var(--radius-full);
|
||||
border: 1px solid var(--c-border, #e5e7eb);
|
||||
padding: 8px 12px;
|
||||
}
|
||||
.map-search-input {
|
||||
flex: 1;
|
||||
border: none;
|
||||
outline: none;
|
||||
font-size: 15px;
|
||||
font-family: inherit;
|
||||
background: transparent;
|
||||
color: var(--c-text);
|
||||
min-width: 0;
|
||||
}
|
||||
.map-search-input::placeholder { color: #aaa; }
|
||||
.map-search-clear {
|
||||
background: none;
|
||||
border: none;
|
||||
padding: 4px;
|
||||
cursor: pointer;
|
||||
color: #999;
|
||||
line-height: 1;
|
||||
flex-shrink: 0;
|
||||
border-radius: 50%;
|
||||
}
|
||||
.map-search-clear:hover { color: var(--c-text); background: var(--c-bg-subtle); }
|
||||
.map-search-results {
|
||||
background: var(--c-bg, #fff);
|
||||
border-radius: 12px;
|
||||
border: 1px solid var(--c-border, #e5e7eb);
|
||||
margin-top: 6px;
|
||||
margin-bottom: 4px;
|
||||
overflow: hidden;
|
||||
max-height: 240px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
.map-search-item {
|
||||
padding: 10px 14px;
|
||||
cursor: pointer;
|
||||
border-bottom: 1px solid var(--c-border-light, rgba(0,0,0,0.05));
|
||||
}
|
||||
.map-search-item:last-child { border-bottom: none; }
|
||||
.map-search-item:hover,
|
||||
.map-search-item:active { background: var(--c-primary-subtle, #fef3c7); }
|
||||
.map-search-item-name {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: var(--c-text);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.map-search-item-sub {
|
||||
font-size: 11px;
|
||||
color: var(--c-text-secondary);
|
||||
margin-top: 1px;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.map-search-loading,
|
||||
.map-search-empty {
|
||||
padding: 12px 14px;
|
||||
font-size: 13px;
|
||||
color: var(--c-text-secondary);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
/* Speed Dial — Ein Trigger-Button, Sub-Buttons fächern nach oben auf */
|
||||
.map-speed-dial {
|
||||
position: absolute;
|
||||
bottom: calc(var(--safe-bottom) + 82px);
|
||||
right: 20px;
|
||||
z-index: 1000;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-end;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
.map-sd-items {
|
||||
display: flex;
|
||||
flex-direction: column-reverse; /* unterste Item = erstes im DOM */
|
||||
align-items: flex-end;
|
||||
gap: var(--space-2);
|
||||
pointer-events: none;
|
||||
}
|
||||
.map-speed-dial.open .map-sd-items { pointer-events: auto; }
|
||||
|
||||
.map-sd-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
opacity: 0;
|
||||
transform: translateY(8px) scale(0.88);
|
||||
transition: opacity 0.16s ease, transform 0.16s ease;
|
||||
}
|
||||
.map-speed-dial.open .map-sd-item { opacity: 1; transform: translateY(0) scale(1); }
|
||||
.map-speed-dial.open .map-sd-item:nth-child(1) { transition-delay: 0ms; }
|
||||
.map-speed-dial.open .map-sd-item:nth-child(2) { transition-delay: 50ms; }
|
||||
.map-speed-dial.open .map-sd-item:nth-child(3) { transition-delay: 100ms; }
|
||||
.map-speed-dial.open .map-sd-item:nth-child(4) { transition-delay: 150ms; }
|
||||
.map-speed-dial.open .map-sd-item:nth-child(5) { transition-delay: 200ms; }
|
||||
|
||||
.map-sd-label {
|
||||
background: rgba(20,20,20,0.72);
|
||||
color: #fff;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
padding: 5px 11px;
|
||||
border-radius: var(--radius-full);
|
||||
white-space: nowrap;
|
||||
backdrop-filter: blur(4px);
|
||||
pointer-events: none;
|
||||
letter-spacing: 0.01em;
|
||||
}
|
||||
.map-sd-btn {
|
||||
width: 46px;
|
||||
height: 46px;
|
||||
border-radius: 50%;
|
||||
background: #fff;
|
||||
color: #C4843A;
|
||||
border: 2px solid rgba(196,132,58,0.25);
|
||||
box-shadow: 0 2px 8px rgba(0,0,0,0.22);
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 1.1rem;
|
||||
flex-shrink: 0;
|
||||
transition: background 0.12s, color 0.12s;
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
}
|
||||
.map-sd-btn:hover,
|
||||
.map-sd-btn:active { background: #fef3c7; }
|
||||
.map-sd-btn.active { background: #C4843A; color: #fff; border-color: #C4843A; }
|
||||
.map-sd-btn.map-fab--pin.active { background: var(--c-danger); border-color: var(--c-danger); color: #fff; }
|
||||
#map-radar-btn.active { background: #1d4ed8; color: #fff; border-color: #1d4ed8; }
|
||||
#map-temp-btn.active { background: #dc2626; color: #fff; border-color: #dc2626; }
|
||||
|
||||
.map-sd-trigger {
|
||||
transition: background 0.15s, transform 0.2s ease;
|
||||
}
|
||||
.map-speed-dial.open .map-sd-trigger {
|
||||
background: #6b4a20;
|
||||
transform: rotate(90deg);
|
||||
}
|
||||
.map-sd-icon-open { display: block; }
|
||||
.map-sd-icon-close { display: none; }
|
||||
.map-speed-dial.open .map-sd-icon-open { display: none; }
|
||||
.map-speed-dial.open .map-sd-icon-close { display: block; }
|
||||
|
||||
/* FAB-Gruppe rechts unten — direkt über dem Zurück-Button */
|
||||
.map-fabs {
|
||||
position: absolute;
|
||||
|
|
|
|||
|
|
@ -86,14 +86,14 @@
|
|||
<title>Ban Yaro</title>
|
||||
|
||||
<!-- Theme + theme-color Statusleiste vor CSS setzen -->
|
||||
<script src="/js/boot-early.js?v=1141"></script>
|
||||
<script src="/js/boot-early.js?v=1155"></script>
|
||||
|
||||
<!-- CSS: Reihenfolge ist wichtig — ?v= zwingt Browser zur Neuladung -->
|
||||
<link rel="stylesheet" href="/css/design-system.css?v=1141">
|
||||
<link rel="stylesheet" href="/css/layout.css?v=1141">
|
||||
<link rel="stylesheet" href="/css/components.css?v=1141">
|
||||
<link rel="stylesheet" href="/css/utilities.css?v=1141">
|
||||
<link rel="stylesheet" href="/css/lists.css?v=1141">
|
||||
<link rel="stylesheet" href="/css/design-system.css?v=1155">
|
||||
<link rel="stylesheet" href="/css/layout.css?v=1155">
|
||||
<link rel="stylesheet" href="/css/components.css?v=1155">
|
||||
<link rel="stylesheet" href="/css/utilities.css?v=1155">
|
||||
<link rel="stylesheet" href="/css/lists.css?v=1155">
|
||||
</head>
|
||||
<body>
|
||||
|
||||
|
|
@ -617,11 +617,11 @@
|
|||
<div id="modal-container"></div>
|
||||
|
||||
<!-- JS: Reihenfolge ist wichtig — erst Basis, dann Features -->
|
||||
<script src="/js/api.js?v=1141"></script>
|
||||
<script src="/js/ui.js?v=1141"></script>
|
||||
<script src="/js/app.js?v=1141"></script>
|
||||
<script src="/js/worlds.js?v=1141"></script>
|
||||
<script src="/js/offline-indicator.js?v=1141"></script>
|
||||
<script src="/js/api.js?v=1155"></script>
|
||||
<script src="/js/ui.js?v=1155"></script>
|
||||
<script src="/js/app.js?v=1155"></script>
|
||||
<script src="/js/worlds.js?v=1155"></script>
|
||||
<script src="/js/offline-indicator.js?v=1155"></script>
|
||||
|
||||
<!-- Feature-Seiten werden lazy geladen -->
|
||||
|
||||
|
|
@ -631,7 +631,7 @@
|
|||
|
||||
|
||||
<!-- Boot: Offline-Banner + SW-Registration (extrahiert für CSP) -->
|
||||
<script src="/js/boot.js?v=1141"></script>
|
||||
<script src="/js/boot.js?v=1155"></script>
|
||||
|
||||
|
||||
</body>
|
||||
|
|
|
|||
|
|
@ -45,9 +45,12 @@ const API = (() => {
|
|||
throw new APIError(msg, 0, 'network');
|
||||
}
|
||||
|
||||
// Versions-Check: Server meldet neue Version → Banner anzeigen (einmalig)
|
||||
// Versions-Check: Server meldet neue Version → beim nächsten navigate() aktualisieren.
|
||||
// Ausnahme: _BY_SW_RELOAD = wir sind gerade von /force-update weitergeleitet worden.
|
||||
// In dem Fall ist APP_VER kurzzeitig veraltet (SW-Cache läuft noch aus) — KEIN erneuter
|
||||
// Pending setzen, sonst entsteht sofort ein Loop beim nächsten Seitenwechsel.
|
||||
const serverVer = response.headers.get('x-app-version');
|
||||
if (serverVer && serverVer !== APP_VER && !window._byUpdatePending) {
|
||||
if (serverVer && serverVer !== APP_VER && !window._byUpdatePending && !window._BY_SW_RELOAD) {
|
||||
window._byUpdatePending = true;
|
||||
window._byNewVersion = serverVer;
|
||||
}
|
||||
|
|
@ -439,6 +442,9 @@ const API = (() => {
|
|||
like(targetType, targetId) {
|
||||
return post('/forum/like', { target_type: targetType, target_id: targetId });
|
||||
},
|
||||
likers(targetType, targetId) {
|
||||
return get(`/forum/likes/${targetType}/${targetId}`);
|
||||
},
|
||||
report(targetType, targetId, grund) {
|
||||
return post('/forum/report', { target_type: targetType, target_id: targetId, grund });
|
||||
},
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@
|
|||
Router, State-Management, Navigation, Initialisierung.
|
||||
============================================================ */
|
||||
|
||||
const APP_VER = '1141'; // ← bei jedem Deploy mit Frontend-Änderungen erhöhen
|
||||
const APP_VER = '1155'; // ← bei jedem Deploy mit Frontend-Änderungen erhöhen
|
||||
const APP_VERSION = '1.6.0'; // ← semantische Version, wird bei make release gesetzt
|
||||
window.APP_VER = APP_VER; // global verfügbar für andere Module (z.B. offline-indicator)
|
||||
window.APP_VERSION = APP_VERSION;
|
||||
|
|
|
|||
|
|
@ -57,7 +57,10 @@ if ('serviceWorker' in navigator) {
|
|||
if (!sw) return;
|
||||
sw.addEventListener('statechange', function() {
|
||||
if (sw.state === 'activated') {
|
||||
if (sessionStorage.getItem('by_skip_sw_reload')) return;
|
||||
if (sessionStorage.getItem('by_skip_sw_reload')) {
|
||||
sessionStorage.removeItem('by_skip_sw_reload'); // einmalig konsumieren
|
||||
return;
|
||||
}
|
||||
window.location.replace('/?_t=' + Date.now());
|
||||
}
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1306,37 +1306,7 @@ window.Page_diary = (() => {
|
|||
</div>
|
||||
<div class="form-group" id="diary-location-group">
|
||||
<label class="form-label">Ort <span class="text-secondary">(optional)</span></label>
|
||||
|
||||
<!-- Karte (Lesemodus, Edit per Button aktivierbar) -->
|
||||
<div style="position:relative">
|
||||
<div id="diary-map-wrap" style="border-radius:var(--radius-md);overflow:hidden;height:220px;background:var(--c-surface-2)"></div>
|
||||
<button type="button" id="diary-map-edit-btn" class="btn btn-secondary btn-sm"
|
||||
style="position:absolute;bottom:var(--space-2);right:var(--space-2);z-index:500">
|
||||
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#pencil-simple"></use></svg>
|
||||
<span id="diary-map-edit-label">Position ändern</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- POI-Name + Aktionen -->
|
||||
<div class="mt-2">
|
||||
<div id="diary-location-chip-wrap" style="${entry?.location_name ? '' : 'display:none'}">
|
||||
<div class="diary-location-chip">
|
||||
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#map-pin"></use></svg>
|
||||
<span id="diary-location-label">${UI.escape(entry?.location_name || '')}</span>
|
||||
<button type="button" id="diary-location-clear" aria-label="Name entfernen">
|
||||
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#x"></use></svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div style="display:flex;gap:var(--space-2);margin-top:var(--space-2)">
|
||||
<button type="button" class="btn btn-danger" id="diary-coords-clear">Ort entfernen</button>
|
||||
<button type="button" class="btn btn-secondary btn-sm" id="diary-location-btn">
|
||||
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#map-pin"></use></svg>
|
||||
<span id="diary-location-btn-label">POI suchen</span>
|
||||
</button>
|
||||
</div>
|
||||
<div id="diary-location-suggestions" style="display:none;margin-top:var(--space-2)"></div>
|
||||
</div>
|
||||
<div id="diary-location-picker"></div>
|
||||
</div>
|
||||
${dogPickerHtml}
|
||||
<div class="form-group" style="margin-top:var(--space-5)">
|
||||
|
|
@ -1538,140 +1508,15 @@ window.Page_diary = (() => {
|
|||
let _locLat = (entry?.gps_lat != null) ? entry.gps_lat : null;
|
||||
let _locLon = (entry?.gps_lon != null) ? entry.gps_lon : null;
|
||||
let _locName = entry?.location_name || null;
|
||||
let _miniMap = null, _miniMarker = null;
|
||||
|
||||
const _pinSvg = '<svg xmlns="http://www.w3.org/2000/svg" width="32" height="40" viewBox="0 0 32 40"><path d="M16 0C7.163 0 0 7.163 0 16c0 10 16 24 16 24S32 26 32 16C32 7.163 24.837 0 16 0z" fill="#C4843A"/><circle cx="16" cy="16" r="7" fill="white"/></svg>';
|
||||
const _mkIcon = () => L.divIcon({ html: _pinSvg, className: '', iconSize: [32,40], iconAnchor: [16,40] });
|
||||
|
||||
function _setName(name) {
|
||||
_locName = name;
|
||||
document.getElementById('diary-location-label').textContent = name;
|
||||
document.getElementById('diary-location-chip-wrap').style.display = '';
|
||||
document.getElementById('diary-location-suggestions').style.display = 'none';
|
||||
}
|
||||
|
||||
function _placeMarker(lat, lon) {
|
||||
if (_miniMarker) { _miniMarker.setLatLng([lat, lon]); return; }
|
||||
_miniMarker = L.marker([lat, lon], { draggable: false, icon: _mkIcon() }).addTo(_miniMap);
|
||||
_miniMarker.on('dragend', () => {
|
||||
const p = _miniMarker.getLatLng(); _locLat = p.lat; _locLon = p.lng;
|
||||
document.getElementById('diary-location-btn-label').textContent = 'POI suchen';
|
||||
// Location Picker (gemeinsame UI-Komponente)
|
||||
setTimeout(() => {
|
||||
const _diaryPicker = UI.locationPicker({
|
||||
containerId: 'diary-location-picker',
|
||||
onSelect: (lat, lon, name) => { _locLat = lat; _locLon = lon; _locName = name; },
|
||||
});
|
||||
}
|
||||
|
||||
document.getElementById('diary-location-clear')?.addEventListener('click', () => {
|
||||
_locName = null;
|
||||
document.getElementById('diary-location-chip-wrap').style.display = 'none';
|
||||
});
|
||||
const _clearBtn = document.getElementById('diary-coords-clear');
|
||||
let _clearPending = false;
|
||||
_clearBtn?.addEventListener('click', () => {
|
||||
if (!_clearPending) {
|
||||
_clearPending = true;
|
||||
_clearBtn.textContent = 'Wirklich entfernen?';
|
||||
_clearBtn.style.color = 'var(--c-danger)';
|
||||
setTimeout(() => {
|
||||
if (_clearPending) {
|
||||
_clearPending = false;
|
||||
_clearBtn.textContent = 'Ort entfernen';
|
||||
_clearBtn.style.color = 'var(--c-text-muted)';
|
||||
}
|
||||
}, 3000);
|
||||
return;
|
||||
}
|
||||
_clearPending = false;
|
||||
_clearBtn.textContent = 'Ort entfernen';
|
||||
_clearBtn.style.color = 'var(--c-text-muted)';
|
||||
_locLat = null; _locLon = null; _locName = null;
|
||||
document.getElementById('diary-location-chip-wrap').style.display = 'none';
|
||||
document.getElementById('diary-location-suggestions').style.display = 'none';
|
||||
document.getElementById('diary-location-btn-label').textContent = 'POI suchen';
|
||||
if (_miniMarker) { _miniMarker.remove(); _miniMarker = null; }
|
||||
if (_miniMap) { _miniMap.setView([48.0, 11.9], 7); _setMapEditing(false); }
|
||||
});
|
||||
|
||||
let _mapEditing = false;
|
||||
|
||||
function _setMapEditing(on) {
|
||||
_mapEditing = on;
|
||||
const lbl = document.getElementById('diary-map-edit-label');
|
||||
if (lbl) lbl.textContent = on ? 'Fertig' : 'Position ändern';
|
||||
if (!_miniMap) return;
|
||||
if (on) {
|
||||
if (_miniMarker) _miniMarker.dragging.enable();
|
||||
} else {
|
||||
if (_miniMarker) _miniMarker.dragging.disable();
|
||||
}
|
||||
}
|
||||
|
||||
document.getElementById('diary-map-edit-btn')?.addEventListener('click', () => {
|
||||
_setMapEditing(!_mapEditing);
|
||||
});
|
||||
|
||||
// Karte beim Formular-Open automatisch laden
|
||||
UI.loadLeaflet().then(() => {
|
||||
setTimeout(() => {
|
||||
const lat = _locLat || 48.0, lon = _locLon || 11.9, zoom = _locLat ? 15 : 7;
|
||||
_miniMap = L.map('diary-map-wrap', {
|
||||
zoomControl: true, attributionControl: false,
|
||||
dragging: true, scrollWheelZoom: false,
|
||||
}).setView([lat, lon], zoom);
|
||||
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', { maxZoom: 19 })
|
||||
.addTo(_miniMap);
|
||||
_miniMap.invalidateSize();
|
||||
if (_locLat) {
|
||||
_placeMarker(lat, lon);
|
||||
_miniMarker.dragging.disable(); // Lesemodus: kein Drag
|
||||
}
|
||||
// Klick nur im Edit-Modus
|
||||
_miniMap.on('click', e => {
|
||||
if (!_mapEditing) return;
|
||||
_locLat = e.latlng.lat; _locLon = e.latlng.lng;
|
||||
_placeMarker(_locLat, _locLon);
|
||||
if (!_mapEditing) _miniMarker.dragging.disable();
|
||||
document.getElementById('diary-location-btn-label').textContent = 'POI suchen';
|
||||
});
|
||||
}, 150);
|
||||
});
|
||||
|
||||
async function _showSuggestions() {
|
||||
const btn = document.getElementById('diary-location-btn');
|
||||
UI.setLoading(btn, true);
|
||||
try {
|
||||
let lat = _locLat, lon = _locLon;
|
||||
if (lat == null || lon == null) {
|
||||
const pos = await API.getLocation();
|
||||
lat = pos.lat; lon = pos.lon;
|
||||
_locLat = lat; _locLon = lon;
|
||||
if (_miniMap) { _miniMap.setView([lat, lon], 15); _placeMarker(lat, lon); }
|
||||
document.getElementById('diary-location-btn-label').textContent = 'POI suchen';
|
||||
}
|
||||
const suggestions = await API.diary.nearby(_appState.activeDog.id, lat, lon);
|
||||
const sugEl = document.getElementById('diary-location-suggestions');
|
||||
if (suggestions.length === 0) {
|
||||
sugEl.innerHTML = '<p style="font-size:var(--text-sm);color:var(--c-text-secondary);padding:var(--space-2) 0">Keine Orte in der Nähe gefunden.</p>';
|
||||
} else {
|
||||
sugEl.innerHTML = suggestions.map(s => `
|
||||
<button type="button" class="diary-location-suggestion"
|
||||
data-name="${UI.escape(s.name)}" data-lat="${s.lat}" data-lon="${s.lon}">
|
||||
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#${_sourceIcon(s.source)}"></use></svg>
|
||||
<span>${UI.escape(s.name)}</span>
|
||||
<small>${s.distance_m < 1000 ? s.distance_m + ' m' : (s.distance_m / 1000).toFixed(1) + ' km'}</small>
|
||||
</button>`).join('');
|
||||
sugEl.querySelectorAll('.diary-location-suggestion').forEach(el => {
|
||||
el.addEventListener('click', () => _setName(el.dataset.name));
|
||||
});
|
||||
}
|
||||
sugEl.style.display = '';
|
||||
} catch (err) {
|
||||
UI.toast.error(err?.message?.includes('GPS') || lat == null
|
||||
? 'GPS nicht verfügbar.' : 'Ortssuche fehlgeschlagen.');
|
||||
} finally {
|
||||
UI.setLoading(btn, false);
|
||||
}
|
||||
}
|
||||
|
||||
document.getElementById('diary-location-btn')?.addEventListener('click', _showSuggestions);
|
||||
if (_locLat != null) _diaryPicker.setValue(_locLat, _locLon, _locName);
|
||||
}, 50);
|
||||
|
||||
document.getElementById('diary-form-delete')?.addEventListener('click', async () => {
|
||||
const ok = await UI.modal.confirm({
|
||||
|
|
|
|||
|
|
@ -640,6 +640,17 @@ function _fmtDate(iso) {
|
|||
} catch (err) { UI.toast.error(err.message); }
|
||||
});
|
||||
|
||||
// Liker-Liste anzeigen (Klick auf die Zahl)
|
||||
const _thLikeCount = document.getElementById('thread-like-count');
|
||||
if (_thLikeCount) {
|
||||
_thLikeCount.style.cursor = 'pointer';
|
||||
_thLikeCount.title = 'Wer hat geliked?';
|
||||
_thLikeCount.addEventListener('click', e => {
|
||||
e.stopPropagation();
|
||||
if ((thread.likes || 0) > 0) _showLikers('thread', thread.id);
|
||||
});
|
||||
}
|
||||
|
||||
// Report thread
|
||||
document.getElementById('thread-report-btn')?.addEventListener('click', () => {
|
||||
_showReportForm('thread', thread.id);
|
||||
|
|
@ -812,9 +823,9 @@ function _fmtDate(iso) {
|
|||
// Like
|
||||
container.querySelectorAll('.forum-post-like:not([data-bound])').forEach(btn => {
|
||||
btn.dataset.bound = '1';
|
||||
const postId = parseInt(btn.dataset.postId);
|
||||
btn.addEventListener('click', async () => {
|
||||
if (!uid) { UI.toast.info('Bitte erst anmelden.'); return; }
|
||||
const postId = parseInt(btn.dataset.postId);
|
||||
try {
|
||||
const res = await API.forum.like('post', postId);
|
||||
btn.classList.toggle('active', res.liked);
|
||||
|
|
@ -822,6 +833,16 @@ function _fmtDate(iso) {
|
|||
if (countEl) countEl.textContent = res.count;
|
||||
} catch (err) { UI.toast.error(err.message); }
|
||||
});
|
||||
// Klick auf die Zahl → Liker-Liste
|
||||
const countEl = btn.querySelector('.forum-post-like-count');
|
||||
if (countEl) {
|
||||
countEl.style.cursor = 'pointer';
|
||||
countEl.title = 'Wer hat geliked?';
|
||||
countEl.addEventListener('click', e => {
|
||||
e.stopPropagation();
|
||||
if (parseInt(countEl.textContent) > 0) _showLikers('post', postId);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Report
|
||||
|
|
@ -874,6 +895,28 @@ function _fmtDate(iso) {
|
|||
});
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------
|
||||
// Liker-Liste — wer hat geliked?
|
||||
// ----------------------------------------------------------
|
||||
async function _showLikers(targetType, targetId) {
|
||||
try {
|
||||
const likers = await API.forum.likers(targetType, targetId);
|
||||
if (!likers.length) { UI.toast.info('Noch keine Likes.'); return; }
|
||||
const rows = likers.map(l => `
|
||||
<div style="display:flex;align-items:center;gap:var(--space-2);padding:var(--space-2) 0;border-bottom:1px solid var(--c-border-light)">
|
||||
<div class="forum-avatar forum-avatar--sm">${UI.escape(_initial(l.name))}</div>
|
||||
<span style="font-size:0.9rem">${UI.escape(l.name || 'Unbekannt')}</span>
|
||||
${l.founder_number ? `<span style="font-size:10px;font-weight:700;background:#7c3aed;color:#fff;padding:1px 5px;border-radius:4px;margin-left:auto">Gründer #${l.founder_number}</span>` : ''}
|
||||
</div>`).join('');
|
||||
UI.modal.open({
|
||||
title: `${UI.icon('heart')} ${likers.length} ${likers.length === 1 ? 'Like' : 'Likes'}`,
|
||||
body: `<div style="max-height:50vh;overflow-y:auto">${rows}</div>`,
|
||||
footer: `<button type="button" class="btn btn-secondary w-full" id="likers-close">Schließen</button>`,
|
||||
});
|
||||
document.getElementById('likers-close')?.addEventListener('click', UI.modal.close);
|
||||
} catch (err) { UI.toast.error(err.message); }
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------
|
||||
// Report-Formular
|
||||
// ----------------------------------------------------------
|
||||
|
|
|
|||
|
|
@ -59,6 +59,7 @@ window.Page_map = (() => {
|
|||
treffpunkt: [],
|
||||
community: [],
|
||||
zuechter: [],
|
||||
hotel: [],
|
||||
};
|
||||
|
||||
const VISIBLE_KEY = 'by_map_visible_v1';
|
||||
|
|
@ -130,6 +131,10 @@ window.Page_map = (() => {
|
|||
interactive: false,
|
||||
};
|
||||
|
||||
// Orts-Suche
|
||||
let _searchTimer = null;
|
||||
let _searchMarker = null;
|
||||
|
||||
let _overpassTimer = null;
|
||||
let _overpassActive = false;
|
||||
let _ringClosing = false;
|
||||
|
|
@ -210,13 +215,50 @@ window.Page_map = (() => {
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<div class="map-fabs">
|
||||
<button class="map-fab map-fab--pin" id="map-pin-btn" title="Marker setzen"><svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#push-pin"></use></svg></button>
|
||||
${App.hasPro(_appState?.user) ? `
|
||||
<button class="map-fab" id="map-radar-btn" title="Regenradar ein-/ausblenden"><svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#cloud-rain"></use></svg></button>
|
||||
<button class="map-fab" id="map-temp-btn" title="Temperatur ein-/ausblenden"><svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#thermometer"></use></svg></button>
|
||||
` : ''}
|
||||
<button class="map-fab" id="map-locate-btn" title="Meinen Standort"><svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#map-pin"></use></svg></button>
|
||||
<!-- Orts-Suche Panel (von oben einschiebend, geschlossen per default) -->
|
||||
<div class="map-search-wrap" id="map-search-wrap">
|
||||
<div class="map-search-row">
|
||||
<svg class="ph-icon" aria-hidden="true" style="width:16px;height:16px;flex-shrink:0;color:#888"><use href="/icons/phosphor.svg#magnifying-glass"></use></svg>
|
||||
<input type="search" id="map-search-input" class="map-search-input"
|
||||
placeholder="Ort oder Adresse…" autocomplete="off" autocorrect="off" spellcheck="false">
|
||||
<button class="map-search-clear" id="map-search-clear" aria-label="Suche schließen">
|
||||
<svg class="ph-icon" aria-hidden="true" style="width:14px;height:14px"><use href="/icons/phosphor.svg#x"></use></svg>
|
||||
</button>
|
||||
</div>
|
||||
<div class="map-search-results" id="map-search-results" style="display:none"></div>
|
||||
</div>
|
||||
|
||||
<!-- Speed Dial -->
|
||||
<div class="map-speed-dial" id="map-speed-dial">
|
||||
<div class="map-sd-items">
|
||||
<!-- DOM-Reihenfolge = Aufklappreihenfolge von unten nach oben -->
|
||||
<div class="map-sd-item">
|
||||
<span class="map-sd-label">Mein Standort</span>
|
||||
<button class="map-sd-btn" id="map-locate-btn" title="Mein Standort"><svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#map-pin"></use></svg></button>
|
||||
</div>
|
||||
<div class="map-sd-item">
|
||||
<span class="map-sd-label">Ort suchen</span>
|
||||
<button class="map-sd-btn" id="map-search-btn" title="Ort suchen"><svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#magnifying-glass"></use></svg></button>
|
||||
</div>
|
||||
<div class="map-sd-item">
|
||||
<span class="map-sd-label">Marker setzen</span>
|
||||
<button class="map-sd-btn map-fab--pin" id="map-pin-btn" title="Marker setzen"><svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#push-pin"></use></svg></button>
|
||||
</div>
|
||||
${App.hasPro(_appState?.user) ? `
|
||||
<div class="map-sd-item">
|
||||
<span class="map-sd-label">Regenradar</span>
|
||||
<button class="map-sd-btn" id="map-radar-btn" title="Regenradar"><svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#cloud-rain"></use></svg></button>
|
||||
</div>
|
||||
<div class="map-sd-item">
|
||||
<span class="map-sd-label">Temperatur</span>
|
||||
<button class="map-sd-btn" id="map-temp-btn" title="Temperatur"><svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#thermometer"></use></svg></button>
|
||||
</div>
|
||||
` : ''}
|
||||
</div>
|
||||
<button class="map-fab map-sd-trigger" id="map-sd-trigger" title="Karten-Aktionen">
|
||||
<svg class="ph-icon map-sd-icon-open" aria-hidden="true"><use href="/icons/phosphor.svg#dots-three-vertical"></use></svg>
|
||||
<svg class="ph-icon map-sd-icon-close" aria-hidden="true"><use href="/icons/phosphor.svg#x"></use></svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="map-statusbar" id="map-statusbar">
|
||||
|
|
@ -289,7 +331,19 @@ window.Page_map = (() => {
|
|||
_saveVisible();
|
||||
});
|
||||
|
||||
// Speed Dial
|
||||
const _sdEl = document.getElementById('map-speed-dial');
|
||||
document.getElementById('map-sd-trigger')?.addEventListener('click', e => {
|
||||
e.stopPropagation();
|
||||
_sdEl?.classList.toggle('open');
|
||||
});
|
||||
// Klick auf Karte / außerhalb schließt Speed Dial
|
||||
document.getElementById('central-map')?.addEventListener('pointerdown', () => {
|
||||
_sdEl?.classList.remove('open');
|
||||
});
|
||||
|
||||
document.getElementById('map-locate-btn').addEventListener('click', () => {
|
||||
_sdEl?.classList.remove('open');
|
||||
if (_userPos) {
|
||||
_map?.setView([_userPos.lat, _userPos.lon], 16);
|
||||
} else {
|
||||
|
|
@ -297,9 +351,54 @@ window.Page_map = (() => {
|
|||
}
|
||||
});
|
||||
|
||||
document.getElementById('map-pin-btn').addEventListener('click', _togglePlacementMode);
|
||||
document.getElementById('map-radar-btn')?.addEventListener('click', _toggleRadar);
|
||||
document.getElementById('map-temp-btn')?.addEventListener('click', _toggleTemp);
|
||||
document.getElementById('map-pin-btn').addEventListener('click', () => {
|
||||
_sdEl?.classList.remove('open');
|
||||
_togglePlacementMode();
|
||||
});
|
||||
document.getElementById('map-radar-btn')?.addEventListener('click', () => {
|
||||
_sdEl?.classList.remove('open');
|
||||
_toggleRadar();
|
||||
});
|
||||
document.getElementById('map-temp-btn')?.addEventListener('click', () => {
|
||||
_sdEl?.classList.remove('open');
|
||||
_toggleTemp();
|
||||
});
|
||||
|
||||
// Suche — FAB öffnet Panel
|
||||
document.getElementById('map-search-btn')?.addEventListener('click', () => {
|
||||
document.getElementById('map-speed-dial')?.classList.remove('open');
|
||||
const wrap = document.getElementById('map-search-wrap');
|
||||
const isOpen = wrap?.classList.contains('active');
|
||||
if (isOpen) {
|
||||
_clearSearch();
|
||||
} else {
|
||||
wrap?.classList.add('active');
|
||||
setTimeout(() => document.getElementById('map-search-input')?.focus(), 60);
|
||||
document.getElementById('map-search-btn')?.classList.add('active');
|
||||
}
|
||||
});
|
||||
|
||||
const searchInput = document.getElementById('map-search-input');
|
||||
const searchResults = document.getElementById('map-search-results');
|
||||
|
||||
searchInput?.addEventListener('input', () => {
|
||||
const q = searchInput.value.trim();
|
||||
clearTimeout(_searchTimer);
|
||||
if (q.length < 2) { searchResults.style.display = 'none'; return; }
|
||||
_searchTimer = setTimeout(() => _runSearch(q), 400);
|
||||
});
|
||||
|
||||
searchInput?.addEventListener('keydown', e => {
|
||||
if (e.key === 'Escape') _clearSearch();
|
||||
});
|
||||
|
||||
document.getElementById('map-search-clear')?.addEventListener('click', _clearSearch);
|
||||
|
||||
// Klick auf Karte schließt Ergebnisse (aber behält Marker)
|
||||
document.getElementById('central-map')?.addEventListener('pointerdown', () => {
|
||||
searchResults.style.display = 'none';
|
||||
searchInput?.blur();
|
||||
});
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------
|
||||
|
|
@ -907,7 +1006,7 @@ window.Page_map = (() => {
|
|||
const params = new URLSearchParams({ type: osmType, ...bbox });
|
||||
try {
|
||||
const pois = await fetch(`/api/osm/pois?${params}`).then(r => r.json());
|
||||
const osmCount = _layers[layerKey].filter(m => !m._ownPlace).length;
|
||||
const osmCount = (_layers[layerKey] || []).filter(m => !m._ownPlace).length;
|
||||
if (pois.length !== osmCount) _replaceOsmMarkers(layerKey, pois);
|
||||
_done++;
|
||||
const pct = Math.round(20 + _done / _total * 80);
|
||||
|
|
@ -919,11 +1018,14 @@ window.Page_map = (() => {
|
|||
const pct = Math.round(20 + _done / _total * 80);
|
||||
const total = Object.values(_layers).flat().filter(m => !m._ownPlace).length;
|
||||
_setOsmStatus(pct < 100 ? `Scanne…` : `${total} Marker`, pct);
|
||||
return _layers[layerKey].filter(m => !m._ownPlace).length;
|
||||
return (_layers[layerKey] || []).filter(m => !m._ownPlace).length;
|
||||
}
|
||||
});
|
||||
await Promise.all(freshTasks);
|
||||
_overpassActive = false;
|
||||
try {
|
||||
await Promise.all(freshTasks);
|
||||
} finally {
|
||||
_overpassActive = false;
|
||||
}
|
||||
|
||||
const totalLoaded = Object.values(_layers).flat().filter(m => !m._ownPlace).length;
|
||||
const allHidden = Object.keys(OSM_LAYER_MAP).every(k => _visible[k] === false);
|
||||
|
|
@ -931,10 +1033,13 @@ window.Page_map = (() => {
|
|||
_setOsmStatus('Layer deaktiviert — Liste antippen', 100);
|
||||
}
|
||||
|
||||
// Wenn 0 OSM-Marker: Hintergrund-Fetch läuft noch — max 3× automatisch nachfragen
|
||||
if (totalLoaded === 0 && zoom >= 14 && _autoRetryCount < 3) {
|
||||
// Wenn 0 OSM-Marker: Hintergrund-Overpass-Fetch läuft noch — bis zu 8× nachfragen
|
||||
// Overpass für alle Layer sequential: bis zu ~4min → Retries müssen das abdecken
|
||||
if (totalLoaded === 0 && zoom >= 14 && _autoRetryCount < 8) {
|
||||
_autoRetryCount++;
|
||||
const delay = _autoRetryCount * 30000; // 30s, 60s, 90s
|
||||
// 10s, 20s, 35s, 50s, 70s, 90s, 120s, 150s
|
||||
const delays = [10000, 20000, 35000, 50000, 70000, 90000, 120000, 150000];
|
||||
const delay = delays[_autoRetryCount - 1] || 120000;
|
||||
_setOsmStatus(`Neue Umgebung – Daten werden geladen…`);
|
||||
setTimeout(() => { if (!_overpassActive) _scheduleOsmLoad(); }, delay);
|
||||
}
|
||||
|
|
@ -1944,6 +2049,92 @@ window.Page_map = (() => {
|
|||
} catch { /* still */ }
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------
|
||||
// Orts-Suche (Nominatim-Proxy)
|
||||
// ----------------------------------------------------------
|
||||
async function _runSearch(q) {
|
||||
const resultsEl = document.getElementById('map-search-results');
|
||||
if (!resultsEl) return;
|
||||
resultsEl.innerHTML = '<div class="map-search-loading">Suche…</div>';
|
||||
resultsEl.style.display = '';
|
||||
try {
|
||||
const data = await API.get(`/osm/geocode?q=${encodeURIComponent(q)}`);
|
||||
if (!data.length) {
|
||||
resultsEl.innerHTML = '<div class="map-search-empty">Keine Ergebnisse</div>';
|
||||
return;
|
||||
}
|
||||
resultsEl.innerHTML = data.map((r, i) =>
|
||||
`<div class="map-search-item" data-i="${i}">
|
||||
<div class="map-search-item-name">${UI.escape(r.name)}</div>
|
||||
${r.subtitle ? `<div class="map-search-item-sub">${UI.escape(r.subtitle)}</div>` : ''}
|
||||
</div>`
|
||||
).join('');
|
||||
resultsEl.querySelectorAll('.map-search-item').forEach(el => {
|
||||
el.addEventListener('pointerdown', e => {
|
||||
e.stopPropagation();
|
||||
const r = data[+el.dataset.i];
|
||||
_flyToResult(r);
|
||||
document.getElementById('map-search-input').value = r.name;
|
||||
document.getElementById('map-search-clear').style.display = '';
|
||||
resultsEl.style.display = 'none';
|
||||
});
|
||||
});
|
||||
} catch {
|
||||
resultsEl.innerHTML = '<div class="map-search-empty">Suche nicht verfügbar</div>';
|
||||
}
|
||||
}
|
||||
|
||||
function _flyToResult(r) {
|
||||
if (!_map || !window.L) return;
|
||||
_searchMarker?.remove();
|
||||
_map.flyTo([r.lat, r.lon], 15, { duration: 1.0 });
|
||||
_searchMarker = L.marker([r.lat, r.lon], {
|
||||
icon: L.divIcon({
|
||||
className: '',
|
||||
html: `<div style="background:#C4843A;color:#fff;font-size:15px;
|
||||
width:32px;height:32px;border-radius:50% 50% 50% 0;transform:rotate(-45deg);
|
||||
display:flex;align-items:center;justify-content:center;
|
||||
box-shadow:0 2px 8px rgba(0,0,0,0.4)">
|
||||
<span style="transform:rotate(45deg)">
|
||||
<svg style="width:16px;height:16px" viewBox="0 0 256 256" fill="currentColor">
|
||||
<path d="M128,16a96,96,0,1,0,96,96A96.11,96.11,0,0,0,128,16Zm0,48a32,32,0,1,1-32,32A32,32,0,0,1,128,64Zm0,144a80,80,0,0,1-56.37-23.37C74.18,170.06,98.65,160,128,160s53.82,10.06,56.37,24.63A80,80,0,0,1,128,208Z"/>
|
||||
</svg>
|
||||
</span></div>`,
|
||||
iconSize: [32, 32],
|
||||
iconAnchor: [16, 32],
|
||||
}),
|
||||
zIndexOffset: 1000,
|
||||
})
|
||||
.addTo(_map)
|
||||
.bindPopup(`<div style="font-size:13px;font-weight:600">${UI.escape(r.name)}</div>
|
||||
${r.subtitle ? `<div style="font-size:11px;color:#888">${UI.escape(r.subtitle)}</div>` : ''}
|
||||
<button class="btn btn-secondary btn-sm" id="search-marker-close" style="margin-top:8px">
|
||||
Marker entfernen
|
||||
</button>`, { maxWidth: 240 })
|
||||
.openPopup();
|
||||
|
||||
setTimeout(() => {
|
||||
document.getElementById('search-marker-close')?.addEventListener('click', () => {
|
||||
_clearSearch();
|
||||
_searchMarker?.closePopup();
|
||||
});
|
||||
}, 50);
|
||||
}
|
||||
|
||||
function _clearSearch() {
|
||||
const input = document.getElementById('map-search-input');
|
||||
const results = document.getElementById('map-search-results');
|
||||
const wrap = document.getElementById('map-search-wrap');
|
||||
const btn = document.getElementById('map-search-btn');
|
||||
if (input) { input.value = ''; input.blur(); }
|
||||
if (results) results.style.display = 'none';
|
||||
wrap?.classList.remove('active');
|
||||
btn?.classList.remove('active');
|
||||
_searchMarker?.remove();
|
||||
_searchMarker = null;
|
||||
clearTimeout(_searchTimer);
|
||||
}
|
||||
|
||||
return { init, refresh, onDogChange, startRecording: _startRecording, stopRecording: _stopRecording, isRecording: () => _recActive };
|
||||
|
||||
})();
|
||||
|
|
|
|||
|
|
@ -1698,6 +1698,10 @@ window.Page_routes = (() => {
|
|||
center: [mid.lat, mid.lon], zoom: 15,
|
||||
zoomControl: false, attributionControl: false,
|
||||
});
|
||||
// Container hat im frisch eingefügten Fixed-Overlay erst jetzt seine
|
||||
// finale Flex-Höhe — Leaflet muss sie neu vermessen, sonst lädt es nur
|
||||
// oben Tiles und der Rest bleibt grau.
|
||||
_navMap.invalidateSize();
|
||||
|
||||
// Route-Polylines: erledigt (grün) + ausstehend (orange)
|
||||
const doneLine = L.polyline([], { color: '#22c55e', weight: 5, opacity: 0.85 }).addTo(_navMap);
|
||||
|
|
@ -1705,6 +1709,14 @@ window.Page_routes = (() => {
|
|||
_navMap.fitBounds(remainLine.getBounds(), { padding: [20, 20] });
|
||||
_addRouteArrows(_navMap, track, '#3b82f6');
|
||||
|
||||
// iOS rendert das Flex-Layout teils verzögert — nochmal neu vermessen
|
||||
// und Ausschnitt erneut anpassen.
|
||||
setTimeout(() => {
|
||||
if (!_navMap) return;
|
||||
_navMap.invalidateSize();
|
||||
_navMap.fitBounds(remainLine.getBounds(), { padding: [20, 20] });
|
||||
}, 250);
|
||||
|
||||
// Start/End-Marker (als Variable damit Reverse sie neu setzen kann)
|
||||
const mkPin = (p, color) => L.circleMarker([p.lat, p.lon], {
|
||||
radius: 8, color: '#fff', weight: 2, fillColor: color, fillOpacity: 1
|
||||
|
|
|
|||
|
|
@ -672,6 +672,13 @@ window.Page_settings = (() => {
|
|||
</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-body" style="padding:0">
|
||||
<div class="sidebar-item" data-page="dog-profile"
|
||||
|
|
@ -925,6 +932,54 @@ window.Page_settings = (() => {
|
|||
});
|
||||
}).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)
|
||||
API.get('/achievements/me').then(a => {
|
||||
const statsEl = document.getElementById('settings-stats-body');
|
||||
|
|
|
|||
|
|
@ -897,8 +897,6 @@ window.Page_walks = (() => {
|
|||
let _locLon = v.lon != null ? parseFloat(v.lon) : null;
|
||||
let _locName = v.ort_name || null;
|
||||
|
||||
const _pinSvg = '<svg xmlns="http://www.w3.org/2000/svg" width="28" height="36" viewBox="0 0 32 40"><path d="M16 0C7.163 0 0 7.163 0 16c0 10 16 24 16 24S32 26 32 16C32 7.163 24.837 0 16 0z" fill="#C4843A"/><circle cx="16" cy="16" r="7" fill="white"/></svg>';
|
||||
|
||||
const body = `
|
||||
<form id="walk-form" autocomplete="off">
|
||||
|
||||
|
|
@ -924,48 +922,7 @@ window.Page_walks = (() => {
|
|||
|
||||
<div class="form-group" id="wf-location-group">
|
||||
<label class="form-label">Treffpunkt</label>
|
||||
|
||||
<!-- Mini-Karte -->
|
||||
<div style="position:relative">
|
||||
<div id="wf-map-wrap" style="border-radius:var(--radius-md);overflow:hidden;height:200px;background:var(--c-surface-2)"></div>
|
||||
<button type="button" id="wf-map-pin-here" style="
|
||||
position:absolute;bottom:10px;left:50%;transform:translateX(-50%);
|
||||
z-index:1000;background:var(--c-primary);color:#fff;border:none;
|
||||
border-radius:var(--radius-full);padding:6px 14px;font-size:var(--text-xs);
|
||||
font-weight:600;box-shadow:var(--shadow-md);cursor:pointer;
|
||||
display:flex;align-items:center;gap:6px;white-space:nowrap">
|
||||
${UI.icon('map-pin')} Pin hier setzen
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Ort-Chip -->
|
||||
<div class="mt-2">
|
||||
<div id="wf-location-chip-wrap" style="${_locName ? '' : 'display:none'}">
|
||||
<div class="diary-location-chip">
|
||||
${UI.icon('map-pin')}
|
||||
<span id="wf-location-label">${UI.escape(_locName || '')}</span>
|
||||
<button type="button" id="wf-location-clear" aria-label="Name entfernen">
|
||||
${UI.icon('x')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style="display:flex;gap:var(--space-2);margin-top:var(--space-2)">
|
||||
<button type="button" class="btn btn-danger btn-sm" id="wf-coords-clear">Ort entfernen</button>
|
||||
<button type="button" class="btn btn-secondary flex-1" id="wf-location-btn">
|
||||
${UI.icon('map-pin')}
|
||||
<span id="wf-location-btn-label">${_locLat ? 'POI suchen' : 'GPS → POI suchen'}</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Vorschläge -->
|
||||
<div id="wf-location-suggestions" style="display:none;margin-top:var(--space-2)"></div>
|
||||
</div>
|
||||
|
||||
<!-- Versteckte Koordinaten-Felder -->
|
||||
<input type="hidden" name="lat" id="wf-lat" value="${_locLat || ''}">
|
||||
<input type="hidden" name="lon" id="wf-lon" value="${_locLon || ''}">
|
||||
<input type="hidden" name="ort_name" id="wf-ort-name" value="${UI.escape(_locName || '')}">
|
||||
<div id="wf-location-picker"></div>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
|
|
@ -996,157 +953,16 @@ window.Page_walks = (() => {
|
|||
|
||||
document.getElementById('wf-cancel')?.addEventListener('click', UI.modal.close);
|
||||
|
||||
// --- Mini-Karte ---
|
||||
let _miniMap = null, _miniMarker = null, _mapEditing = false;
|
||||
|
||||
const _mkIcon = () => L.divIcon({ html: _pinSvg, className: '', iconSize: [28, 36], iconAnchor: [14, 36] });
|
||||
|
||||
function _placeMarker(lat, lon) {
|
||||
if (_miniMarker) { _miniMarker.setLatLng([lat, lon]); return; }
|
||||
_miniMarker = L.marker([lat, lon], { draggable: true, icon: _mkIcon() }).addTo(_miniMap);
|
||||
_miniMarker.on('dragend', () => {
|
||||
const p = _miniMarker.getLatLng();
|
||||
_locLat = p.lat; _locLon = p.lng;
|
||||
document.getElementById('wf-lat').value = _locLat;
|
||||
document.getElementById('wf-lon').value = _locLon;
|
||||
document.getElementById('wf-location-btn-label').textContent = 'POI suchen';
|
||||
// Location Picker
|
||||
let _wfPicker = null;
|
||||
setTimeout(() => {
|
||||
_wfPicker = UI.locationPicker({
|
||||
containerId: 'wf-location-picker',
|
||||
onSelect: (lat, lon, name) => { _locLat = lat; _locLon = lon; _locName = name; },
|
||||
});
|
||||
}
|
||||
if (_locLat != null) _wfPicker.setValue(_locLat, _locLon, _locName);
|
||||
}, 50);
|
||||
|
||||
function _setCoords(lat, lon) {
|
||||
_locLat = lat; _locLon = lon;
|
||||
document.getElementById('wf-lat').value = lat;
|
||||
document.getElementById('wf-lon').value = lon;
|
||||
}
|
||||
|
||||
function _setName(name) {
|
||||
_locName = name;
|
||||
document.getElementById('wf-location-label').textContent = name;
|
||||
document.getElementById('wf-location-chip-wrap').style.display = '';
|
||||
document.getElementById('wf-ort-name').value = name;
|
||||
document.getElementById('wf-location-suggestions').style.display = 'none';
|
||||
}
|
||||
|
||||
UI.loadLeaflet().then(() => {
|
||||
setTimeout(() => {
|
||||
const lat = _locLat || 48.0, lon = _locLon || 11.9, zoom = _locLat ? 15 : 7;
|
||||
_miniMap = L.map('wf-map-wrap', {
|
||||
zoomControl: true, attributionControl: false,
|
||||
dragging: true, scrollWheelZoom: false,
|
||||
}).setView([lat, lon], zoom);
|
||||
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', { maxZoom: 19 })
|
||||
.addTo(_miniMap);
|
||||
_miniMap.invalidateSize();
|
||||
if (_locLat) _placeMarker(lat, lon);
|
||||
_miniMap.on('click', e => {
|
||||
_setCoords(e.latlng.lat, e.latlng.lng);
|
||||
_placeMarker(_locLat, _locLon);
|
||||
document.getElementById('wf-location-btn-label').textContent = 'POI suchen';
|
||||
});
|
||||
document.getElementById('wf-map-pin-here')?.addEventListener('click', () => {
|
||||
const c = _miniMap.getCenter();
|
||||
_setCoords(c.lat, c.lng);
|
||||
_placeMarker(c.lat, c.lng);
|
||||
document.getElementById('wf-location-btn-label').textContent = 'POI suchen';
|
||||
});
|
||||
}, 150);
|
||||
});
|
||||
|
||||
// Ort-Name-Chip entfernen
|
||||
document.getElementById('wf-location-clear')?.addEventListener('click', () => {
|
||||
_locName = null;
|
||||
document.getElementById('wf-location-chip-wrap').style.display = 'none';
|
||||
document.getElementById('wf-ort-name').value = '';
|
||||
});
|
||||
|
||||
// Koordinaten + Name entfernen (Zwei-Klick)
|
||||
const clearBtn = document.getElementById('wf-coords-clear');
|
||||
let _clearPending = false;
|
||||
clearBtn?.addEventListener('click', () => {
|
||||
if (!_clearPending) {
|
||||
_clearPending = true;
|
||||
clearBtn.textContent = 'Wirklich entfernen?';
|
||||
clearBtn.style.color = 'var(--c-danger)';
|
||||
setTimeout(() => {
|
||||
_clearPending = false;
|
||||
if (clearBtn) {
|
||||
clearBtn.textContent = 'Ort entfernen';
|
||||
clearBtn.style.color = '';
|
||||
}
|
||||
}, 3000);
|
||||
return;
|
||||
}
|
||||
_clearPending = false;
|
||||
clearBtn.textContent = 'Ort entfernen';
|
||||
clearBtn.style.color = '';
|
||||
_locLat = null; _locLon = null; _locName = null;
|
||||
document.getElementById('wf-lat').value = '';
|
||||
document.getElementById('wf-lon').value = '';
|
||||
document.getElementById('wf-ort-name').value = '';
|
||||
document.getElementById('wf-location-chip-wrap').style.display = 'none';
|
||||
document.getElementById('wf-location-suggestions').style.display = 'none';
|
||||
document.getElementById('wf-location-btn-label').textContent = 'GPS → POI suchen';
|
||||
if (_miniMarker) { _miniMarker.remove(); _miniMarker = null; }
|
||||
if (_miniMap) { _miniMap.setView([48.0, 11.9], 7); }
|
||||
});
|
||||
|
||||
// GPS → POI-Suche (wie diary.js)
|
||||
async function _showSuggestions() {
|
||||
const btn = document.getElementById('wf-location-btn');
|
||||
UI.setLoading(btn, true);
|
||||
try {
|
||||
let lat = _locLat, lon = _locLon;
|
||||
if (lat == null || lon == null) {
|
||||
const pos = await API.getLocation({ enableHighAccuracy: true });
|
||||
lat = pos.lat; lon = pos.lon;
|
||||
_setCoords(lat, lon);
|
||||
if (_miniMap) {
|
||||
_miniMap.setView([lat, lon], 15);
|
||||
_placeMarker(lat, lon);
|
||||
if (_miniMarker) _miniMarker.dragging.disable();
|
||||
}
|
||||
document.getElementById('wf-location-btn-label').textContent = 'POI suchen';
|
||||
}
|
||||
|
||||
const suggestions = _appState.user
|
||||
? await API.walks.nearby(lat, lon)
|
||||
: [];
|
||||
|
||||
const sugEl = document.getElementById('wf-location-suggestions');
|
||||
if (!suggestions.length) {
|
||||
sugEl.innerHTML = '<p style="font-size:var(--text-sm);color:var(--c-text-secondary);padding:var(--space-2) 0">Keine Orte in der Nähe gefunden.</p>';
|
||||
} else {
|
||||
sugEl.innerHTML = suggestions.map(s => `
|
||||
<button type="button" class="diary-location-suggestion"
|
||||
data-name="${UI.escape(s.name)}" data-lat="${s.lat}" data-lon="${s.lon}">
|
||||
${UI.icon(_sourceIcon(s.source))}
|
||||
<span>${UI.escape(s.name)}</span>
|
||||
<small>${s.distance_m < 1000 ? s.distance_m + ' m' : (s.distance_m / 1000).toFixed(1) + ' km'}</small>
|
||||
</button>`).join('');
|
||||
sugEl.querySelectorAll('.diary-location-suggestion').forEach(el => {
|
||||
el.addEventListener('click', () => {
|
||||
const slat = parseFloat(el.dataset.lat);
|
||||
const slon = parseFloat(el.dataset.lon);
|
||||
_setCoords(slat, slon);
|
||||
_setName(el.dataset.name);
|
||||
if (_miniMap) {
|
||||
_miniMap.setView([slat, slon], 16);
|
||||
_placeMarker(slat, slon);
|
||||
if (_miniMarker) _miniMarker.dragging.disable();
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
sugEl.style.display = '';
|
||||
} catch (err) {
|
||||
UI.toast.error(err?.message?.includes('GPS') || _locLat == null
|
||||
? 'GPS nicht verfügbar.' : 'Ortssuche fehlgeschlagen.');
|
||||
} finally {
|
||||
UI.setLoading(btn, false);
|
||||
}
|
||||
}
|
||||
|
||||
document.getElementById('wf-location-btn')?.addEventListener('click', _showSuggestions);
|
||||
|
||||
// Formular absenden
|
||||
document.getElementById('walk-form')?.addEventListener('submit', async e => {
|
||||
|
|
|
|||
|
|
@ -453,6 +453,10 @@ const UI = (() => {
|
|||
const isDark = document.documentElement.dataset.theme === 'dark';
|
||||
if (isDark) tiles.getContainer().style.filter = 'brightness(0.7) invert(1) contrast(0.9) hue-rotate(200deg)';
|
||||
}
|
||||
// Safety-Net: Container-Größe nach Layout neu vermessen. Verhindert
|
||||
// grau bleibende Bereiche wenn die Karte vor dem finalen Layout erstellt
|
||||
// wird (z.B. in frisch eingefügten Overlays mit flex:1).
|
||||
requestAnimationFrame(() => m.invalidateSize());
|
||||
return m;
|
||||
},
|
||||
|
||||
|
|
@ -873,12 +877,35 @@ const UI = (() => {
|
|||
coordsClear: `${p}-coords-clear`,
|
||||
suggestions: `${p}-suggestions`,
|
||||
pinHere: `${p}-pin-here`,
|
||||
geoInput: `${p}-geo-input`,
|
||||
geoClear: `${p}-geo-clear`,
|
||||
geoResults: `${p}-geo-results`,
|
||||
};
|
||||
|
||||
// HTML in den Container rendern
|
||||
function _render(container) {
|
||||
container.innerHTML = `
|
||||
<div style="position:relative">
|
||||
<!-- Geocoding-Suchfeld als Overlay oben — left:46px lässt Zoom-Control frei -->
|
||||
<div style="position:absolute;top:8px;left:46px;right:8px;z-index:1001">
|
||||
<div style="display:flex;align-items:center;gap:7px;background:rgba(255,255,255,0.96);
|
||||
border-radius:var(--radius-full);padding:6px 11px;
|
||||
box-shadow:0 2px 8px rgba(0,0,0,0.22);backdrop-filter:blur(4px)">
|
||||
<svg class="ph-icon" aria-hidden="true" style="width:14px;height:14px;flex-shrink:0;color:#aaa"><use href="/icons/phosphor.svg#magnifying-glass"></use></svg>
|
||||
<input type="search" id="${ids.geoInput}" placeholder="Ort oder Adresse suchen…"
|
||||
autocomplete="off" autocorrect="off" spellcheck="false"
|
||||
style="flex:1;border:none;outline:none;font-size:13px;background:transparent;
|
||||
font-family:inherit;color:var(--c-text);min-width:0">
|
||||
<button type="button" id="${ids.geoClear}" aria-label="Suche löschen"
|
||||
style="display:none;background:none;border:none;padding:2px;cursor:pointer;
|
||||
color:#bbb;line-height:1">
|
||||
<svg class="ph-icon" aria-hidden="true" style="width:13px;height:13px"><use href="/icons/phosphor.svg#x"></use></svg>
|
||||
</button>
|
||||
</div>
|
||||
<div id="${ids.geoResults}" style="display:none;background:rgba(255,255,255,0.98);
|
||||
border-radius:10px;box-shadow:0 4px 14px rgba(0,0,0,0.18);
|
||||
margin-top:5px;overflow:hidden;max-height:190px;overflow-y:auto"></div>
|
||||
</div>
|
||||
<div id="${ids.mapWrap}" style="border-radius:var(--radius-md);overflow:hidden;height:200px;background:var(--c-surface-2)"></div>
|
||||
<button type="button" id="${ids.pinHere}" style="
|
||||
position:absolute;bottom:10px;left:50%;transform:translateX(-50%);
|
||||
|
|
@ -1102,6 +1129,75 @@ const UI = (() => {
|
|||
}
|
||||
|
||||
_getEl(ids.locBtn)?.addEventListener('click', _showSuggestions);
|
||||
|
||||
// Geocoding-Suche
|
||||
let _geoTimer = null;
|
||||
const geoInput = _getEl(ids.geoInput);
|
||||
const geoClear = _getEl(ids.geoClear);
|
||||
const geoResults = _getEl(ids.geoResults);
|
||||
|
||||
geoInput?.addEventListener('input', () => {
|
||||
const q = geoInput.value.trim();
|
||||
if (geoClear) geoClear.style.display = q ? '' : 'none';
|
||||
clearTimeout(_geoTimer);
|
||||
if (q.length < 2) { if (geoResults) geoResults.style.display = 'none'; return; }
|
||||
_geoTimer = setTimeout(async () => {
|
||||
if (geoResults) {
|
||||
geoResults.innerHTML = '<div style="padding:9px 13px;font-size:12px;color:var(--c-text-secondary)">Suche…</div>';
|
||||
geoResults.style.display = '';
|
||||
}
|
||||
try {
|
||||
const data = await API.get(`/osm/geocode?q=${encodeURIComponent(q)}`);
|
||||
if (!geoResults) return;
|
||||
if (!data.length) {
|
||||
geoResults.innerHTML = '<div style="padding:9px 13px;font-size:12px;color:var(--c-text-secondary)">Keine Ergebnisse</div>';
|
||||
return;
|
||||
}
|
||||
geoResults.innerHTML = data.map((r, i) => `
|
||||
<div data-i="${i}" style="padding:9px 13px;cursor:pointer;border-bottom:1px solid rgba(0,0,0,0.05)">
|
||||
<div style="font-size:13px;font-weight:600;color:var(--c-text);white-space:nowrap;overflow:hidden;text-overflow:ellipsis">${escape(r.name)}</div>
|
||||
${r.subtitle ? `<div style="font-size:11px;color:var(--c-text-secondary)">${escape(r.subtitle)}</div>` : ''}
|
||||
</div>`).join('');
|
||||
geoResults.querySelectorAll('[data-i]').forEach(el => {
|
||||
el.addEventListener('pointerdown', e => {
|
||||
e.preventDefault();
|
||||
const r = data[+el.dataset.i];
|
||||
_setCoords(r.lat, r.lon);
|
||||
_setName(r.name);
|
||||
if (_map) {
|
||||
_map.flyTo([r.lat, r.lon], 15, { duration: 0.8 });
|
||||
_placeMarker(r.lat, r.lon);
|
||||
}
|
||||
const lbl = _getEl(ids.locBtnLabel);
|
||||
if (lbl) lbl.textContent = 'POI suchen';
|
||||
geoInput.value = '';
|
||||
if (geoClear) geoClear.style.display = 'none';
|
||||
geoResults.style.display = 'none';
|
||||
onSelect?.(_lat, _lon, _name);
|
||||
});
|
||||
});
|
||||
} catch {
|
||||
if (geoResults) geoResults.innerHTML = '<div style="padding:9px 13px;font-size:12px;color:var(--c-text-secondary)">Suche nicht verfügbar</div>';
|
||||
}
|
||||
}, 400);
|
||||
});
|
||||
|
||||
geoInput?.addEventListener('keydown', e => {
|
||||
if (e.key === 'Escape') {
|
||||
geoInput.value = '';
|
||||
if (geoClear) geoClear.style.display = 'none';
|
||||
if (geoResults) geoResults.style.display = 'none';
|
||||
}
|
||||
});
|
||||
geoClear?.addEventListener('click', () => {
|
||||
geoInput.value = '';
|
||||
geoClear.style.display = 'none';
|
||||
if (geoResults) geoResults.style.display = 'none';
|
||||
});
|
||||
_getEl(ids.mapWrap)?.addEventListener('pointerdown', () => {
|
||||
if (geoResults) geoResults.style.display = 'none';
|
||||
geoInput?.blur();
|
||||
});
|
||||
}
|
||||
|
||||
// Container initialisieren
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@
|
|||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<meta name="color-scheme" content="light dark">
|
||||
<script src="/js/landing-init.js?v=1141"></script>
|
||||
<script src="/js/landing-init.js?v=1155"></script>
|
||||
<title>Ban Yaro — Die Hunde-App für Deutschland, Österreich & Schweiz</title>
|
||||
<meta name="description" content="Ban Yaro: Die kostenlose All-in-One Hunde-App für DACH. Tagebuch, Giftköder-Alarm, Training mit KI, Forum, Wurfbörse, Stammbaum, Inzucht-Check — DSGVO-konform, offline-fähig, ohne App Store.">
|
||||
<meta name="keywords" content="Hunde App, Hunde Community, Wurfbörse, Züchter, Welpen kaufen, Stammbaum Hund, Inzuchtkoeffizient, Hundezucht, Impfpass Hund, Giftköder Alarm, Gassi Community, Hundetraining App, Hunde Forum, Hunde KI, Hundefilm Datenbank, Welpen Marktplatz">
|
||||
|
|
@ -149,6 +149,25 @@
|
|||
}
|
||||
</script>
|
||||
|
||||
<script type="application/ld+json">
|
||||
{
|
||||
"@context": "https://schema.org",
|
||||
"@type": "WebSite",
|
||||
"name": "Ban Yaro",
|
||||
"url": "https://banyaro.app",
|
||||
"description": "Die Hunde-App für Deutschland, Österreich und die Schweiz",
|
||||
"inLanguage": "de",
|
||||
"potentialAction": {
|
||||
"@type": "SearchAction",
|
||||
"target": {
|
||||
"@type": "EntryPoint",
|
||||
"urlTemplate": "https://banyaro.app/wiki/rassen?q={search_term_string}"
|
||||
},
|
||||
"query-input": "required name=search_term_string"
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style>
|
||||
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
|
||||
|
|
|
|||
|
|
@ -3,5 +3,10 @@ Disallow: /api/
|
|||
Disallow: /ausweis/
|
||||
Disallow: /teilen/
|
||||
Disallow: /media/
|
||||
Disallow: /force-update
|
||||
Disallow: /pass/
|
||||
Disallow: /widget
|
||||
Disallow: /litters
|
||||
Disallow: /?_t
|
||||
|
||||
Sitemap: https://banyaro.app/sitemap.xml
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@
|
|||
============================================================ */
|
||||
|
||||
// ← EINZIGE Stelle für die Version — STATIC_ASSETS und CACHE_VERSION leiten sich ab
|
||||
const VER = '1141';
|
||||
const VER = '1155';
|
||||
const CACHE_VERSION = `by-v${VER}`;
|
||||
const CACHE_STATIC = `${CACHE_VERSION}-static`;
|
||||
const CACHE_TILES = 'ban-yaro-tiles-v1'; // bleibt über SW-Updates erhalten
|
||||
|
|
|
|||
16
docker-compose.osm.yml
Normal file
16
docker-compose.osm.yml
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
# Monatlicher OSM-POI-Refresh (Build 4) — NICHT Teil des Default-Stacks.
|
||||
# Wird manuell oder vom DSM-Aufgabenplaner getriggert:
|
||||
# docker compose -f docker-compose.osm.yml run --rm osm-refresh
|
||||
# Schreibt in dieselbe SQLite-DB wie der App-Container (./data:/data).
|
||||
services:
|
||||
osm-refresh:
|
||||
build: ./tools/osm-extract
|
||||
image: banyaro-osm-refresh
|
||||
container_name: banyaro-osm-refresh
|
||||
mem_limit: 4g # Schutzschranke gegen die anderen Container
|
||||
volumes:
|
||||
- ./data:/data # gleiche DB wie die App (/data/banyaro.db)
|
||||
environment:
|
||||
- DB_PATH=/data/banyaro.db
|
||||
# - COUNTRIES=switzerland austria germany # bei Bedarf überschreiben
|
||||
restart: "no"
|
||||
BIN
flyer/flyer_a5_rueckseite.pdf
Normal file
BIN
flyer/flyer_a5_rueckseite.pdf
Normal file
Binary file not shown.
BIN
flyer/flyer_a5_vorderseite.pdf
Normal file
BIN
flyer/flyer_a5_vorderseite.pdf
Normal file
Binary file not shown.
292
promotion/flyer_a5_allgemein.html
Normal file
292
promotion/flyer_a5_allgemein.html
Normal file
File diff suppressed because one or more lines are too long
383
promotion/flyer_a5_rueckseite.html
Normal file
383
promotion/flyer_a5_rueckseite.html
Normal file
File diff suppressed because one or more lines are too long
BIN
promotion/flyer_a5_rueckseite.pdf
Normal file
BIN
promotion/flyer_a5_rueckseite.pdf
Normal file
Binary file not shown.
BIN
promotion/flyer_a5_vorderseite.pdf
Normal file
BIN
promotion/flyer_a5_vorderseite.pdf
Normal file
Binary file not shown.
5
tools/osm-extract/.gitignore
vendored
Normal file
5
tools/osm-extract/.gitignore
vendored
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
# Große/temporäre Datendateien — nie committen
|
||||
*.osm.pbf
|
||||
*.sqlite
|
||||
*.db
|
||||
*.log
|
||||
14
tools/osm-extract/Dockerfile
Normal file
14
tools/osm-extract/Dockerfile
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
FROM python:3.12-slim-bookworm
|
||||
|
||||
# osmium-tool = RAM-schonende tags-filter-Vorstufe (C++, streaming),
|
||||
# pyosmium = Extraktion + Schwerpunktberechnung.
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
osmium-tool curl ca-certificates \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
RUN pip install --no-cache-dir osmium
|
||||
|
||||
WORKDIR /app
|
||||
COPY extract_osm_pois.py load_into_prod.py refresh.sh /app/
|
||||
RUN chmod +x /app/refresh.sh
|
||||
|
||||
ENTRYPOINT ["/app/refresh.sh"]
|
||||
74
tools/osm-extract/INSTALL.md
Normal file
74
tools/osm-extract/INSTALL.md
Normal file
|
|
@ -0,0 +1,74 @@
|
|||
# Build 4 — POI-Offline-Umstellung auf der DiskStation (DSM-Upload)
|
||||
|
||||
Ziel: Live-Overpass-Scanner abschalten + die 1,45 Mio DACH-POIs in die
|
||||
Produktiv-DB migrieren. Ohne 5,7-GB-Download (die fertige `dach.sqlite` wird
|
||||
mit hochgeladen).
|
||||
|
||||
App-Verzeichnis auf der DS: **`/volume1/docker/banyaro/`** (im File Station:
|
||||
`docker` → `banyaro`).
|
||||
|
||||
---
|
||||
|
||||
## Schritt 1 — Code hochladen (File Station)
|
||||
|
||||
1. `build4-osm-code.zip` in den Ordner **`docker/banyaro`** hochladen.
|
||||
2. Rechtsklick → **Entpacken → Hierher entpacken**. Das überschreibt
|
||||
`backend/routes/osm.py` (Scanner aus) und legt `tools/osm-extract/` +
|
||||
`docker-compose.osm.yml` an. Andere Dateien bleiben unberührt.
|
||||
|
||||
## Schritt 2 — Vorbereiteten POI-Extrakt hochladen
|
||||
|
||||
3. `dach.sqlite` (181 MB) in den Ordner **`docker/banyaro/data`** hochladen.
|
||||
(Liegt dann im Container als `/data/dach.sqlite`.)
|
||||
|
||||
## Schritt 3 — Migration + Deploy (SSH-Terminal: `ssh ds`)
|
||||
|
||||
```sh
|
||||
cd /volume1/docker/banyaro
|
||||
|
||||
# Refresh-Image bauen (einmalig)
|
||||
docker compose -f docker-compose.osm.yml build
|
||||
|
||||
# MIGRATION: lädt dach.sqlite in die Produktiv-DB (Backup wird vorher angelegt,
|
||||
# user_edited-POIs + Community-Marker bleiben geschützt)
|
||||
docker compose -f docker-compose.osm.yml run --rm \
|
||||
-e PREBUILT_SQLITE=/data/dach.sqlite osm-refresh
|
||||
|
||||
# DEPLOY: baut die App neu → scanner-lose osm.py geht live
|
||||
docker compose up -d --build
|
||||
```
|
||||
|
||||
> Reihenfolge bewusst: erst Daten laden, dann App neu bauen → kein Fenster mit
|
||||
> leerer Karte.
|
||||
|
||||
## Schritt 4 — Prüfen
|
||||
|
||||
```sh
|
||||
docker compose -f docker-compose.osm.yml run --rm --entrypoint python3 \
|
||||
osm-refresh -c "import sqlite3; c=sqlite3.connect('/data/banyaro.db'); \
|
||||
print('osm_pois:', c.execute('select count(*) from osm_pois').fetchone()[0]); \
|
||||
print(c.execute('select type,count(*) from osm_pois group by type order by 2 desc').fetchall())"
|
||||
```
|
||||
Erwartung: ~1.452.675 POIs (bank ~1,0 Mio, restaurant ~60k …). In der App die
|
||||
Karte öffnen → Marker laden ohne Overpass.
|
||||
|
||||
## Aufräumen (optional)
|
||||
|
||||
`dach.sqlite` aus `docker/banyaro/data` kann nach erfolgreicher Migration weg.
|
||||
|
||||
## Monatlicher Auto-Refresh (DSM-Aufgabenplaner)
|
||||
|
||||
Systemsteuerung → Aufgabenplaner → Erstellen → Geplante Aufgabe →
|
||||
Benutzerdefiniertes Skript. Benutzer `root`, monatlich z. B. 1. um 04:00:
|
||||
|
||||
```sh
|
||||
cd /volume1/docker/banyaro && \
|
||||
docker compose -f docker-compose.osm.yml run --rm osm-refresh \
|
||||
>> /volume1/docker/banyaro/data/osm-refresh.log 2>&1
|
||||
```
|
||||
(ohne `PREBUILT_SQLITE` → holt frisch von Geofabrik, ~1–2 GB RAM dank tags-filter)
|
||||
|
||||
## Rollback
|
||||
|
||||
Vor jedem Lauf entsteht `data/banyaro.pre-osm-JJJJMMTT.db`. Im Notfall:
|
||||
App stoppen, diese Datei auf `banyaro.db` zurückkopieren, App starten.
|
||||
55
tools/osm-extract/README.md
Normal file
55
tools/osm-extract/README.md
Normal file
|
|
@ -0,0 +1,55 @@
|
|||
# OSM-POI Offline-Refresh (Build 4)
|
||||
|
||||
Ersetzt das Live-Scannen gegen `overpass-api.de` (war wiederholt OSM-Bann-Quelle)
|
||||
durch einen **monatlichen Offline-Batch**: POIs werden aus den Geofabrik-OSM-Daten
|
||||
extrahiert und in die Produktiv-DB geladen. Danach keine OSM-Live-Last mehr.
|
||||
|
||||
## Bestandteile
|
||||
|
||||
| Datei | Zweck |
|
||||
|---|---|
|
||||
| `extract_osm_pois.py` | pbf → `osm_pois`-Schema (pyosmium). Kanonisches Kategorie→Tag-Mapping. |
|
||||
| `load_into_prod.py` | Extrakt → Produktiv-DB. Schützt `user_edited=1`, ersetzt den Rest. |
|
||||
| `refresh.sh` | Orchestrierung im Container: download → tags-filter → extract → load. |
|
||||
| `Dockerfile` | Image mit osmium-tool + pyosmium. |
|
||||
| `../../docker-compose.osm.yml` | Eigener Service, **nicht** im Default-Stack. |
|
||||
|
||||
## Was es tut
|
||||
|
||||
- Lädt CH/AT/DE von Geofabrik (~5,7 GB), dampft sie mit `osmium tags-filter`
|
||||
(streaming, <500 MB RAM) auf die relevanten Objekte ein, extrahiert die 9
|
||||
Ban-Yaro-Kategorien und lädt sie in `/data/banyaro.db`.
|
||||
- **Sicherheitskopie** der DB vor jedem Lauf (letzte 3 bleiben).
|
||||
- `user_edited=1`-POIs und `user_map_pois` (Community-Marker) bleiben unberührt.
|
||||
- Peak-RAM ~1–2 GB, hartes `mem_limit: 4g` als Schutzschranke.
|
||||
- Aktuelle Größenordnung: DACH ~1,45 Mio POIs, ~180 MB.
|
||||
|
||||
## Manuell ausführen (Test)
|
||||
|
||||
```sh
|
||||
cd /pfad/zu/banyaro # dort, wo docker-compose.yml liegt
|
||||
docker compose -f docker-compose.osm.yml build
|
||||
docker compose -f docker-compose.osm.yml run --rm osm-refresh
|
||||
```
|
||||
|
||||
## Monatlich per DSM-Aufgabenplaner
|
||||
|
||||
1. **Systemsteuerung → Aufgabenplaner → Erstellen → Geplante Aufgabe → Benutzerdefiniertes Skript**
|
||||
2. Benutzer: `root` (für Docker-Zugriff)
|
||||
3. Zeitplan: **monatlich**, z. B. am 1. um 04:00 (lastarm)
|
||||
4. Aufgabeneinstellungen → Benutzerdefiniertes Skript:
|
||||
|
||||
```sh
|
||||
cd /volume1/docker/banyaro && \
|
||||
/usr/local/bin/docker compose -f docker-compose.osm.yml run --rm osm-refresh \
|
||||
>> /volume1/docker/banyaro/data/osm-refresh.log 2>&1
|
||||
```
|
||||
|
||||
> Pfad `/volume1/docker/banyaro` an euer Compose-Verzeichnis anpassen.
|
||||
> Docker-Binary auf DSM ist meist `/usr/local/bin/docker` (`docker compose`),
|
||||
> bei älteren DSM ggf. `docker-compose`.
|
||||
|
||||
## Rollback
|
||||
|
||||
Vor jedem Lauf wird `banyaro.pre-osm-JJJJMMTT.db` neben der DB abgelegt. Im
|
||||
Notfall App stoppen, diese Datei auf `banyaro.db` zurückkopieren, App starten.
|
||||
140
tools/osm-extract/extract_osm_pois.py
Normal file
140
tools/osm-extract/extract_osm_pois.py
Normal file
|
|
@ -0,0 +1,140 @@
|
|||
#!/usr/bin/env python3
|
||||
"""
|
||||
Offline-Extraktion der Ban-Yaro-POIs aus einer OSM-.pbf-Datei.
|
||||
|
||||
Ersetzt das Live-Scannen gegen overpass-api.de (backend/routes/osm.py) durch
|
||||
einen einmaligen/periodischen Batch-Lauf — keine OSM-Live-Last, kein Bann mehr.
|
||||
|
||||
Das Kategorie→Tag-Mapping ist 1:1 aus OSM_QUERIES in backend/routes/osm.py
|
||||
übernommen. Schreibt ins selbe Schema wie die Produktiv-Tabelle `osm_pois`
|
||||
(osm_id, type, lat, lon, name, opening_hours, phone, website, cached_at).
|
||||
|
||||
Aufruf:
|
||||
python3 extract_osm_pois.py <input.osm.pbf> <output.sqlite>
|
||||
"""
|
||||
import sys
|
||||
import sqlite3
|
||||
import osmium
|
||||
|
||||
|
||||
# --- Kategorie-Klassifikation (1:1 aus OSM_QUERIES, backend/routes/osm.py) ---
|
||||
def classify(t) -> list[str]:
|
||||
"""Gibt alle Ban-Yaro-Typen zurück, die auf dieses OSM-Objekt passen."""
|
||||
types: list[str] = []
|
||||
a = t.get("amenity")
|
||||
shop = t.get("shop")
|
||||
craft = t.get("craft")
|
||||
leisure = t.get("leisure")
|
||||
tourism = t.get("tourism")
|
||||
dog = t.get("dog")
|
||||
outdoor = t.get("outdoor_seating")
|
||||
# "hundefreundlich, breiter gefasst": explizit erlaubt ODER Terrasse
|
||||
dog_ok = dog in ("yes", "allowed", "leashed")
|
||||
|
||||
if a == "waste_basket":
|
||||
types.append("waste_basket")
|
||||
if a == "drinking_water":
|
||||
types.append("drinking_water")
|
||||
if a == "veterinary":
|
||||
types.append("tierarzt")
|
||||
if a == "bench":
|
||||
types.append("bank")
|
||||
if leisure == "dog_park" or (leisure == "park" and dog == "yes"):
|
||||
types.append("dog_park")
|
||||
if shop == "pet":
|
||||
types.append("shop")
|
||||
if shop == "pet_grooming" or craft == "pet_grooming":
|
||||
types.append("hundesalon")
|
||||
if (a in ("restaurant", "cafe") and (dog_ok or outdoor == "yes")) or a == "biergarten":
|
||||
types.append("restaurant")
|
||||
if tourism in ("hotel", "guest_house", "hostel") and dog_ok:
|
||||
types.append("hotel")
|
||||
return types
|
||||
|
||||
|
||||
SCHEMA = """
|
||||
CREATE TABLE IF NOT EXISTS osm_pois (
|
||||
osm_id INTEGER NOT NULL,
|
||||
type TEXT NOT NULL,
|
||||
lat REAL NOT NULL,
|
||||
lon REAL NOT NULL,
|
||||
name TEXT,
|
||||
opening_hours TEXT,
|
||||
phone TEXT,
|
||||
website TEXT,
|
||||
user_edited INTEGER NOT NULL DEFAULT 0,
|
||||
cached_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
PRIMARY KEY (osm_id, type)
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_osm_pois_loc ON osm_pois(type, lat, lon);
|
||||
"""
|
||||
|
||||
|
||||
class PoiHandler(osmium.SimpleHandler):
|
||||
def __init__(self, conn):
|
||||
super().__init__()
|
||||
self.conn = conn
|
||||
self.rows = 0
|
||||
self.objs = 0
|
||||
|
||||
def _save(self, osm_id, tags, lat, lon):
|
||||
types = classify(tags)
|
||||
if not types:
|
||||
return
|
||||
self.objs += 1
|
||||
name = tags.get("name")
|
||||
oh = tags.get("opening_hours")
|
||||
phone = tags.get("phone") or tags.get("contact:phone")
|
||||
web = tags.get("website") or tags.get("contact:website")
|
||||
for ty in types:
|
||||
self.conn.execute(
|
||||
"INSERT OR REPLACE INTO osm_pois "
|
||||
"(osm_id, type, lat, lon, name, opening_hours, phone, website, cached_at) "
|
||||
"VALUES (?,?,?,?,?,?,?,?, datetime('now'))",
|
||||
(osm_id, ty, lat, lon, name, oh, phone, web),
|
||||
)
|
||||
self.rows += 1
|
||||
|
||||
def node(self, n):
|
||||
if n.tags:
|
||||
self._save(n.id, n.tags, n.location.lat, n.location.lon)
|
||||
|
||||
def way(self, w):
|
||||
# Wege (z. B. Tierarzt im Gebäude) → Schwerpunkt aus Knoten ("out center")
|
||||
if not w.tags:
|
||||
return
|
||||
lats, lons = [], []
|
||||
for nd in w.nodes:
|
||||
if nd.location.valid():
|
||||
lats.append(nd.location.lat)
|
||||
lons.append(nd.location.lon)
|
||||
if lats:
|
||||
self._save(w.id, w.tags, sum(lats) / len(lats), sum(lons) / len(lons))
|
||||
|
||||
|
||||
def main():
|
||||
if len(sys.argv) != 3:
|
||||
print(__doc__)
|
||||
sys.exit(1)
|
||||
src, dst = sys.argv[1], sys.argv[2]
|
||||
|
||||
conn = sqlite3.connect(dst)
|
||||
conn.executescript(SCHEMA)
|
||||
|
||||
h = PoiHandler(conn)
|
||||
# locations=True: Knoten-Koordinaten im Speicher halten, damit Wege einen
|
||||
# Schwerpunkt bekommen. flex_mem skaliert bis Länder-Extrakte.
|
||||
h.apply_file(src, locations=True, idx="flex_mem")
|
||||
|
||||
conn.commit()
|
||||
print(f"\nObjekte mit Treffer: {h.objs:,} eingefügte Zeilen: {h.rows:,}")
|
||||
print("\nPro Typ:")
|
||||
for ty, cnt in conn.execute(
|
||||
"SELECT type, COUNT(*) FROM osm_pois GROUP BY type ORDER BY 2 DESC"
|
||||
):
|
||||
print(f" {ty:16s} {cnt:>8,}")
|
||||
conn.close()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
71
tools/osm-extract/load_into_prod.py
Normal file
71
tools/osm-extract/load_into_prod.py
Normal file
|
|
@ -0,0 +1,71 @@
|
|||
#!/usr/bin/env python3
|
||||
"""
|
||||
Lädt die offline extrahierten POIs (dach.sqlite) in die Produktiv-DB.
|
||||
|
||||
Semantik des Monats-Refresh:
|
||||
* Alle nicht-editierten OSM-POIs (user_edited=0) werden ersetzt → POIs, die
|
||||
aus OSM verschwunden sind, fallen sauber raus.
|
||||
* Von Nutzern korrigierte POIs (user_edited=1, via Moderation) bleiben
|
||||
UNANGETASTET — INSERT OR IGNORE überspringt sie bei Kollision.
|
||||
* Community-Marker (Tabelle user_map_pois) sind separat und werden nie
|
||||
berührt.
|
||||
|
||||
Läuft in EINER Transaktion. Bei Fehler bleibt die alte DB unverändert.
|
||||
|
||||
Aufruf:
|
||||
python3 load_into_prod.py <extract.sqlite> <ziel/banyaro.db>
|
||||
"""
|
||||
import sys
|
||||
import sqlite3
|
||||
|
||||
COLS = "osm_id, type, lat, lon, name, opening_hours, phone, website, user_edited, cached_at"
|
||||
|
||||
|
||||
def main():
|
||||
if len(sys.argv) != 3:
|
||||
print(__doc__)
|
||||
sys.exit(1)
|
||||
extract_path, prod_path = sys.argv[1], sys.argv[2]
|
||||
|
||||
# timeout/busy_timeout: die App schreibt evtl. parallel — auf Lock warten,
|
||||
# statt sofort zu scheitern. Der Load läuft in EINER Transaktion.
|
||||
conn = sqlite3.connect(prod_path, timeout=120)
|
||||
conn.execute("PRAGMA busy_timeout=120000")
|
||||
conn.execute("PRAGMA foreign_keys=ON")
|
||||
conn.execute(f"ATTACH DATABASE ? AS ext", (extract_path,))
|
||||
|
||||
before = conn.execute("SELECT COUNT(*) FROM osm_pois").fetchone()[0]
|
||||
edited = conn.execute("SELECT COUNT(*) FROM osm_pois WHERE user_edited=1").fetchone()[0]
|
||||
incoming = conn.execute("SELECT COUNT(*) FROM ext.osm_pois").fetchone()[0]
|
||||
|
||||
try:
|
||||
conn.execute("BEGIN")
|
||||
# 1) nicht-editierte OSM-POIs verwerfen (editierte bleiben stehen)
|
||||
conn.execute("DELETE FROM osm_pois WHERE user_edited=0")
|
||||
# 2) frische Extraktion einspielen; editierte Survivor nicht überschreiben
|
||||
conn.execute(
|
||||
f"INSERT OR IGNORE INTO osm_pois ({COLS}) "
|
||||
f"SELECT {COLS} FROM ext.osm_pois"
|
||||
)
|
||||
conn.execute("COMMIT")
|
||||
except Exception:
|
||||
conn.execute("ROLLBACK")
|
||||
raise
|
||||
|
||||
after = conn.execute("SELECT COUNT(*) FROM osm_pois").fetchone()[0]
|
||||
print(f"Vorher: {before:>10,}")
|
||||
print(f"davon user_edited:{edited:>10,} (geschützt)")
|
||||
print(f"Eingespielt: {incoming:>10,}")
|
||||
print(f"Nachher: {after:>10,}")
|
||||
print("\nPro Typ (nachher):")
|
||||
for ty, cnt in conn.execute(
|
||||
"SELECT type, COUNT(*) FROM osm_pois GROUP BY type ORDER BY 2 DESC"
|
||||
):
|
||||
print(f" {ty:16s} {cnt:>9,}")
|
||||
|
||||
conn.execute("DETACH DATABASE ext")
|
||||
conn.close()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
66
tools/osm-extract/refresh.sh
Normal file
66
tools/osm-extract/refresh.sh
Normal file
|
|
@ -0,0 +1,66 @@
|
|||
#!/usr/bin/env bash
|
||||
#
|
||||
# Monatlicher POI-Refresh (Build 4) — läuft im Docker-Container auf der Synology.
|
||||
# download → tags-filter (RAM-schonend) → extract → load in die Produktiv-DB.
|
||||
# Ersetzt das Live-Overpass-Scannen (war Bann-Quelle).
|
||||
#
|
||||
# Erst-Migration ohne 5,7-GB-Download: vorab gebaute dach.sqlite mitliefern und
|
||||
# PREBUILT_SQLITE=/data/dach.sqlite setzen → überspringt download/extract.
|
||||
#
|
||||
set -euo pipefail
|
||||
|
||||
DB="${DB_PATH:-/data/banyaro.db}"
|
||||
WORK="${WORK_DIR:-/work}"
|
||||
COUNTRIES="${COUNTRIES:-switzerland austria germany}"
|
||||
GEOFABRIK="${GEOFABRIK_BASE:-https://download.geofabrik.de/europe}"
|
||||
KEEP_BACKUPS="${KEEP_BACKUPS:-3}"
|
||||
PREBUILT_SQLITE="${PREBUILT_SQLITE:-}"
|
||||
|
||||
# OSM-Tags für die 9 Ban-Yaro-Kategorien (Superset; finale Klassifikation macht
|
||||
# extract_osm_pois.py). nw/ = node+way, referenzierte Knoten bleiben für die
|
||||
# Weg-Geometrie automatisch erhalten.
|
||||
FILTER=(
|
||||
"nw/amenity=waste_basket,drinking_water,veterinary,bench,biergarten,restaurant,cafe"
|
||||
"nw/leisure=dog_park,park"
|
||||
"nw/shop=pet,pet_grooming"
|
||||
"nw/craft=pet_grooming"
|
||||
"nw/tourism=hotel,guest_house,hostel"
|
||||
)
|
||||
|
||||
mkdir -p "$WORK"; cd "$WORK"
|
||||
echo "[$(date -u)] POI-Refresh start → $DB"
|
||||
|
||||
# 1) Sicherheitskopie der Produktiv-DB (nur die letzten N behalten)
|
||||
if [ -f "$DB" ]; then
|
||||
bak="${DB%.db}.pre-osm-$(date -u +%Y%m%d).db"
|
||||
cp -p "$DB" "$bak"
|
||||
echo "Backup: $bak"
|
||||
ls -1t "${DB%.db}".pre-osm-*.db 2>/dev/null | tail -n +$((KEEP_BACKUPS + 1)) | xargs -r rm -f
|
||||
fi
|
||||
|
||||
# 2a) Schnellweg: vorab gebauten Extrakt direkt laden (kein Download/Extract)
|
||||
if [ -n "$PREBUILT_SQLITE" ]; then
|
||||
[ -f "$PREBUILT_SQLITE" ] || { echo "FEHLER: $PREBUILT_SQLITE nicht gefunden"; exit 1; }
|
||||
echo "[$(date -u)] PREBUILT_SQLITE=$PREBUILT_SQLITE → überspringe download/extract"
|
||||
python3 /app/load_into_prod.py "$PREBUILT_SQLITE" "$DB"
|
||||
echo "[$(date -u)] POI-Refresh (prebuilt) fertig."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# 2b) Regulärer Monatslauf: frisch holen + extrahieren
|
||||
rm -f dach.sqlite
|
||||
for c in $COUNTRIES; do
|
||||
echo "[$(date -u)] $c: download"
|
||||
curl -fSL --retry 3 -o "$c.osm.pbf" "$GEOFABRIK/$c-latest.osm.pbf"
|
||||
echo "[$(date -u)] $c: tags-filter"
|
||||
osmium tags-filter --overwrite -o "$c.f.osm.pbf" "$c.osm.pbf" "${FILTER[@]}"
|
||||
rm -f "$c.osm.pbf"
|
||||
echo "[$(date -u)] $c: extract"
|
||||
python3 /app/extract_osm_pois.py "$c.f.osm.pbf" dach.sqlite
|
||||
rm -f "$c.f.osm.pbf"
|
||||
done
|
||||
|
||||
echo "[$(date -u)] load → Produktiv-DB"
|
||||
python3 /app/load_into_prod.py dach.sqlite "$DB"
|
||||
rm -f dach.sqlite
|
||||
echo "[$(date -u)] POI-Refresh fertig."
|
||||
Loading…
Add table
Add a link
Reference in a new issue