diff --git a/.gitignore b/.gitignore index cbcf3ae..8319981 100644 --- a/.gitignore +++ b/.gitignore @@ -13,3 +13,11 @@ __pycache__/ .claude/worktrees/ Ban Yaro - Google Play package/ /unsplash/ + +# Selbst-gehostete Vektor-Tiles (groß, gehören nicht ins Repo) +tiles/build/ +*.pmtiles +*.osm.pbf +*.mbtiles +tiles/build.log +tiles/.DS_Store diff --git a/Makefile b/Makefile index c16bf3f..4c7cefe 100644 --- a/Makefile +++ b/Makefile @@ -24,10 +24,11 @@ TAR_EXCLUDE := --exclude='.git' \ --exclude='./backend/__pycache__' \ --exclude='./.env' \ --exclude='./*.db' \ + --exclude='./tiles' \ --exclude='./.DS_Store' .PHONY: help deploy deploy-clean staging release sync push restart build stop status \ - logs logs-f shell db dev clean-cache check-ssh reports bump test + logs logs-f shell db dev clean-cache check-ssh reports bump test tiles tiles-deploy # ---------------------------------------------------------- # SSH-Prüfung — Abhängigkeit aller DS-Befehle @@ -139,6 +140,56 @@ staging-db: check-ssh sudo chmod 666 $(DS_PATH_STAGING)/data/banyaro.db && \ echo '✓ DB kopiert'" +# ---------------------------------------------------------- +# TILES — DACH-Vektortiles (planetiler → PMTiles), lokal bauen + ausliefern +# Voraussetzung: Docker Desktop läuft, osmium installiert (brew install osmium-tool). +# make tiles DACH neu generieren (download → merge → planetiler) +# make tiles-deploy dach.pmtiles auf Staging ausliefern (atomar) +# make tiles-deploy ENV=prod dach.pmtiles auf Produktion ausliefern (atomar) +# Monatlich neu generieren hält die Karte aktuell. Datei liegt im data-Volume, +# NICHT im Image — wird per Range-Route (/tiles) ausgeliefert. +# ---------------------------------------------------------- +TILES_DIR := tiles/build +# DACH + alle angrenzenden Länder (15). Reihenfolge egal — osmium merge -H + time-filter +# dedupliziert Grenz-Nodes. Output bleibt dach.pmtiles (Frontend referenziert den Namen). +TILES_REGIONS := germany austria switzerland france italy czech-republic poland \ + slovakia hungary slovenia netherlands belgium luxembourg denmark liechtenstein +PLANETILER_IMAGE := ghcr.io/onthegomap/planetiler:latest +TILES_TARGET := $(if $(filter prod,$(ENV)),$(DS_PATH),$(DS_PATH_STAGING)) + +tiles: + @mkdir -p $(TILES_DIR) + @echo "→ Geofabrik-Extrakte laden ($(TILES_REGIONS))..." + @for r in $(TILES_REGIONS); do \ + echo " $$r"; \ + curl -fsSL -o $(TILES_DIR)/$$r.osm.pbf https://download.geofabrik.de/europe/$$r-latest.osm.pbf; done + @echo "→ merge (History) + time-filter dedup → dach.osm.pbf..." + @# Geofabrik-Extrakte können versetzte Stände haben (z.B. germany älter als at/ch) → + @# Grenz-Nodes mit abweichender Version. Als History mergen + auf 'jetzt' snapshotten + @# liefert genau eine Version pro ID (planetiler braucht eindeutige, sortierte IDs). + @osmium merge -H $(foreach r,$(TILES_REGIONS),$(TILES_DIR)/$(r).osm.pbf) -o $(TILES_DIR)/dach-hist.osm.pbf --overwrite + @osmium time-filter $(TILES_DIR)/dach-hist.osm.pbf -o $(TILES_DIR)/dach.osm.pbf --overwrite + @# History + Einzel-PBFs jetzt freigeben (spart ~Quellsumme an Spitzen-Plattenplatz vor planetiler). + @rm -f $(TILES_DIR)/dach-hist.osm.pbf $(foreach r,$(TILES_REGIONS),$(TILES_DIR)/$(r).osm.pbf) + @echo "→ planetiler → dach.pmtiles (disk-backed mmap)..." + @docker run --rm -v "$(CURDIR)/$(TILES_DIR):/data" $(PLANETILER_IMAGE) \ + --osm-path=/data/dach.osm.pbf --download --output=/data/dach.pmtiles --force \ + --storage=mmap --nodemap-storage=mmap + @echo "" + @echo " ✓ Tiles gebaut:"; ls -lh $(TILES_DIR)/dach.pmtiles + +tiles-deploy: check-ssh + @if [ ! -f $(TILES_DIR)/dach.pmtiles ]; then echo "❌ $(TILES_DIR)/dach.pmtiles fehlt — erst 'make tiles'"; exit 1; fi + @echo "→ Ausliefern nach $(TILES_TARGET)/data/tiles/ (atomarer Swap)..." + @ssh $(DS_HOST) "mkdir -p $(TILES_TARGET)/data/tiles" + @scp -O $(TILES_DIR)/dach.pmtiles $(DS_HOST):$(TILES_TARGET)/data/tiles/dach.pmtiles.tmp + @ssh $(DS_HOST) "mv -f $(TILES_TARGET)/data/tiles/dach.pmtiles.tmp $(TILES_TARGET)/data/tiles/dach.pmtiles" + @echo " ✓ dach.pmtiles ausgeliefert ($(if $(filter prod,$(ENV)),PRODUKTION,Staging))" + @# Cache-Bust: TILES_VER in map-gl-style.js hochzählen (sonst liefert der Browser bis 24h alte Tiles). + @NEWVER=$$(date +%Y%m%d%H%M); \ + sed -i '' "s/var TILES_VER = '[0-9]*';/var TILES_VER = '$$NEWVER';/" backend/static/js/map-gl-style.js; \ + echo " ↻ TILES_VER → $$NEWVER — JETZT Frontend ausliefern: make bump && make $(if $(filter prod,$(ENV)),deploy,staging)" + # ---------------------------------------------------------- # RELEASE — develop → main → Production (VERSION= pflichtangabe) # Beispiel: make release VERSION=1.1.0 diff --git a/VERSION b/VERSION index 8535dde..64e9c64 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1161 \ No newline at end of file +1219 \ No newline at end of file diff --git a/backend/main.py b/backend/main.py index f4ecac6..bf4f74b 100644 --- a/backend/main.py +++ b/backend/main.py @@ -111,6 +111,7 @@ class SecurityHeadersMiddleware(BaseHTTPMiddleware): response.headers["Content-Security-Policy"] = ( "default-src 'self'; " "script-src 'self' https://umami.motocamp.de; " # ohne unsafe-inline/eval — alle Inline-Scripts extrahiert + "worker-src 'self' blob:; " # 'self' = Service Worker (sw.js); blob: = MapLibre-GL-Worker "style-src 'self' 'unsafe-inline'; " # Inline-Styles bleiben (zu viele Fundstellen für jetzt) "img-src 'self' data: blob: https:; " "connect-src 'self' https:; " @@ -371,6 +372,100 @@ app.mount("/js", StaticFiles(directory=f"{STATIC_DIR}/js"), name="js") app.mount("/icons", StaticFiles(directory=f"{STATIC_DIR}/icons"), name="icons") app.mount("/img", StaticFiles(directory=f"{STATIC_DIR}/img"), name="img") +# Selbst-gehostete Vektor-Tiles (.pmtiles) — liegen im data-Volume, NICHT im Image. +# WICHTIG: Starlettes StaticFiles/FileResponse liefert hinter unserer BaseHTTPMiddleware +# KEINE Range-Requests (206) — app-weit kommt nur 200 ohne Accept-Ranges zurück. +# MapLibre/pmtiles BRAUCHT aber Byte-Ranges (liest einzelne Tiles aus dem Single-File). +# Daher eine eigene Route, die 206 als normales Response (Byte-Slice) zurückgibt — das +# überlebt die Middleware. Für Produktion/Skalierung gehört das hinter nginx/NPM direkt +# (Range nativ, keine App-CPU) — siehe docs/TILE_SERVER_HANDOVER.md, Entscheidung #2. +_TILES_DIR = os.getenv("TILES_DIR", "/data/tiles") + +# Glyphs (Font-PBFs) für MapLibre-Labels — kleine Static-Files (kein Range nötig), +# liegen im data-Volume unter tiles/fonts/{fontstack}/{range}.pbf. +_FONTS_DIR = os.getenv("FONTS_DIR", os.path.join(_TILES_DIR, "fonts")) +if os.path.isdir(_FONTS_DIR): + app.mount("/fonts", StaticFiles(directory=_FONTS_DIR), name="fonts") + +@app.api_route("/tiles/{filename}", methods=["GET", "HEAD"]) +async def serve_tile(filename: str, request: Request): + # Kein Path-Traversal + if "/" in filename or "\\" in filename or ".." in filename: + return Response(status_code=404) + path = os.path.join(_TILES_DIR, filename) + if not os.path.isfile(path): + return Response(status_code=404) + file_size = os.path.getsize(path) + _mtime = int(os.path.getmtime(path)) + _etag = f'"{file_size:x}-{_mtime:x}"' + # Versionierte URL (?v=…) ist inhaltsstabil → lange + immutable cachen. OHNE Version nur kurz cachen, + # damit ein Tile-Swap (gleiche URL, neuer Inhalt) sich innerhalb ~1 Min von selbst heilt — sonst + # liefert der Browser bis zu 24h die alten PMTiles-Bytes (alte Abdeckung). + _versioned = "v" in request.query_params + _cache = "public, max-age=31536000, immutable" if _versioned else "public, max-age=60" + base_headers = {"Accept-Ranges": "bytes", "Cache-Control": _cache, "ETag": _etag} + if request.method == "HEAD": + return Response( + status_code=200, media_type="application/octet-stream", + headers={**base_headers, "Content-Length": str(file_size)}, + ) + range_header = request.headers.get("range") + if range_header and range_header.startswith("bytes="): + rng = range_header[6:].split(",")[0] # nur erster Range (pmtiles nutzt single-range) + start_s, _, end_s = rng.partition("-") + try: + if start_s == "": # Suffix-Range "bytes=-N" + length = int(end_s) + start = max(0, file_size - length) + end = file_size - 1 + else: + start = int(start_s) + end = int(end_s) if end_s else file_size - 1 + except ValueError: + return Response(status_code=416, headers={**base_headers, "Content-Range": f"bytes */{file_size}"}) + end = min(end, file_size - 1) + if start > end or start >= file_size: + return Response(status_code=416, headers={**base_headers, "Content-Range": f"bytes */{file_size}"}) + with open(path, "rb") as f: + f.seek(start) + data = f.read(end - start + 1) + return Response( + data, status_code=206, media_type="application/octet-stream", + headers={**base_headers, "Content-Range": f"bytes {start}-{end}/{file_size}"}, + ) + # Kein Range → ganze Datei streamen (pmtiles macht das normalerweise nicht). + return FileResponse(path, media_type="application/octet-stream", headers=base_headers) + + +@app.get("/maplibre-test") +async def maplibre_test(): + # Spike-Testseite: MapLibre rendert /tiles/*.pmtiles (Geometrie-Style, kein Glyph). + return FileResponse(os.path.join(STATIC_DIR, "maplibre-test.html"), media_type="text/html") + + +@app.get("/leaflet-vector-test") +async def leaflet_vector_test(): + # Isolationstest: protomaps-leaflet + map-vector.js + DACH-PMTiles, ohne App-Shell/Flag. + return FileResponse(os.path.join(STATIC_DIR, "leaflet-vector-test.html"), media_type="text/html") + + +@app.get("/ui-vector-test") +async def ui_vector_test(): + # Testet den echten ui.js-Vektor-Pfad (UI.map.create) ohne Auth/App-Shell. + return FileResponse(os.path.join(STATIC_DIR, "ui-vector-test.html"), media_type="text/html") + + +@app.get("/maplibre-perf-test") +async def maplibre_perf_test(): + # Wegwerf-Perf-Test: MapLibre GPU + 600 Cluster-Marker auf DACH-Basemap (Handy-Test). + return FileResponse(os.path.join(STATIC_DIR, "maplibre-perf-test.html"), media_type="text/html") + + +@app.get("/maplibre-markers-test") +async def maplibre_markers_test(): + # Headless-Proof für map-gl-markers.js (Cluster/Icons/Danger/Toggle/Popup, ohne Auth). + return FileResponse(os.path.join(STATIC_DIR, "maplibre-markers-test.html"), media_type="text/html") + # User-generierte Medien (Fotos aus Tagebuch, Giftköder-Alarm, etc.) MEDIA_DIR = os.getenv("MEDIA_DIR", "/data/media") os.makedirs(MEDIA_DIR, exist_ok=True) diff --git a/backend/routes/forum.py b/backend/routes/forum.py index 33eb726..58d03b4 100644 --- a/backend/routes/forum.py +++ b/backend/routes/forum.py @@ -166,8 +166,22 @@ async def list_threads( # ------------------------------------------------------------------ # POST /api/forum/threads # ------------------------------------------------------------------ -def _check_post_limits(user_id: int, conn, text: str, user_created_at: str | None = None, is_thread: bool = False): - """Cooldown, Stunden-Limit und Duplikat-Check für Forum-Posts.""" +def _check_post_limits(user_id: int, conn, text: str, user_created_at: str | None = None, + is_thread: bool = False, now_client: str | None = None): + """Cooldown, Stunden-Limit und Duplikat-Check für Forum-Posts. + + WICHTIG: created_at wird als Client-Lokalzeit gespeichert (safe_client_time). + Alle Zeit-Checks müssen daher gegen die gleiche Zeitbasis rechnen — sonst + sorgt der UTC/Lokalzeit-Versatz (z.B. CEST = UTC+2) dafür, dass der Cooldown + dauerhaft greift (diff wird negativ → immer < 30). Referenz ist die + Client-Zeit dieses Requests (now_client), Fallback UTC. + """ + from datetime import datetime as _dt, timedelta as _td + try: + now_dt = _dt.fromisoformat(now_client) if now_client else _dt.utcnow() + except (ValueError, TypeError): + now_dt = _dt.utcnow() + # 30-Sekunden-Cooldown zwischen beliebigen Posts last = conn.execute( """SELECT MAX(created_at) AS last FROM ( @@ -179,25 +193,25 @@ def _check_post_limits(user_id: int, conn, text: str, user_created_at: str | Non ).fetchone()["last"] if last: try: - from datetime import datetime as _dt - diff = (_dt.utcnow() - _dt.fromisoformat(last)).total_seconds() - if diff < 30: + diff = (now_dt - _dt.fromisoformat(last)).total_seconds() + if 0 <= diff < 30: raise HTTPException(429, "Bitte warte einen Moment bevor du erneut postest.") except (ValueError, TypeError): pass - # Stunden-Limit + # Stunden-Limit (gleiche Zeitbasis wie created_at) + hour_ago = (now_dt - _td(hours=1)).strftime("%Y-%m-%d %H:%M:%S") if is_thread: count = conn.execute( - "SELECT COUNT(*) FROM forum_threads WHERE user_id=? AND created_at > datetime('now','-1 hour')", - (user_id,), + "SELECT COUNT(*) FROM forum_threads WHERE user_id=? AND created_at > ?", + (user_id, hour_ago), ).fetchone()[0] if count >= 5: raise HTTPException(429, "Du hast in dieser Stunde bereits 5 Threads erstellt. Bitte warte etwas.") else: count = conn.execute( - "SELECT COUNT(*) FROM forum_posts WHERE user_id=? AND created_at > datetime('now','-1 hour')", - (user_id,), + "SELECT COUNT(*) FROM forum_posts WHERE user_id=? AND created_at > ?", + (user_id, hour_ago), ).fetchone()[0] if count >= 20: raise HTTPException(429, "Du hast in dieser Stunde bereits 20 Antworten geschrieben. Bitte warte etwas.") @@ -223,8 +237,8 @@ async def create_thread(data: ThreadCreate, user=Depends(get_current_user)): if data.kategorie not in KATEGORIEN: raise HTTPException(400, "Ungültige Kategorie.") with db() as conn: - _check_post_limits(user["id"], conn, data.text.strip(), user.get("created_at"), is_thread=True) ct = safe_client_time(data.client_time) + _check_post_limits(user["id"], conn, data.text.strip(), user.get("created_at"), is_thread=True, now_client=ct) cur = conn.execute( """INSERT INTO forum_threads (user_id, kategorie, titel, text, thread_lat, thread_lon, thread_ort, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?)""", @@ -370,9 +384,9 @@ async def create_post(thread_id: int, data: PostCreate, user=Depends(get_current if thread['is_deleted']: raise HTTPException(404, "Thread nicht gefunden.") - _check_post_limits(user["id"], conn, data.text.strip(), user.get("created_at"), is_thread=False) - ct = safe_client_time(data.client_time) + _check_post_limits(user["id"], conn, data.text.strip(), user.get("created_at"), is_thread=False, now_client=ct) + cur = conn.execute( "INSERT INTO forum_posts (thread_id, user_id, text, created_at) VALUES (?, ?, ?, ?)", (thread_id, user['id'], data.text.strip(), ct) diff --git a/backend/routes/profile.py b/backend/routes/profile.py index 3762413..f840ec8 100644 --- a/backend/routes/profile.py +++ b/backend/routes/profile.py @@ -149,25 +149,74 @@ async def put_world_config(body: WorldConfigIn, user=Depends(get_current_user)): # ---------------------------------------------------------- # DELETE /profile/account — Konto unwiderruflich löschen # ---------------------------------------------------------- +# Spalten, die eine HANDLUNG referenzieren (Moderator/Admin/Ersteller), +# nicht Eigentum des Users. Beim Löschen auf NULL setzen statt die fremde +# Zeile (z. B. einen Partner-Code oder eine moderierte Einreichung) mitzureißen. +_ACTOR_COLUMNS = { + ("wiki_foto_submissions", "reviewed_by"), + ("osm_poi_edits", "mod_id"), + ("partner_codes", "created_by"), + ("outreach_log", "sent_by"), + ("upgrade_requests", "fulfilled_by"), +} + + @router.delete('/account') async def delete_account(user=Depends(get_current_user)): - """Löscht das Konto und alle zugehörigen Daten unwiderruflich.""" + """Löscht das Konto und ALLE zugehörigen Daten unwiderruflich (DSGVO + App-Store-Gl. 4). + + FK-sicher und schema-robust: ermittelt per Introspektion alle Tabellen, die + auf users(id) verweisen. CASCADE-Tabellen werden beim users-DELETE automatisch + geleert; NO-ACTION/RESTRICT-Eigentumstabellen löschen wir explizit; Aktions- + Spalten (Moderator/Admin) setzen wir auf NULL. `defer_foreign_keys` macht die + Reihenfolge irrelevant — geprüft wird erst beim Commit. + """ uid = user['id'] with db() as conn: - # Alle Hunde-IDs des Users - dog_ids = [r['id'] for r in conn.execute( - "SELECT id FROM dogs WHERE user_id=?", (uid,)).fetchall()] - for did in dog_ids: - conn.execute("DELETE FROM diary WHERE dog_id=?", (did,)) - conn.execute("DELETE FROM health WHERE dog_id=?", (did,)) - conn.execute("DELETE FROM training_sessions WHERE dog_id=?", (did,)) - conn.execute("DELETE FROM training_streaks WHERE dog_id=?", (did,)) - conn.execute("DELETE FROM expenses WHERE dog_id=?", (did,)) - conn.execute("DELETE FROM dogs WHERE user_id=?", (uid,)) - conn.execute("DELETE FROM upgrade_requests WHERE user_id=?", (uid,)) - conn.execute("DELETE FROM push_subscriptions WHERE user_id=?", (uid,)) - conn.execute("DELETE FROM notifications WHERE user_id=?", (uid,)) - conn.execute("DELETE FROM forum_posts WHERE user_id=?", (uid,)) + # FK-Prüfung bis zum Commit aufschieben → Löschreihenfolge egal. + conn.execute("PRAGMA defer_foreign_keys=ON") + + tables = [r['name'] for r in conn.execute( + "SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%'" + ).fetchall()] + + # Tabellen merken, deren User-Bezug bereits über eine FK behandelt wurde + # (gelöscht oder genullt), damit der Spalten-Scan sie nicht doppelt anfasst. + handled_fk_cols: set[tuple] = set() + + # --- 1) Formale FKs auf users(id) --- + for tbl in tables: + try: + fks = conn.execute(f"PRAGMA foreign_key_list({tbl})").fetchall() + except Exception: + continue + for fk in fks: + if fk['table'] != 'users': + continue + col = fk['from'] + handled_fk_cols.add((tbl, col)) + on_delete = (fk['on_delete'] or '').upper() + if on_delete == 'CASCADE': + continue # wird durch den finalen users-DELETE mitgelöscht + if on_delete == 'SET NULL' or (tbl, col) in _ACTOR_COLUMNS: + conn.execute(f"UPDATE {tbl} SET {col}=NULL WHERE {col}=?", (uid,)) + else: + # NO ACTION / RESTRICT auf einer Eigentums-Spalte → Zeilen löschen. + conn.execute(f"DELETE FROM {tbl} WHERE {col}=?", (uid,)) + + # --- 2) Eigentums-Spalten OHNE formale FK (z. B. events.user_id) --- + # Manche Tabellen tragen user_id/owner_id ohne REFERENCES-Klausel. Die fängt + # die FK-Introspektion nicht — für ein echtes „alle Daten löschen" hier nach. + for tbl in tables: + try: + cols = {r['name'] for r in conn.execute(f"PRAGMA table_info({tbl})").fetchall()} + except Exception: + continue + for col in ('user_id', 'owner_id'): + if col in cols and (tbl, col) not in handled_fk_cols and (tbl, col) not in _ACTOR_COLUMNS: + conn.execute(f"DELETE FROM {tbl} WHERE {col}=?", (uid,)) + + # Räumt alle verbliebenen ON-DELETE-CASCADE-Tabellen automatisch ab. conn.execute("DELETE FROM users WHERE id=?", (uid,)) return {"status": "deleted"} diff --git a/backend/static/css/components.css b/backend/static/css/components.css index 1f22ba8..87dea9c 100644 --- a/backend/static/css/components.css +++ b/backend/static/css/components.css @@ -2514,115 +2514,6 @@ html.modal-open { text-align: center; } -/* ============================================================ - ORTE (places.js) - ============================================================ */ -.places-layout { - display: flex; - flex-direction: column; - height: 100%; - overflow: hidden; -} -.places-toolbar { - display: flex; - align-items: center; - gap: var(--space-3); - padding: var(--space-3) var(--space-4); - background: var(--c-surface); - border-bottom: 1px solid var(--c-border-light); - flex-shrink: 0; - overflow-x: auto; - scrollbar-width: none; -} -.places-toolbar::-webkit-scrollbar { display: none; } -.places-filter { - display: flex; - gap: var(--space-2); - flex: 1; - overflow-x: auto; - scrollbar-width: none; -} -.places-filter::-webkit-scrollbar { display: none; } -.places-filter-btn { - padding: var(--space-1) var(--space-3); - border-radius: var(--radius-full); - border: 1.5px solid var(--c-border); - background: var(--c-surface); - color: var(--c-text-secondary); - font-size: var(--text-sm); - cursor: pointer; - white-space: nowrap; - transition: all 0.15s; - flex-shrink: 0; -} -.places-filter-btn.active { - background: var(--c-primary); - border-color: var(--c-primary); - color: #fff; -} -.places-map { - height: 42%; - flex-shrink: 0; - min-height: 180px; -} -.places-list { - flex: 1; - overflow-y: auto; - scrollbar-width: thin; - scrollbar-color: var(--c-primary) var(--c-surface); -} -.places-list-inner { - padding: var(--space-3) var(--space-4); - display: flex; - flex-direction: column; - gap: var(--space-2); -} -.places-card { - display: flex; - align-items: center; - gap: var(--space-3); - padding: var(--space-3); - background: var(--c-surface); - border: 1.5px solid var(--c-border-light); - border-left: 4px solid var(--typ-color, var(--c-primary)); - border-radius: var(--radius-lg); - cursor: pointer; - transition: box-shadow 0.15s; -} -.places-card:hover { box-shadow: 0 2px 8px rgba(0,0,0,0.08); } -.places-card-icon { font-size: 1.6rem; flex-shrink: 0; } -.places-card-body { flex: 1; min-width: 0; } -.places-card-name { font-weight: var(--weight-semibold); color: var(--c-text); } -.places-card-meta { font-size: var(--text-sm); color: var(--c-text-secondary); margin-top: 2px; } -.places-card-flags { display: flex; flex-wrap: wrap; gap: var(--space-1); margin-top: var(--space-1); } -.places-card-arrow { color: var(--c-text-muted); font-size: 1.2rem; } -.places-flag { - font-size: var(--text-xs); - padding: 2px 7px; - border-radius: var(--radius-full); - background: var(--c-surface-2); - color: var(--c-text-secondary); -} -.places-flag--detail { - font-size: var(--text-sm); - padding: var(--space-1) var(--space-3); -} -.places-locate-btn { - width: 40px; - height: 40px; - border-radius: 50%; - background: #C4843A; - color: #fff; - border: none; - box-shadow: 0 2px 8px rgba(0,0,0,0.3); - cursor: pointer; - display: flex; - align-items: center; - justify-content: center; - margin: 10px; -} -.places-locate-btn:hover { background: #9E6520; } - /* ============================================================ ROUTEN — Komoot-Stil (routes.js) ============================================================ */ @@ -3132,6 +3023,16 @@ html.modal-open { } .map-full { width: 100%; height: 100%; } +/* Karten-Overlays click-through: die Container der Buttons/Infos liegen über der + Karte und fingen Touch in ihrer GANZEN Bounding-Box ab → tote Zonen, in denen + sich die Karte nicht greifen/pannen ließ. Nur die echten Buttons fangen Touch. */ +.map-statusbar, +.map-crosshair, +.map-speed-dial, +.map-search-wrap:not(.active), +.map-rec-panel:not(.active) { pointer-events: none; } +.map-sd-trigger { pointer-events: auto; } + /* Legende: horizontaler Scroll-Strip oben */ .map-legend { position: absolute; @@ -3151,6 +3052,51 @@ html.modal-open { } .map-legend::-webkit-scrollbar { display: none; } +/* Regenradar-Zeitleiste (RainViewer: ~2h Vergangenheit + ~30min Nowcast, Play/Pause + Slider) */ +/* Optik + Maße wie die Status-Pill darunter: gleiche linke Kante (var(--space-3)); die Breite wird + per JS an die Pill angeglichen (gleiche rechte Kante). Höhe wie die Pill. */ +.map-radar-timeline { + position: absolute; + left: var(--space-3); + width: min(320px, calc(100% - 100px)); /* Fallback; JS setzt = Pill-Breite */ + bottom: calc(var(--space-3) + 34px); /* unmittelbar über der Status-Pill */ + z-index: 900; + display: flex; + align-items:center; + gap: 7px; + padding: 3px 12px 3px 4px; + border-radius: var(--radius-full); + background: rgba(255, 255, 255, 0.88); + backdrop-filter: blur(4px); + -webkit-backdrop-filter: blur(4px); + border: 1px solid var(--c-border-light); + color: var(--c-text); + pointer-events: auto; + box-sizing: border-box; +} +:root[data-theme="dark"] .map-radar-timeline { + background: rgba(24, 20, 16, 0.92); + border-color: rgba(255, 255, 255, 0.1); +} +.rdr-play { + flex-shrink: 0; + width: 24px; height: 24px; + border: none; border-radius: 50%; + background: var(--c-surface-2); + color: var(--c-text); cursor: pointer; + display: flex; align-items: center; justify-content: center; +} +.rdr-play svg { width: 14px; height: 14px; } +.rdr-play:active { background: var(--c-border); } +.rdr-slider { flex: 1; min-width: 0; height: 4px; accent-color: var(--c-primary); cursor: pointer; } +.rdr-time { + flex-shrink: 0; + font-size: 11px; font-weight: 600; + font-variant-numeric: tabular-nums; + min-width: 74px; text-align: right; color: var(--c-text-secondary); +} +.rdr-time.is-forecast { color: var(--c-primary); } /* Nowcast/Vorhersage-Frames hervorgehoben */ + .map-legend-btn { flex-shrink: 0; display: inline-flex; @@ -6853,15 +6799,6 @@ html.modal-open { margin-bottom: 2px; } -/* OSM-Attribution unter der Karte */ -.lost-map-attribution { - font-size: 10px; - color: var(--c-text-secondary); - text-align: right; - padding: 2px var(--space-2) 0; - margin-bottom: var(--space-4); -} - /* Info-Zeile über der Liste ("X vermisste Hunde …") */ .lost-info-text { font-size: var(--text-sm); diff --git a/backend/static/css/utilities.css b/backend/static/css/utilities.css index f9f5ec2..f38a562 100644 --- a/backend/static/css/utilities.css +++ b/backend/static/css/utilities.css @@ -63,3 +63,14 @@ font-weight: 600; margin-bottom: var(--space-1); } + +/* ------------------------------------------------------------------ + Hover-Utilities — ersetzen CSP-blockierte onmouseenter/leave/over. + :hover braucht !important, da Inline-Base-Styles höher spezifisch sind. + ------------------------------------------------------------------ */ +.by-hover-lift { transition: transform .15s, box-shadow .15s; } +.by-hover-lift:hover { transform: translateY(-2px) !important; box-shadow: var(--shadow-md) !important; } +.by-hover-surface2 { transition: background .15s; } +.by-hover-surface2:hover{ background: var(--c-surface-2) !important; } +.by-hover-surface3 { transition: background .15s; } +.by-hover-surface3:hover{ background: var(--c-surface-3) !important; } diff --git a/backend/static/index.html b/backend/static/index.html index 950d38a..1770205 100644 --- a/backend/static/index.html +++ b/backend/static/index.html @@ -86,14 +86,14 @@ Ban Yaro - + - - - - - + + + + + @@ -617,11 +617,11 @@ - - - - - + + + + + @@ -631,7 +631,7 @@ - + diff --git a/backend/static/js/app.js b/backend/static/js/app.js index 23c0284..6d91f3c 100644 --- a/backend/static/js/app.js +++ b/backend/static/js/app.js @@ -3,7 +3,7 @@ Router, State-Management, Navigation, Initialisierung. ============================================================ */ -const APP_VER = '1161'; // ← bei jedem Deploy mit Frontend-Änderungen erhöhen +const APP_VER = '1219'; // ← 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; @@ -137,7 +137,8 @@ const App = (() => { let lastForce = 0; try { lastForce = parseInt(localStorage.getItem('by_last_force_update') || '0', 10); } catch {} const cooldownActive = (Date.now() - lastForce) < 10 * 60 * 1000; - if (!modalOpen && !cooldownActive) { + // Während einer laufenden Aufzeichnung NIE force-updaten (Datenverlust). + if (!modalOpen && !cooldownActive && !window._byRecording) { window._byUpdatePending = false; sessionStorage.setItem('by_updated_to', window._byNewVersion || ''); sessionStorage.setItem('by_update_target', pageId); @@ -294,16 +295,27 @@ const App = (() => { }); page.module = {}; // verhindert erneutes Laden } - } catch { + } catch (err) { + // Echten Fehler NICHT verschlucken — sonst rätselt man bei jedem Seiten-Crash + console.error(`[page-load] ${pageId} init fehlgeschlagen:`, err); const _offline = !navigator.onLine; container.innerHTML = UI.emptyState({ - icon: _offline ? '📡' : '🚧', + icon: _offline ? '📡' : '⚠️', title: pages[pageId].title, text: _offline ? 'Diese Seite ist offline nicht verfügbar. Bitte öffne sie einmal mit Internetverbindung, damit sie gecacht wird.' - : 'Diese Seite ist noch in Entwicklung.', + : 'Die Seite konnte nicht geladen werden. Das passiert manchmal nach einem Update.', + action: _offline ? '' : + ``, }); - page.module = {}; + document.getElementById('page-retry-btn')?.addEventListener('click', () => { + page._loading = false; + navigate(pageId, false, params); + }); + // WICHTIG: page.module NICHT auf {} setzen. Bei einem echten Fehler (Netz-Blip, + // SW-Update mitten in der Navigation, Race) würde {} die Seite für die ganze + // Session tot stellen — der Guard `if (page.module)` käme nie mehr zum Laden. + // So wird beim nächsten Aufruf neu versucht und ein transienter Fehler heilt sich. } finally { page._loading = false; } @@ -434,6 +446,62 @@ const App = (() => { // NAVIGATION EVENTS // ---------------------------------------------------------- function _bindNavigation() { + // Globaler Bild-Fallback — ersetzt CSP-blockierte onerror-Attribute. + // 'error' bubbelt nicht → Capture-Phase. Greift nur bei [data-fb]/[data-fb-src]. + document.addEventListener('error', e => { + const el = e.target; + if (!el || el.tagName !== 'IMG') return; + const fb = el.dataset.fb, altSrc = el.dataset.fbSrc; + if (fb === undefined && altSrc === undefined) return; + // Schritt 1: Alternative Quelle versuchen (z.B. _preview → Original / Platzhalter) + if (altSrc && !el.dataset.fbTried) { + el.dataset.fbTried = '1'; + el.src = altSrc; + return; + } + // Schritt 2: terminaler Fallback + switch (fb) { + case 'hide-parent': + if (el.parentElement) el.parentElement.style.display = 'none'; + break; + case 'dim-grandparent': + if (el.parentElement?.parentElement) el.parentElement.parentElement.style.opacity = '.4'; + break; + case 'sibling': + el.style.display = 'none'; + if (el.nextElementSibling) el.nextElementSibling.style.display = 'flex'; + break; + case 'show-el': { + el.style.display = 'none'; + const t = el.dataset.fbEl && document.getElementById(el.dataset.fbEl); + if (t) t.style.display = 'flex'; + break; + } + case 'emoji': + if (el.parentElement) el.parentElement.innerHTML = + `
${el.dataset.fbEmoji || '🐾'}
`; + break; + case 'initials': { + const sz = parseInt(el.dataset.fbSize, 10) || 40; + el.outerHTML = + `
${el.dataset.fbInitials || ''}
`; + break; + } + default: // 'hide' + el.style.display = 'none'; + el.classList.add('img-broken'); + } + }, true); + + // Video-Vorschau bei Hover (ersetzt CSP-blockierte onmouseenter/leave). + //