diff --git a/.gitignore b/.gitignore index 8319981..cbcf3ae 100644 --- a/.gitignore +++ b/.gitignore @@ -13,11 +13,3 @@ __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 4c7cefe..c16bf3f 100644 --- a/Makefile +++ b/Makefile @@ -24,11 +24,10 @@ 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 tiles tiles-deploy + logs logs-f shell db dev clean-cache check-ssh reports bump test # ---------------------------------------------------------- # SSH-Prüfung — Abhängigkeit aller DS-Befehle @@ -140,56 +139,6 @@ 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 64e9c64..8535dde 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1219 \ No newline at end of file +1161 \ No newline at end of file diff --git a/backend/main.py b/backend/main.py index bf4f74b..f4ecac6 100644 --- a/backend/main.py +++ b/backend/main.py @@ -111,7 +111,6 @@ 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:; " @@ -372,100 +371,6 @@ 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 58d03b4..33eb726 100644 --- a/backend/routes/forum.py +++ b/backend/routes/forum.py @@ -166,22 +166,8 @@ 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, 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() - +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.""" # 30-Sekunden-Cooldown zwischen beliebigen Posts last = conn.execute( """SELECT MAX(created_at) AS last FROM ( @@ -193,25 +179,25 @@ def _check_post_limits(user_id: int, conn, text: str, user_created_at: str | Non ).fetchone()["last"] if last: try: - diff = (now_dt - _dt.fromisoformat(last)).total_seconds() - if 0 <= diff < 30: + from datetime import datetime as _dt + diff = (_dt.utcnow() - _dt.fromisoformat(last)).total_seconds() + if diff < 30: raise HTTPException(429, "Bitte warte einen Moment bevor du erneut postest.") except (ValueError, TypeError): pass - # Stunden-Limit (gleiche Zeitbasis wie created_at) - hour_ago = (now_dt - _td(hours=1)).strftime("%Y-%m-%d %H:%M:%S") + # Stunden-Limit if is_thread: count = conn.execute( - "SELECT COUNT(*) FROM forum_threads WHERE user_id=? AND created_at > ?", - (user_id, hour_ago), + "SELECT COUNT(*) FROM forum_threads WHERE user_id=? AND created_at > datetime('now','-1 hour')", + (user_id,), ).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 > ?", - (user_id, hour_ago), + "SELECT COUNT(*) FROM forum_posts WHERE user_id=? AND created_at > datetime('now','-1 hour')", + (user_id,), ).fetchone()[0] if count >= 20: raise HTTPException(429, "Du hast in dieser Stunde bereits 20 Antworten geschrieben. Bitte warte etwas.") @@ -237,8 +223,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 (?, ?, ?, ?, ?, ?, ?, ?)""", @@ -384,9 +370,9 @@ async def create_post(thread_id: int, data: PostCreate, user=Depends(get_current if thread['is_deleted']: raise HTTPException(404, "Thread nicht gefunden.") - 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) + _check_post_limits(user["id"], conn, data.text.strip(), user.get("created_at"), is_thread=False) + ct = safe_client_time(data.client_time) 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 f840ec8..3762413 100644 --- a/backend/routes/profile.py +++ b/backend/routes/profile.py @@ -149,74 +149,25 @@ 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 (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. - """ + """Löscht das Konto und alle zugehörigen Daten unwiderruflich.""" uid = user['id'] with db() as conn: - # 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. + # 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,)) 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 87dea9c..1f22ba8 100644 --- a/backend/static/css/components.css +++ b/backend/static/css/components.css @@ -2514,6 +2514,115 @@ 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) ============================================================ */ @@ -3023,16 +3132,6 @@ 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; @@ -3052,51 +3151,6 @@ 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; @@ -6799,6 +6853,15 @@ 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 f38a562..f9f5ec2 100644 --- a/backend/static/css/utilities.css +++ b/backend/static/css/utilities.css @@ -63,14 +63,3 @@ 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 1770205..950d38a 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 6d91f3c..23c0284 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 = '1219'; // ← bei jedem Deploy mit Frontend-Änderungen erhöhen +const APP_VER = '1161'; // ← 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,8 +137,7 @@ 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; - // Während einer laufenden Aufzeichnung NIE force-updaten (Datenverlust). - if (!modalOpen && !cooldownActive && !window._byRecording) { + if (!modalOpen && !cooldownActive) { window._byUpdatePending = false; sessionStorage.setItem('by_updated_to', window._byNewVersion || ''); sessionStorage.setItem('by_update_target', pageId); @@ -295,27 +294,16 @@ const App = (() => { }); page.module = {}; // verhindert erneutes Laden } - } catch (err) { - // Echten Fehler NICHT verschlucken — sonst rätselt man bei jedem Seiten-Crash - console.error(`[page-load] ${pageId} init fehlgeschlagen:`, err); + } catch { 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.' - : 'Die Seite konnte nicht geladen werden. Das passiert manchmal nach einem Update.', - action: _offline ? '' : - ``, + : 'Diese Seite ist noch in Entwicklung.', }); - 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. + page.module = {}; } finally { page._loading = false; } @@ -446,62 +434,6 @@ 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). - //