Sprint 10: OSM-POI-Cache, Karten-Clustering, Routen-Redesign
Karte (map.js):
- OSM Overpass API: Restaurants, Tierärzte, Parkplätze, Bänke, Wasserstellen
- Leaflet.markercluster für alle OSM-Layer
- Standort-Dot mit GPS-Genauigkeitskreis, Wake-Lock bei Aufzeichnung
- Community-Pins setzen/löschen, Meldungen, Crosshair-Placement
- Layer-Sichtbarkeit in localStorage (by_map_visible_v1)
Routen (routes.js + routen.py):
- Komoot-Stil: SVG-Track-Preview, Foto-Upload, Nearby-POIs im Detail-Modal
- Neue Felder: is_public, hunde_tauglichkeit, foto_urls
- Rate-Endpoint (POST /api/routes/{id}/rate)
- Foto-Upload (POST /api/routes/{id}/photo)
- Fix: json_extract $[-1] → $[#-1] (SQLite-kompatibler Pfad für letztes Element)
Backend (osm.py, database.py, scheduler.py):
- /api/osm/pois: OSM-Overpass-Cache mit Tile-Logik (14 Tage TTL)
- /api/osm/user-poi: Community-Marker CRUD
- /api/osm/report: Marker als ungültig melden
- Neue Tabellen: osm_pois, osm_tiles, user_map_pois, osm_reports
- Giftköder-Archiv-Job (täglich 03:00, soft-delete nach Ablauf)
- Giftköder-Archiv-Job als APScheduler-CronJob
UI: Orte-Menüpunkt entfernt (in Karte integriert), APP_VER auf 62
This commit is contained in:
parent
bf26e5faf4
commit
ebe4ce20cf
16 changed files with 3020 additions and 737 deletions
9
Makefile
9
Makefile
|
|
@ -107,10 +107,9 @@ deploy-clean: check-ssh
|
|||
@ssh $(DS_HOST) "$(DOCKER) logs $(CONTAINER) --tail=15"
|
||||
|
||||
# ----------------------------------------------------------
|
||||
# SYNC — nur Dateien übertragen, kein Docker
|
||||
# ACHTUNG: Statische Dateien (CSS/JS/HTML) sind ins Image gebacken!
|
||||
# sync+restart reicht NUR für Python-Änderungen (routes/*.py etc.)
|
||||
# Für Frontend-Änderungen immer: make deploy
|
||||
# SYNC — nur Dateien zur DS übertragen, kein Docker-Rebuild
|
||||
# ACHTUNG: ALLE Dateien (CSS/JS/HTML/Python) sind ins Image gebacken!
|
||||
# sync+restart reicht für NICHTS — immer: make deploy
|
||||
# ----------------------------------------------------------
|
||||
sync: check-ssh
|
||||
@echo "→ Sync zu DS..."
|
||||
|
|
@ -125,7 +124,7 @@ push:
|
|||
|
||||
# ----------------------------------------------------------
|
||||
# RESTART — kein Rebuild, nur Container neu starten
|
||||
# Nach sync von reinen Frontend-Änderungen ausreichend
|
||||
# Reicht nur für Umgebungsvariablen-Änderungen (.env)
|
||||
# ----------------------------------------------------------
|
||||
restart: check-ssh
|
||||
@ssh $(DS_HOST) " \
|
||||
|
|
|
|||
|
|
@ -295,6 +295,50 @@ def init_db():
|
|||
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
|
||||
-- OSM POI-Cache (Mülleimer, Hundewiesen, Wasserstellen aus OpenStreetMap)
|
||||
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,
|
||||
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);
|
||||
|
||||
-- OSM Tile-Cache: welche Kacheln wurden schon geladen?
|
||||
CREATE TABLE IF NOT EXISTS osm_tiles (
|
||||
type TEXT NOT NULL,
|
||||
tile_key TEXT NOT NULL, -- "zoom_x_y"
|
||||
cached_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
PRIMARY KEY (type, tile_key)
|
||||
);
|
||||
|
||||
-- Community-Pins: von Nutzern gesetzte Karten-Marker
|
||||
CREATE TABLE IF NOT EXISTS user_map_pois (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER REFERENCES users(id) ON DELETE SET NULL,
|
||||
type TEXT NOT NULL, -- waste_basket | drinking_water | dog_park | sonstiges
|
||||
lat REAL NOT NULL,
|
||||
lon REAL NOT NULL,
|
||||
name TEXT,
|
||||
notiz TEXT,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_user_pois_loc ON user_map_pois(type, lat, lon);
|
||||
|
||||
-- Meldungen: ungültige OSM- oder Community-Marker
|
||||
CREATE TABLE IF NOT EXISTS osm_reports (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER REFERENCES users(id) ON DELETE SET NULL,
|
||||
osm_id INTEGER, -- gesetzt wenn OSM-Marker gemeldet
|
||||
user_poi_id INTEGER, -- gesetzt wenn Community-Marker gemeldet
|
||||
type TEXT,
|
||||
grund TEXT NOT NULL, -- existiert_nicht | falsche_position | spam | sonstiges
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
|
||||
-- TIERÄRZTE (user-level, nie löschen — Historien-Erhalt bei Umzug)
|
||||
CREATE TABLE IF NOT EXISTS tieraerzte (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
|
|
@ -349,6 +393,14 @@ def _migrate(conn_factory):
|
|||
("tieraerzte", "ort", "TEXT"),
|
||||
# Gesundheit: Erinnerungsintervall für wiederkehrende Einträge
|
||||
("health", "intervall_tage", "INTEGER"),
|
||||
# Routen: neue Felder
|
||||
("routes", "is_public", "INTEGER NOT NULL DEFAULT 1"),
|
||||
("routes", "hunde_tauglichkeit", "TEXT"),
|
||||
("routes", "foto_urls", "TEXT NOT NULL DEFAULT '[]'"),
|
||||
# OSM POIs: Kontaktdaten
|
||||
("osm_pois", "opening_hours", "TEXT"),
|
||||
("osm_pois", "phone", "TEXT"),
|
||||
("osm_pois", "website", "TEXT"),
|
||||
]
|
||||
with conn_factory() as conn:
|
||||
for table, column, col_type in migrations:
|
||||
|
|
|
|||
|
|
@ -62,6 +62,7 @@ from routes.routen import router as routen_router
|
|||
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
|
||||
|
||||
app.include_router(auth_router, prefix="/api/auth", tags=["Auth"])
|
||||
app.include_router(dogs_router, prefix="/api/dogs", tags=["Hunde"])
|
||||
|
|
@ -76,6 +77,7 @@ app.include_router(routen_router, prefix="/api/routes", tags=["Routen"])
|
|||
app.include_router(walks_router, prefix="/api/walks", tags=["Gassi-Treffen"])
|
||||
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"])
|
||||
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
|
|
@ -129,49 +131,6 @@ async def share_target(request: Request):
|
|||
headers={"Cache-Control": "no-cache"}
|
||||
)
|
||||
|
||||
# Cache-Reset-Seite — löscht SW + Caches, leitet zur App weiter
|
||||
@app.get("/update")
|
||||
async def force_update():
|
||||
from fastapi.responses import HTMLResponse
|
||||
html = """<!DOCTYPE html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Ban Yaro — Aktualisieren</title>
|
||||
<style>
|
||||
body{font-family:-apple-system,sans-serif;display:flex;align-items:center;
|
||||
justify-content:center;min-height:100vh;margin:0;background:#faf8f5}
|
||||
.box{text-align:center;padding:2rem}
|
||||
h1{color:#C4843A;margin-bottom:.5rem}
|
||||
p{color:#666;margin-bottom:1.5rem}
|
||||
.sp{width:44px;height:44px;border:4px solid #eee;border-top-color:#C4843A;
|
||||
border-radius:50%;animation:s .8s linear infinite;margin:0 auto}
|
||||
@keyframes s{to{transform:rotate(360deg)}}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="box">
|
||||
<h1>Ban Yaro</h1>
|
||||
<p>App wird aktualisiert…</p>
|
||||
<div class="sp"></div>
|
||||
</div>
|
||||
<script>
|
||||
(async () => {
|
||||
if ('serviceWorker' in navigator) {
|
||||
const regs = await navigator.serviceWorker.getRegistrations();
|
||||
await Promise.all(regs.map(r => r.unregister()));
|
||||
}
|
||||
const keys = await caches.keys();
|
||||
await Promise.all(keys.map(k => caches.delete(k)));
|
||||
window.location.replace('/');
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>"""
|
||||
return HTMLResponse(html, headers={"Cache-Control": "no-store"})
|
||||
|
||||
|
||||
# SPA Fallback — ALLE nicht-API-Routen gehen zur index.html
|
||||
@app.get("/{full_path:path}")
|
||||
async def spa_fallback(full_path: str):
|
||||
|
|
|
|||
317
backend/routes/osm.py
Normal file
317
backend/routes/osm.py
Normal file
|
|
@ -0,0 +1,317 @@
|
|||
"""
|
||||
BAN YARO — OSM/Overpass POI-Cache + Community-Pins
|
||||
Cacht OSM-Daten lokal, erlaubt Nutzern eigene Marker und Meldungen.
|
||||
"""
|
||||
|
||||
import math
|
||||
import httpx
|
||||
import logging
|
||||
from typing import Optional
|
||||
from fastapi import APIRouter, Query, BackgroundTasks, Depends, HTTPException
|
||||
from pydantic import BaseModel
|
||||
from database import db
|
||||
from auth import get_current_user, get_current_user_optional as get_optional_user
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter()
|
||||
|
||||
CACHE_ZOOM = 12
|
||||
CACHE_DAYS = 14
|
||||
OVERPASS_URL = 'https://overpass-api.de/api/interpreter'
|
||||
|
||||
OSM_QUERIES = {
|
||||
'waste_basket': '[out:json][timeout:20];node["amenity"="waste_basket"]({bbox});out;',
|
||||
'dog_park': '[out:json][timeout:25];(way["leisure"="dog_park"]({bbox});node["leisure"="dog_park"]({bbox});way["leisure"="park"]["dog"="yes"]({bbox});node["leisure"="park"]["dog"="yes"]({bbox}););out center;',
|
||||
'drinking_water': '[out:json][timeout:20];node["amenity"="drinking_water"]({bbox});out;',
|
||||
'tierarzt': '[out:json][timeout:25];(node["amenity"="veterinary"]({bbox});way["amenity"="veterinary"]({bbox}););out center;',
|
||||
'shop': '[out:json][timeout:25];(node["shop"="pet"]({bbox});way["shop"="pet"]({bbox}););out center;',
|
||||
'restaurant': '[out:json][timeout:35];(node["amenity"="restaurant"]({bbox});way["amenity"="restaurant"]({bbox});node["amenity"="cafe"]({bbox});way["amenity"="cafe"]({bbox});node["amenity"="biergarten"]({bbox});way["amenity"="biergarten"]({bbox}););out center;',
|
||||
'bank': '[out:json][timeout:20];node["amenity"="bench"]({bbox});out;',
|
||||
}
|
||||
|
||||
# Ab dieser Anzahl Meldungen wird ein Marker ausgeblendet
|
||||
REPORT_THRESHOLD = 3
|
||||
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Tile-Mathematik
|
||||
# ------------------------------------------------------------------
|
||||
def _lat_lon_to_tile(lat, lon, zoom):
|
||||
n = 2 ** zoom
|
||||
x = int((lon + 180) / 360 * n)
|
||||
y = int((1 - math.asinh(math.tan(math.radians(lat))) / math.pi) / 2 * n)
|
||||
return x, y
|
||||
|
||||
def _tile_to_bbox(x, y, zoom):
|
||||
n = 2 ** zoom
|
||||
west = x / n * 360 - 180
|
||||
east = (x + 1) / n * 360 - 180
|
||||
north = math.degrees(math.atan(math.sinh(math.pi * (1 - 2 * y / n))))
|
||||
south = math.degrees(math.atan(math.sinh(math.pi * (1 - 2 * (y + 1) / n))))
|
||||
return south, west, north, east
|
||||
|
||||
def _covering_tiles(south, west, north, east, zoom):
|
||||
x0, y0 = _lat_lon_to_tile(north, west, zoom)
|
||||
x1, y1 = _lat_lon_to_tile(south, east, zoom)
|
||||
return [(x, y) for x in range(x0, x1 + 1) for y in range(y0, y1 + 1)]
|
||||
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Overpass-Fetch + Cache
|
||||
# ------------------------------------------------------------------
|
||||
async def _fetch_overpass(query):
|
||||
async with httpx.AsyncClient(timeout=40) as client:
|
||||
r = await client.post(OVERPASS_URL, data={'data': query})
|
||||
r.raise_for_status()
|
||||
return r.json().get('elements', [])
|
||||
|
||||
def _stale_tiles(poi_type, tiles):
|
||||
stale = []
|
||||
with db() as conn:
|
||||
for (x, y) in tiles:
|
||||
key = f"{CACHE_ZOOM}_{x}_{y}"
|
||||
row = conn.execute(
|
||||
"""SELECT 1 FROM osm_tiles WHERE type=? AND tile_key=?
|
||||
AND cached_at > datetime('now', ?)""",
|
||||
(poi_type, key, f'-{CACHE_DAYS} days')
|
||||
).fetchone()
|
||||
if not row:
|
||||
stale.append((x, y))
|
||||
return stale
|
||||
|
||||
async def _fetch_and_store_tile(poi_type, x, y):
|
||||
key = f"{CACHE_ZOOM}_{x}_{y}"
|
||||
s, w, n, e = _tile_to_bbox(x, y, CACHE_ZOOM)
|
||||
query = OSM_QUERIES[poi_type].format(bbox=f"{s},{w},{n},{e}")
|
||||
try:
|
||||
elements = await _fetch_overpass(query)
|
||||
except Exception as exc:
|
||||
logger.warning(f"Overpass Fehler {poi_type} Tile {key}: {exc}")
|
||||
return
|
||||
with db() as conn:
|
||||
for el in elements:
|
||||
osm_id = el.get('id')
|
||||
lat = el.get('lat') or (el.get('center') or {}).get('lat')
|
||||
lon = el.get('lon') or (el.get('center') or {}).get('lon')
|
||||
if not (osm_id and lat and lon):
|
||||
continue
|
||||
tags = el.get('tags') or {}
|
||||
name = tags.get('name') or tags.get('description')
|
||||
opening_hours = tags.get('opening_hours')
|
||||
phone = tags.get('phone') or tags.get('contact:phone') or tags.get('telephone')
|
||||
website = tags.get('website') or tags.get('contact:website') or tags.get('url')
|
||||
conn.execute("""
|
||||
INSERT INTO osm_pois (osm_id, type, lat, lon, name, opening_hours, phone, website, cached_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, datetime('now'))
|
||||
ON CONFLICT(osm_id, type) DO UPDATE SET
|
||||
lat=excluded.lat, lon=excluded.lon,
|
||||
name=excluded.name,
|
||||
opening_hours=excluded.opening_hours,
|
||||
phone=excluded.phone,
|
||||
website=excluded.website,
|
||||
cached_at=excluded.cached_at
|
||||
""", (osm_id, poi_type, lat, lon, name, opening_hours, phone, website))
|
||||
conn.execute("""
|
||||
INSERT INTO osm_tiles (type, tile_key, cached_at)
|
||||
VALUES (?, ?, datetime('now'))
|
||||
ON CONFLICT(type, tile_key) DO UPDATE SET cached_at=excluded.cached_at
|
||||
""", (poi_type, key))
|
||||
logger.info(f"OSM Tile {key} ({poi_type}): {len(elements)} POIs gecacht.")
|
||||
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# GET /pois — OSM + Community-Pins
|
||||
# fast=true: nur DB, kein Overpass-Fetch (sofortantwort)
|
||||
# fast=false (default): fetcht Overpass wenn Tiles veraltet sind
|
||||
# ------------------------------------------------------------------
|
||||
@router.get('/pois')
|
||||
async def get_pois(
|
||||
type: str = Query(...),
|
||||
south: float = Query(...),
|
||||
west: float = Query(...),
|
||||
north: float = Query(...),
|
||||
east: float = Query(...),
|
||||
fast: bool = Query(False),
|
||||
user = Depends(get_optional_user),
|
||||
):
|
||||
result = []
|
||||
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:
|
||||
for (x, y) in stale:
|
||||
await _fetch_and_store_tile(type, x, y)
|
||||
fetched_fresh = True
|
||||
|
||||
with db() as conn:
|
||||
reported = {
|
||||
row[0] for row in conn.execute(
|
||||
"""SELECT osm_id FROM osm_reports
|
||||
WHERE type=? AND osm_id IS NOT NULL
|
||||
GROUP BY osm_id HAVING COUNT(*) >= ?""",
|
||||
(type, REPORT_THRESHOLD)
|
||||
).fetchall()
|
||||
}
|
||||
rows = conn.execute("""
|
||||
SELECT osm_id, lat, lon, name, opening_hours, phone, website FROM osm_pois
|
||||
WHERE type=? AND lat BETWEEN ? AND ? AND lon BETWEEN ? AND ?
|
||||
""", (type, south, north, west, east)).fetchall()
|
||||
|
||||
for r in rows:
|
||||
if r['osm_id'] not in reported:
|
||||
result.append({
|
||||
'id': r['osm_id'],
|
||||
'lat': r['lat'],
|
||||
'lon': r['lon'],
|
||||
'name': r['name'],
|
||||
'opening_hours': r['opening_hours'],
|
||||
'phone': r['phone'],
|
||||
'website': r['website'],
|
||||
'source': 'osm',
|
||||
'fresh': fetched_fresh,
|
||||
})
|
||||
|
||||
# Community-Pins: user_map_pois passend zum Typ
|
||||
# type='sonstiges' → zeigt alle 'sonstiges'-Pins
|
||||
# type='waste_basket' etc. → zeigt user-submitted POIs dieses Typs
|
||||
user_poi_type = type # direkte Übereinstimmung
|
||||
with db() as conn:
|
||||
reported_user = {
|
||||
row[0] for row in conn.execute(
|
||||
"""SELECT user_poi_id FROM osm_reports
|
||||
WHERE user_poi_id IS NOT NULL
|
||||
GROUP BY user_poi_id HAVING COUNT(*) >= ?""",
|
||||
(REPORT_THRESHOLD,)
|
||||
).fetchall()
|
||||
}
|
||||
user_pois = conn.execute("""
|
||||
SELECT p.id, p.lat, p.lon, p.name, p.notiz, p.user_id, p.type,
|
||||
u.name AS username
|
||||
FROM user_map_pois p
|
||||
LEFT JOIN users u ON u.id = p.user_id
|
||||
WHERE p.type=? AND p.lat BETWEEN ? AND ? AND p.lon BETWEEN ? AND ?
|
||||
""", (user_poi_type, south, north, west, east)).fetchall()
|
||||
|
||||
user_id = user['id'] if user else None
|
||||
for p in user_pois:
|
||||
if p['id'] not in reported_user:
|
||||
result.append({
|
||||
'id': f"u{p['id']}",
|
||||
'user_poi_id': p['id'],
|
||||
'lat': p['lat'],
|
||||
'lon': p['lon'],
|
||||
'name': p['name'],
|
||||
'notiz': p['notiz'],
|
||||
'username': p['username'],
|
||||
'source': 'user',
|
||||
'own': p['user_id'] == user_id,
|
||||
})
|
||||
|
||||
return result
|
||||
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# POST /user-poi — Community-Marker setzen
|
||||
# ------------------------------------------------------------------
|
||||
class UserPoiIn(BaseModel):
|
||||
type: str
|
||||
lat: float
|
||||
lon: float
|
||||
name: Optional[str] = None
|
||||
notiz: Optional[str] = None
|
||||
|
||||
ALLOWED_TYPES = {
|
||||
'waste_basket', 'drinking_water', 'dog_park',
|
||||
'giftkoeder', # Giftköder-Meldung (Community-Pin mit Radius)
|
||||
'kotbeutel', # Kotbeutelspender
|
||||
'gefahr', # Allgemeine Gefahr / Hinweis
|
||||
'parkplatz', # Hundefreundlicher Parkplatz
|
||||
'treffpunkt', # Treffpunkt für Hundehalter
|
||||
'sonstiges',
|
||||
}
|
||||
|
||||
@router.post('/user-poi')
|
||||
async def add_user_poi(body: UserPoiIn, user = Depends(get_current_user)):
|
||||
if body.type not in ALLOWED_TYPES:
|
||||
raise HTTPException(400, 'Ungültiger Typ')
|
||||
with db() as conn:
|
||||
row = conn.execute("""
|
||||
INSERT INTO user_map_pois (user_id, type, lat, lon, name, notiz)
|
||||
VALUES (?, ?, ?, ?, ?, ?)
|
||||
""", (user['id'], body.type, body.lat, body.lon, body.name, body.notiz))
|
||||
new_id = row.lastrowid
|
||||
return {'id': new_id, 'status': 'ok'}
|
||||
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# DELETE /user-poi/{id} — eigenen Marker löschen
|
||||
# ------------------------------------------------------------------
|
||||
@router.delete('/user-poi/{poi_id}')
|
||||
async def delete_user_poi(poi_id: int, user = Depends(get_current_user)):
|
||||
with db() as conn:
|
||||
row = conn.execute(
|
||||
"SELECT user_id FROM user_map_pois WHERE id=?", (poi_id,)
|
||||
).fetchone()
|
||||
if not row:
|
||||
raise HTTPException(404, 'Nicht gefunden')
|
||||
if row['user_id'] != user['id']:
|
||||
raise HTTPException(403, 'Nicht berechtigt')
|
||||
conn.execute("DELETE FROM user_map_pois WHERE id=?", (poi_id,))
|
||||
return {'status': 'ok'}
|
||||
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# POST /report — Marker als ungültig melden
|
||||
# ------------------------------------------------------------------
|
||||
class ReportIn(BaseModel):
|
||||
type: str
|
||||
grund: str
|
||||
osm_id: Optional[int] = None
|
||||
user_poi_id: Optional[int] = None
|
||||
|
||||
ALLOWED_GRUENDE = {'existiert_nicht', 'falsche_position', 'spam', 'sonstiges'}
|
||||
|
||||
@router.post('/report')
|
||||
async def report_poi(body: ReportIn, user = Depends(get_current_user)):
|
||||
if not body.osm_id and not body.user_poi_id:
|
||||
raise HTTPException(400, 'osm_id oder user_poi_id erforderlich')
|
||||
if body.grund not in ALLOWED_GRUENDE:
|
||||
raise HTTPException(400, 'Ungültiger Grund')
|
||||
with db() as conn:
|
||||
# Doppelmeldung vom selben User verhindern
|
||||
existing = conn.execute("""
|
||||
SELECT 1 FROM osm_reports
|
||||
WHERE user_id=? AND osm_id IS ? AND user_poi_id IS ?
|
||||
""", (user['id'], body.osm_id, body.user_poi_id)).fetchone()
|
||||
if existing:
|
||||
return {'status': 'bereits_gemeldet'}
|
||||
conn.execute("""
|
||||
INSERT INTO osm_reports (user_id, osm_id, user_poi_id, type, grund)
|
||||
VALUES (?, ?, ?, ?, ?)
|
||||
""", (user['id'], body.osm_id, body.user_poi_id, body.type, body.grund))
|
||||
return {'status': 'ok'}
|
||||
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# POST /analyze — Cache-Warmup für alle Typen
|
||||
# ------------------------------------------------------------------
|
||||
@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():
|
||||
for poi_type in OSM_QUERIES:
|
||||
for (x, y) in _stale_tiles(poi_type, tiles):
|
||||
await _fetch_and_store_tile(poi_type, x, y)
|
||||
|
||||
background_tasks.add_task(_warmup)
|
||||
return {'status': 'gestartet', 'tiles': len(tiles), 'types': list(OSM_QUERIES.keys())}
|
||||
|
|
@ -1,11 +1,11 @@
|
|||
"""BAN YARO — Gassi-Routen"""
|
||||
|
||||
import json, math
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
import json, math, os, uuid
|
||||
from fastapi import APIRouter, Depends, HTTPException, UploadFile, File
|
||||
from pydantic import BaseModel
|
||||
from typing import Optional, List
|
||||
from database import db
|
||||
from auth import get_current_user
|
||||
from auth import get_current_user, get_current_user_optional
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
|
@ -37,6 +37,8 @@ class RouteCreate(BaseModel):
|
|||
untergrund: Optional[str] = None # wald | asphalt | wiese | mix
|
||||
schatten: Optional[bool] = None
|
||||
leine_empfohlen: Optional[bool] = None
|
||||
is_public: Optional[bool] = True
|
||||
hunde_tauglichkeit: Optional[str] = None # eingeschränkt | gut | sehr_gut | premium
|
||||
|
||||
class RouteUpdate(BaseModel):
|
||||
name: Optional[str] = None
|
||||
|
|
@ -45,15 +47,27 @@ class RouteUpdate(BaseModel):
|
|||
untergrund: Optional[str] = None
|
||||
schatten: Optional[bool] = None
|
||||
leine_empfohlen: Optional[bool] = None
|
||||
is_public: Optional[bool] = None
|
||||
hunde_tauglichkeit: Optional[str] = None
|
||||
|
||||
|
||||
def _simplify_track(track: list, max_pts: int = 40) -> list:
|
||||
"""Reduziert GPS-Track auf max_pts Punkte für Vorschau."""
|
||||
if len(track) <= max_pts:
|
||||
return track
|
||||
step = len(track) / max_pts
|
||||
return [track[round(i * step)] for i in range(max_pts)]
|
||||
|
||||
|
||||
def _parse(row) -> dict:
|
||||
d = dict(row)
|
||||
if isinstance(d.get('gps_track'), str):
|
||||
d['gps_track'] = json.loads(d['gps_track'])
|
||||
for k in ('schatten', 'leine_empfohlen'):
|
||||
for k in ('schatten', 'leine_empfohlen', 'is_public'):
|
||||
if d.get(k) is not None:
|
||||
d[k] = bool(d[k])
|
||||
if isinstance(d.get('foto_urls'), str):
|
||||
d['foto_urls'] = json.loads(d['foto_urls'])
|
||||
return d
|
||||
|
||||
|
||||
|
|
@ -65,6 +79,7 @@ async def list_routes(
|
|||
lat: Optional[float] = None,
|
||||
lon: Optional[float] = None,
|
||||
radius: int = 10000,
|
||||
user = Depends(get_current_user_optional),
|
||||
):
|
||||
with db() as conn:
|
||||
rows = conn.execute("""
|
||||
|
|
@ -72,11 +87,12 @@ async def list_routes(
|
|||
r.distanz_km, r.dauer_min, r.schwierigkeit,
|
||||
r.untergrund, r.schatten, r.leine_empfohlen,
|
||||
r.bewertung, r.anz_bewertungen, r.created_at,
|
||||
r.gps_track, r.is_public, r.hunde_tauglichkeit, r.foto_urls,
|
||||
u.name AS user_name,
|
||||
json_extract(r.gps_track, '$[0].lat') AS start_lat,
|
||||
json_extract(r.gps_track, '$[0].lon') AS start_lon,
|
||||
json_extract(r.gps_track, '$[-1].lat') AS end_lat,
|
||||
json_extract(r.gps_track, '$[-1].lon') AS end_lon
|
||||
json_extract(r.gps_track, '$[#-1].lat') AS end_lat,
|
||||
json_extract(r.gps_track, '$[#-1].lon') AS end_lon
|
||||
FROM routes r
|
||||
LEFT JOIN users u ON u.id = r.user_id
|
||||
ORDER BY r.created_at DESC
|
||||
|
|
@ -85,9 +101,14 @@ async def list_routes(
|
|||
result = []
|
||||
for row in rows:
|
||||
d = dict(row)
|
||||
for k in ('schatten', 'leine_empfohlen'):
|
||||
for k in ('schatten', 'leine_empfohlen', 'is_public'):
|
||||
if d.get(k) is not None:
|
||||
d[k] = bool(d[k])
|
||||
if isinstance(d.get('foto_urls'), str):
|
||||
d['foto_urls'] = json.loads(d['foto_urls'])
|
||||
raw_track = json.loads(d.get('gps_track') or '[]')
|
||||
d['preview_track'] = _simplify_track(raw_track, 40)
|
||||
del d['gps_track']
|
||||
result.append(d)
|
||||
|
||||
if lat is not None and lon is not None:
|
||||
|
|
@ -95,6 +116,9 @@ async def list_routes(
|
|||
r for r in result
|
||||
if r['start_lat'] and _haversine(lat, lon, r['start_lat'], r['start_lon']) <= radius
|
||||
]
|
||||
|
||||
user_id = user['id'] if user else None
|
||||
result = [r for r in result if r.get('is_public', True) or r.get('user_id') == user_id]
|
||||
return result
|
||||
|
||||
|
||||
|
|
@ -112,13 +136,15 @@ async def create_route(data: RouteCreate, user=Depends(get_current_user)):
|
|||
cur = conn.execute("""
|
||||
INSERT INTO routes
|
||||
(user_id, name, beschreibung, gps_track, distanz_km, dauer_min,
|
||||
schwierigkeit, untergrund, schatten, leine_empfohlen)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
schwierigkeit, untergrund, schatten, leine_empfohlen, is_public, hunde_tauglichkeit)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
""", (
|
||||
user['id'], data.name, data.beschreibung, gps_json,
|
||||
data.distanz_km, data.dauer_min, data.schwierigkeit, data.untergrund,
|
||||
int(data.schatten) if data.schatten is not None else None,
|
||||
int(data.leine_empfohlen) if data.leine_empfohlen is not None else None,
|
||||
int(data.is_public) if data.is_public is not None else 1,
|
||||
data.hunde_tauglichkeit,
|
||||
))
|
||||
row = conn.execute("SELECT * FROM routes WHERE id = ?", (cur.lastrowid,)).fetchone()
|
||||
return _parse(row)
|
||||
|
|
@ -153,7 +179,7 @@ async def update_route(route_id: int, data: RouteUpdate, user=Depends(get_curren
|
|||
|
||||
updates = data.model_dump(exclude_none=True)
|
||||
if updates:
|
||||
for key in ('schatten', 'leine_empfohlen'):
|
||||
for key in ('schatten', 'leine_empfohlen', 'is_public'):
|
||||
if key in updates:
|
||||
updates[key] = int(updates[key])
|
||||
cols = ', '.join(f"{k} = ?" for k in updates)
|
||||
|
|
@ -175,3 +201,61 @@ async def delete_route(route_id: int, user=Depends(get_current_user)):
|
|||
if row['user_id'] != user['id']:
|
||||
raise HTTPException(403, "Nicht berechtigt.")
|
||||
conn.execute("DELETE FROM routes WHERE id = ?", (route_id,))
|
||||
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# POST /api/routes/{id}/rate — Bewertung abgeben
|
||||
# ------------------------------------------------------------------
|
||||
class RouteRate(BaseModel):
|
||||
wertung: float # 1–5
|
||||
|
||||
@router.post("/{route_id}/rate")
|
||||
async def rate_route(route_id: int, data: RouteRate, user=Depends(get_current_user)):
|
||||
if not 1 <= data.wertung <= 5:
|
||||
raise HTTPException(400, "Wertung muss zwischen 1 und 5 liegen.")
|
||||
with db() as conn:
|
||||
row = conn.execute("SELECT * FROM routes WHERE id = ?", (route_id,)).fetchone()
|
||||
if not row:
|
||||
raise HTTPException(404, "Route nicht gefunden.")
|
||||
r = dict(row)
|
||||
n = (r['anz_bewertungen'] or 0) + 1
|
||||
avg = ((r['bewertung'] or 0) * (n - 1) + data.wertung) / n
|
||||
conn.execute(
|
||||
"UPDATE routes SET bewertung=?, anz_bewertungen=? WHERE id=?",
|
||||
(round(avg, 2), n, route_id)
|
||||
)
|
||||
return {'bewertung': round(avg, 2), 'anz_bewertungen': n}
|
||||
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# POST /api/routes/{id}/photo — Foto hochladen
|
||||
# ------------------------------------------------------------------
|
||||
MEDIA_DIR = os.getenv("MEDIA_DIR", "/data/media")
|
||||
|
||||
@router.post("/{route_id}/photo", status_code=201)
|
||||
async def add_route_photo(
|
||||
route_id: int,
|
||||
file: UploadFile = File(...),
|
||||
user = Depends(get_current_user),
|
||||
):
|
||||
with db() as conn:
|
||||
row = conn.execute("SELECT * FROM routes WHERE id=?", (route_id,)).fetchone()
|
||||
if not row:
|
||||
raise HTTPException(404, "Route nicht gefunden.")
|
||||
if dict(row)['user_id'] != user['id']:
|
||||
raise HTTPException(403, "Nicht berechtigt.")
|
||||
|
||||
ext = os.path.splitext(file.filename or "")[1] or ".jpg"
|
||||
filename = f"route_{route_id}_{uuid.uuid4().hex[:8]}{ext}"
|
||||
path = os.path.join(MEDIA_DIR, "routes", filename)
|
||||
os.makedirs(os.path.dirname(path), exist_ok=True)
|
||||
with open(path, "wb") as f:
|
||||
f.write(await file.read())
|
||||
|
||||
foto_url = f"/media/routes/{filename}"
|
||||
with db() as conn:
|
||||
row = conn.execute("SELECT foto_urls FROM routes WHERE id=?", (route_id,)).fetchone()
|
||||
urls = json.loads(dict(row)['foto_urls'] or '[]')
|
||||
urls.append(foto_url)
|
||||
conn.execute("UPDATE routes SET foto_urls=? WHERE id=?", (json.dumps(urls), route_id))
|
||||
return {'foto_url': foto_url, 'foto_urls': urls}
|
||||
|
|
|
|||
|
|
@ -22,10 +22,17 @@ def start():
|
|||
CronTrigger(hour=8, minute=0), # täglich 08:00 Uhr
|
||||
id="health_reminders",
|
||||
replace_existing=True,
|
||||
misfire_grace_time=3600, # bis zu 1h Verzug ok (z.B. nach Neustart)
|
||||
misfire_grace_time=3600,
|
||||
)
|
||||
_scheduler.add_job(
|
||||
_job_poison_archive,
|
||||
CronTrigger(hour=3, minute=0), # täglich 03:00 Uhr (ruhige Zeit)
|
||||
id="poison_archive",
|
||||
replace_existing=True,
|
||||
misfire_grace_time=3600,
|
||||
)
|
||||
_scheduler.start()
|
||||
logger.info("Scheduler gestartet — Health-Reminder täglich 08:00 Uhr.")
|
||||
logger.info("Scheduler gestartet — Health-Reminder 08:00, Giftköder-Archiv 03:00.")
|
||||
|
||||
|
||||
def stop():
|
||||
|
|
@ -87,3 +94,31 @@ async def _job_health_reminders():
|
|||
logger.info(f"Reminder Push: user={r['user_id']} entry={r['id']} delta={delta}d")
|
||||
|
||||
logger.info(f"Health-Reminder Job fertig — {len(rows)} Einträge, {sent_total} Push gesendet.")
|
||||
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# JOB: Abgelaufene Giftköder-Meldungen archivieren
|
||||
# Abgelaufene, aber noch nicht manuell aufgelöste Einträge werden
|
||||
# sauber als geloest=1 markiert — für spätere KI-Musteranalyse.
|
||||
# Die Zeilen selbst werden NIE gelöscht.
|
||||
# ------------------------------------------------------------------
|
||||
async def _job_poison_archive():
|
||||
"""
|
||||
Findet Giftköder-Meldungen deren expires_at verstrichen ist
|
||||
und die noch nicht als geloest markiert wurden.
|
||||
Setzt geloest=1, geloest_grund='automatisch_abgelaufen'.
|
||||
"""
|
||||
from datetime import datetime
|
||||
now = datetime.utcnow().isoformat()
|
||||
with db() as conn:
|
||||
result = conn.execute("""
|
||||
UPDATE poison
|
||||
SET geloest = 1,
|
||||
geloest_at = datetime('now'),
|
||||
geloest_grund = 'automatisch_abgelaufen'
|
||||
WHERE geloest = 0
|
||||
AND expires_at < ?
|
||||
""", (now,))
|
||||
count = result.rowcount
|
||||
if count:
|
||||
logger.info(f"Giftköder-Archiv: {count} abgelaufene Meldungen archiviert.")
|
||||
|
|
|
|||
60
backend/static/css/MarkerCluster.Default.css
Normal file
60
backend/static/css/MarkerCluster.Default.css
Normal file
|
|
@ -0,0 +1,60 @@
|
|||
.marker-cluster-small {
|
||||
background-color: rgba(181, 226, 140, 0.6);
|
||||
}
|
||||
.marker-cluster-small div {
|
||||
background-color: rgba(110, 204, 57, 0.6);
|
||||
}
|
||||
|
||||
.marker-cluster-medium {
|
||||
background-color: rgba(241, 211, 87, 0.6);
|
||||
}
|
||||
.marker-cluster-medium div {
|
||||
background-color: rgba(240, 194, 12, 0.6);
|
||||
}
|
||||
|
||||
.marker-cluster-large {
|
||||
background-color: rgba(253, 156, 115, 0.6);
|
||||
}
|
||||
.marker-cluster-large div {
|
||||
background-color: rgba(241, 128, 23, 0.6);
|
||||
}
|
||||
|
||||
/* IE 6-8 fallback colors */
|
||||
.leaflet-oldie .marker-cluster-small {
|
||||
background-color: rgb(181, 226, 140);
|
||||
}
|
||||
.leaflet-oldie .marker-cluster-small div {
|
||||
background-color: rgb(110, 204, 57);
|
||||
}
|
||||
|
||||
.leaflet-oldie .marker-cluster-medium {
|
||||
background-color: rgb(241, 211, 87);
|
||||
}
|
||||
.leaflet-oldie .marker-cluster-medium div {
|
||||
background-color: rgb(240, 194, 12);
|
||||
}
|
||||
|
||||
.leaflet-oldie .marker-cluster-large {
|
||||
background-color: rgb(253, 156, 115);
|
||||
}
|
||||
.leaflet-oldie .marker-cluster-large div {
|
||||
background-color: rgb(241, 128, 23);
|
||||
}
|
||||
|
||||
.marker-cluster {
|
||||
background-clip: padding-box;
|
||||
border-radius: 20px;
|
||||
}
|
||||
.marker-cluster div {
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
margin-left: 5px;
|
||||
margin-top: 5px;
|
||||
|
||||
text-align: center;
|
||||
border-radius: 15px;
|
||||
font: 12px "Helvetica Neue", Arial, Helvetica, sans-serif;
|
||||
}
|
||||
.marker-cluster span {
|
||||
line-height: 30px;
|
||||
}
|
||||
14
backend/static/css/MarkerCluster.css
Normal file
14
backend/static/css/MarkerCluster.css
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
.leaflet-cluster-anim .leaflet-marker-icon, .leaflet-cluster-anim .leaflet-marker-shadow {
|
||||
-webkit-transition: -webkit-transform 0.3s ease-out, opacity 0.3s ease-in;
|
||||
-moz-transition: -moz-transform 0.3s ease-out, opacity 0.3s ease-in;
|
||||
-o-transition: -o-transform 0.3s ease-out, opacity 0.3s ease-in;
|
||||
transition: transform 0.3s ease-out, opacity 0.3s ease-in;
|
||||
}
|
||||
|
||||
.leaflet-cluster-spider-leg {
|
||||
/* stroke-dashoffset (duration and function) should match with leaflet-marker-icon transform in order to track it exactly */
|
||||
-webkit-transition: -webkit-stroke-dashoffset 0.3s ease-out, -webkit-stroke-opacity 0.3s ease-in;
|
||||
-moz-transition: -moz-stroke-dashoffset 0.3s ease-out, -moz-stroke-opacity 0.3s ease-in;
|
||||
-o-transition: -o-stroke-dashoffset 0.3s ease-out, -o-stroke-opacity 0.3s ease-in;
|
||||
transition: stroke-dashoffset 0.3s ease-out, stroke-opacity 0.3s ease-in;
|
||||
}
|
||||
|
|
@ -1439,126 +1439,329 @@ textarea.form-control {
|
|||
}
|
||||
|
||||
/* ============================================================
|
||||
ROUTEN (routes.js)
|
||||
ROUTEN — Komoot-Stil (routes.js)
|
||||
============================================================ */
|
||||
.routes-layout {
|
||||
.rk-layout {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
background: var(--c-bg);
|
||||
}
|
||||
.routes-tabs {
|
||||
display: flex;
|
||||
border-bottom: 2px solid var(--c-border-light);
|
||||
flex-shrink: 0;
|
||||
background: var(--c-surface);
|
||||
}
|
||||
.routes-tab {
|
||||
flex: 1;
|
||||
padding: var(--space-3) var(--space-4);
|
||||
font-size: var(--text-sm);
|
||||
font-weight: var(--weight-medium);
|
||||
color: var(--c-text-secondary);
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
border-bottom: 3px solid transparent;
|
||||
margin-bottom: -2px;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
.routes-tab.active {
|
||||
color: var(--c-primary);
|
||||
border-color: var(--c-primary);
|
||||
}
|
||||
.routes-tab-content {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
.routes-map {
|
||||
height: 40%;
|
||||
min-height: 160px;
|
||||
.rk-header {
|
||||
background: var(--c-surface);
|
||||
border-bottom: 1px solid var(--c-border-light);
|
||||
padding: var(--space-3) var(--space-4);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.routes-list {
|
||||
.rk-search-row {
|
||||
display: flex;
|
||||
gap: var(--space-2);
|
||||
margin-bottom: var(--space-3);
|
||||
align-items: center;
|
||||
}
|
||||
/* Import-Label als Button */
|
||||
.rk-imp-btn {
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
/* Import Modal */
|
||||
.rk-import-preview {
|
||||
height: 160px;
|
||||
border-radius: var(--radius-lg);
|
||||
overflow: hidden;
|
||||
background: var(--c-surface-2);
|
||||
margin-bottom: var(--space-3);
|
||||
}
|
||||
.rk-import-stats {
|
||||
display: flex;
|
||||
gap: var(--space-3);
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
font-size: var(--text-sm);
|
||||
color: var(--c-text-secondary);
|
||||
margin-bottom: var(--space-2);
|
||||
}
|
||||
.rk-search {
|
||||
flex: 1;
|
||||
padding: var(--space-2) var(--space-3);
|
||||
border: 1.5px solid var(--c-border);
|
||||
border-radius: var(--radius-full);
|
||||
font-size: var(--text-sm);
|
||||
background: var(--c-bg);
|
||||
color: var(--c-text);
|
||||
outline: none;
|
||||
}
|
||||
.rk-search:focus { border-color: var(--c-primary); }
|
||||
.rk-rec-btn {
|
||||
white-space: nowrap;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.rk-filters {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
.rk-filter-group {
|
||||
display: flex;
|
||||
gap: var(--space-1);
|
||||
overflow-x: auto;
|
||||
scrollbar-width: none;
|
||||
flex-wrap: nowrap;
|
||||
}
|
||||
.rk-filter-group::-webkit-scrollbar { display: none; }
|
||||
.rk-chip {
|
||||
padding: 4px 10px;
|
||||
border-radius: var(--radius-full);
|
||||
border: 1.5px solid var(--c-border);
|
||||
background: var(--c-bg);
|
||||
color: var(--c-text-secondary);
|
||||
font-size: var(--text-xs);
|
||||
white-space: nowrap;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
.rk-chip.active {
|
||||
background: var(--c-primary);
|
||||
border-color: var(--c-primary);
|
||||
color: #fff;
|
||||
}
|
||||
.rk-grid {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: var(--space-3) var(--space-4);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-2);
|
||||
gap: var(--space-3);
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: var(--c-primary) var(--c-surface);
|
||||
}
|
||||
.routes-card {
|
||||
.rk-loading, .rk-empty {
|
||||
text-align: center;
|
||||
padding: var(--space-10) var(--space-4);
|
||||
color: var(--c-text-secondary);
|
||||
}
|
||||
.rk-empty-icon { font-size: 3rem; margin-bottom: var(--space-3); }
|
||||
.rk-empty--onboarding { padding: var(--space-6) var(--space-4); }
|
||||
.rk-empty-title { font-size: var(--text-xl); font-weight: 700; color: var(--c-text-primary); margin: 0 0 var(--space-2); }
|
||||
.rk-empty-text { color: var(--c-text-secondary); margin-bottom: var(--space-5); max-width: 320px; margin-left: auto; margin-right: auto; }
|
||||
.rk-empty-features {
|
||||
display: grid; grid-template-columns: 1fr 1fr; gap: var(--space-2) var(--space-4);
|
||||
max-width: 320px; margin: 0 auto var(--space-6); text-align: left;
|
||||
}
|
||||
.rk-empty-feature { display: flex; align-items: center; gap: var(--space-2); font-size: var(--text-sm); color: var(--c-text-secondary); }
|
||||
.rk-empty-feature span:first-child { font-size: 1.1rem; flex-shrink: 0; }
|
||||
.rk-card {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: var(--space-3);
|
||||
padding: var(--space-3);
|
||||
flex-direction: column;
|
||||
background: var(--c-surface);
|
||||
border: 1.5px solid var(--c-border-light);
|
||||
border-radius: var(--radius-lg);
|
||||
border-radius: var(--radius-xl);
|
||||
overflow: hidden;
|
||||
cursor: pointer;
|
||||
transition: box-shadow 0.15s;
|
||||
transition: box-shadow 0.15s, transform 0.15s;
|
||||
}
|
||||
.routes-card:hover { box-shadow: 0 2px 8px rgba(0,0,0,0.08); }
|
||||
.routes-card-num {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border-radius: 50%;
|
||||
color: #fff;
|
||||
font-weight: var(--weight-bold);
|
||||
font-size: var(--text-sm);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
.rk-card:hover {
|
||||
box-shadow: 0 4px 16px rgba(0,0,0,0.1);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
.rk-card-preview {
|
||||
height: 140px;
|
||||
overflow: hidden;
|
||||
background: #e8f0e8;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.rk-preview-empty {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
height: 100%;
|
||||
font-size: 2.5rem;
|
||||
color: var(--c-text-muted);
|
||||
opacity: 0.4;
|
||||
}
|
||||
.routes-card-body { flex: 1; min-width: 0; }
|
||||
.routes-card-name { font-weight: var(--weight-semibold); color: var(--c-text); }
|
||||
.routes-card-meta { font-size: var(--text-sm); color: var(--c-text-secondary); margin-top: 2px; }
|
||||
.routes-card-tags { display: flex; flex-wrap: wrap; gap: var(--space-1); margin-top: var(--space-2); }
|
||||
.routes-badge {
|
||||
.rk-card-body {
|
||||
padding: var(--space-3) var(--space-4) var(--space-4);
|
||||
}
|
||||
.rk-card-name {
|
||||
font-weight: var(--weight-semibold);
|
||||
font-size: var(--text-base);
|
||||
margin-bottom: var(--space-1);
|
||||
}
|
||||
.rk-card-stats {
|
||||
display: flex;
|
||||
gap: var(--space-3);
|
||||
font-size: var(--text-sm);
|
||||
color: var(--c-text-secondary);
|
||||
margin-bottom: var(--space-2);
|
||||
}
|
||||
.rk-card-tags {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--space-1);
|
||||
margin-bottom: var(--space-3);
|
||||
}
|
||||
.rk-badge {
|
||||
font-size: var(--text-xs);
|
||||
padding: 2px 8px;
|
||||
border-radius: var(--radius-full);
|
||||
background: var(--c-surface-2);
|
||||
color: var(--c-text-secondary);
|
||||
}
|
||||
.routes-badge--leicht { background: #dcfce7; color: #15803d; }
|
||||
.routes-badge--mittel { background: #fef9c3; color: #a16207; }
|
||||
.routes-badge--anspruchsvoll{ background: #fee2e2; color: #b91c1c; }
|
||||
.routes-badge--info { background: var(--c-primary-subtle); color: var(--c-primary-dark); }
|
||||
|
||||
/* Aufzeichnungs-Panel */
|
||||
.routes-rec-panel {
|
||||
padding: var(--space-5) var(--space-4);
|
||||
background: var(--c-surface);
|
||||
flex-shrink: 0;
|
||||
.rk-badge--leicht { background: #dcfce7; color: #15803d; }
|
||||
.rk-badge--mittel { background: #fef9c3; color: #a16207; }
|
||||
.rk-badge--anspruchsvoll{ background: #fee2e2; color: #b91c1c; }
|
||||
.rk-badge--info { background: var(--c-primary-subtle); color: var(--c-primary-dark); }
|
||||
.rk-card-footer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
.routes-rec-stats {
|
||||
display: flex;
|
||||
gap: var(--space-4);
|
||||
margin-bottom: var(--space-4);
|
||||
justify-content: center;
|
||||
.rk-stars {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
}
|
||||
.routes-rec-stat {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
min-width: 70px;
|
||||
}
|
||||
.routes-rec-stat-val {
|
||||
font-size: 1.8rem;
|
||||
font-weight: var(--weight-bold);
|
||||
color: var(--c-primary);
|
||||
.rk-star {
|
||||
font-size: 1.1rem;
|
||||
cursor: pointer;
|
||||
color: var(--c-border);
|
||||
transition: color 0.1s, transform 0.1s;
|
||||
line-height: 1;
|
||||
}
|
||||
.routes-rec-stat-lbl {
|
||||
font-size: var(--text-xs);
|
||||
color: var(--c-text-muted);
|
||||
margin-top: 2px;
|
||||
.rk-star.filled { color: #F59E0B; }
|
||||
.rk-star:hover { color: #F59E0B; transform: scale(1.2); }
|
||||
.rk-star-count { font-size: var(--text-xs); color: var(--c-text-muted); margin-left: 4px; }
|
||||
.rk-card-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
.rk-card-author { font-size: var(--text-xs); color: var(--c-text-muted); }
|
||||
.rk-dl-btn {
|
||||
font-size: var(--text-xs);
|
||||
padding: 4px 8px;
|
||||
border-radius: var(--radius-md);
|
||||
border: 1px solid var(--c-border);
|
||||
background: var(--c-bg);
|
||||
color: var(--c-text-secondary);
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.rk-dl-btn:hover { background: var(--c-surface-2); }
|
||||
|
||||
/* Hundetauglichkeit-Badge */
|
||||
.rk-badge--dog { background: #fef3c7; color: #92400e; font-size: 1rem; }
|
||||
.rk-badge--private { background: #f1f5f9; color: #64748b; }
|
||||
|
||||
/* Foto-Galerie in Route-Detail */
|
||||
.rk-photo-gallery {
|
||||
display: flex;
|
||||
gap: var(--space-2);
|
||||
overflow-x: auto;
|
||||
margin: var(--space-2) 0;
|
||||
scrollbar-width: thin;
|
||||
}
|
||||
.rk-photo-thumb {
|
||||
width: 80px;
|
||||
height: 80px;
|
||||
object-fit: cover;
|
||||
border-radius: var(--radius-md);
|
||||
cursor: pointer;
|
||||
flex-shrink: 0;
|
||||
border: 2px solid var(--c-border-light);
|
||||
}
|
||||
.rk-photo-add {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 80px;
|
||||
height: 80px;
|
||||
border: 2px dashed var(--c-border);
|
||||
border-radius: var(--radius-md);
|
||||
cursor: pointer;
|
||||
flex-shrink: 0;
|
||||
font-size: 1.5rem;
|
||||
color: var(--c-text-muted);
|
||||
}
|
||||
.rk-photo-add-empty {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
padding: var(--space-3);
|
||||
border: 2px dashed var(--c-border);
|
||||
border-radius: var(--radius-md);
|
||||
cursor: pointer;
|
||||
color: var(--c-text-secondary);
|
||||
font-size: var(--text-sm);
|
||||
margin: var(--space-2) 0;
|
||||
}
|
||||
|
||||
/* Nearby POIs */
|
||||
.rk-nearby-section { margin-top: var(--space-3); }
|
||||
.rk-nearby-title {
|
||||
font-weight: var(--weight-semibold);
|
||||
margin-bottom: var(--space-2);
|
||||
color: var(--c-text);
|
||||
}
|
||||
.rk-nearby-group {
|
||||
margin-bottom: var(--space-3);
|
||||
}
|
||||
.rk-nearby-group-label {
|
||||
font-size: var(--text-sm);
|
||||
font-weight: var(--weight-medium);
|
||||
color: var(--c-text-secondary);
|
||||
margin-bottom: var(--space-1);
|
||||
}
|
||||
.rk-nearby-item {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--space-2);
|
||||
align-items: baseline;
|
||||
padding: var(--space-1) 0;
|
||||
border-bottom: 1px solid var(--c-border-light);
|
||||
}
|
||||
.rk-nearby-item:last-child { border-bottom: none; }
|
||||
.rk-nearby-name { font-size: var(--text-sm); color: var(--c-text); }
|
||||
.rk-nearby-detail { font-size: var(--text-xs); color: var(--c-text-muted); }
|
||||
.rk-nearby-phone { color: var(--c-primary); text-decoration: none; }
|
||||
|
||||
/* Hundetauglichkeit-Auswahl im Formular */
|
||||
.rk-paw-select {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--space-2);
|
||||
margin-top: var(--space-1);
|
||||
}
|
||||
.rk-paw-btn {
|
||||
padding: var(--space-2) var(--space-3);
|
||||
border: 1.5px solid var(--c-border);
|
||||
border-radius: var(--radius-full);
|
||||
background: var(--c-bg);
|
||||
color: var(--c-text-secondary);
|
||||
font-size: var(--text-sm);
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.rk-paw-btn.selected {
|
||||
border-color: var(--c-primary);
|
||||
background: var(--c-primary-subtle);
|
||||
color: var(--c-primary-dark);
|
||||
font-weight: var(--weight-medium);
|
||||
}
|
||||
|
||||
/* Aufzeichnungs-FAB Zustand */
|
||||
.map-fab--rec.recording {
|
||||
background: #EF4444;
|
||||
border-color: #EF4444;
|
||||
color: #fff;
|
||||
animation: rec-pulse 1.2s ease-in-out infinite;
|
||||
}
|
||||
@keyframes rec-pulse {
|
||||
0%, 100% { box-shadow: 0 0 0 0 rgba(239,68,68,0.5); }
|
||||
50% { box-shadow: 0 0 0 8px rgba(239,68,68,0); }
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
|
|
@ -1574,50 +1777,80 @@ textarea.form-control {
|
|||
z-index: 1;
|
||||
}
|
||||
@media (min-width: 768px) {
|
||||
.map-full-layout {
|
||||
top: 0;
|
||||
left: var(--nav-sidebar-width);
|
||||
bottom: 0;
|
||||
}
|
||||
}
|
||||
.map-full {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
.map-full-layout { top: 0; left: var(--nav-sidebar-width); bottom: 0; }
|
||||
}
|
||||
.map-full { width: 100%; height: 100%; }
|
||||
|
||||
/* Legende: horizontaler Scroll-Strip oben */
|
||||
.map-legend {
|
||||
position: absolute;
|
||||
top: var(--space-3);
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
top: var(--space-2);
|
||||
left: 42px; /* Zoom-Control (+/-) freilassen */
|
||||
right: 0;
|
||||
z-index: 1000;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
flex-wrap: nowrap;
|
||||
gap: var(--space-1);
|
||||
max-width: calc(100vw - var(--space-6));
|
||||
justify-content: center;
|
||||
padding: 0 var(--space-3) 0 var(--space-1);
|
||||
overflow-x: auto;
|
||||
overflow-y: hidden;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
scrollbar-width: none;
|
||||
pointer-events: auto;
|
||||
}
|
||||
.map-legend::-webkit-scrollbar { display: none; }
|
||||
|
||||
.map-legend-btn {
|
||||
padding: var(--space-1) var(--space-2);
|
||||
flex-shrink: 0;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 5px 10px;
|
||||
border-radius: var(--radius-full);
|
||||
background: rgba(255,255,255,0.92);
|
||||
background: rgba(255,255,255,0.93);
|
||||
border: 1.5px solid var(--layer-color, var(--c-border));
|
||||
color: var(--c-text-secondary);
|
||||
font-size: var(--text-xs);
|
||||
font-size: 11px;
|
||||
font-weight: var(--weight-semibold);
|
||||
cursor: pointer;
|
||||
backdrop-filter: blur(4px);
|
||||
backdrop-filter: blur(6px);
|
||||
transition: all 0.15s;
|
||||
box-shadow: 0 1px 4px rgba(0,0,0,0.15);
|
||||
white-space: nowrap;
|
||||
}
|
||||
.map-legend-btn.active {
|
||||
background: var(--layer-color, var(--c-primary));
|
||||
color: #fff;
|
||||
background: var(--layer-color, var(--c-primary));
|
||||
color: #fff;
|
||||
border-color: var(--layer-color, var(--c-primary));
|
||||
}
|
||||
.map-locate-btn {
|
||||
position: absolute;
|
||||
bottom: var(--space-6);
|
||||
right: var(--space-4);
|
||||
z-index: 1000;
|
||||
.map-legend-label { font-size: 10px; }
|
||||
.map-legend-all {
|
||||
font-size: 1rem;
|
||||
min-width: 32px;
|
||||
padding: 0 var(--space-2);
|
||||
background: var(--c-surface-2);
|
||||
border-color: var(--c-border);
|
||||
color: var(--c-text-secondary);
|
||||
font-weight: var(--weight-bold);
|
||||
}
|
||||
.map-legend-all.all-off {
|
||||
background: #1e293b;
|
||||
border-color: #1e293b;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
/* FAB-Gruppe rechts unten */
|
||||
.map-fabs {
|
||||
position: absolute;
|
||||
bottom: var(--space-4);
|
||||
right: var(--space-3);
|
||||
z-index: 1000;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-2);
|
||||
align-items: center;
|
||||
}
|
||||
.map-fab {
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
border-radius: 50%;
|
||||
|
|
@ -1625,11 +1858,247 @@ textarea.form-control {
|
|||
border: 1.5px solid var(--c-border);
|
||||
box-shadow: 0 2px 8px rgba(0,0,0,0.2);
|
||||
cursor: pointer;
|
||||
font-size: 1.3rem;
|
||||
font-size: 1.25rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: background 0.15s, border-color 0.15s;
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
}
|
||||
.map-fab:hover { background: var(--c-surface-2); }
|
||||
.map-fab--pin.active {
|
||||
background: var(--c-danger);
|
||||
border-color: var(--c-danger);
|
||||
color: #fff;
|
||||
font-size: 1rem;
|
||||
}
|
||||
.map-fab:disabled { opacity: 0.5; cursor: default; }
|
||||
.map-fab--offline.loading { animation: fab-spin 1.2s linear infinite; pointer-events: none; }
|
||||
@keyframes fab-spin { to { transform: rotate(360deg); } }
|
||||
|
||||
/* Aufzeichnungs-Panel — schiebt sich von unten rein */
|
||||
.map-rec-panel {
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
background: rgba(15, 23, 42, 0.95);
|
||||
backdrop-filter: blur(8px);
|
||||
color: #fff;
|
||||
padding: var(--space-4) var(--space-5) calc(var(--space-4) + env(safe-area-inset-bottom, 0px));
|
||||
border-radius: var(--radius-xl) var(--radius-xl) 0 0;
|
||||
transform: translateY(100%);
|
||||
transition: transform 0.3s cubic-bezier(0.34, 1.56, 0.64, 1);
|
||||
z-index: 420;
|
||||
pointer-events: none;
|
||||
}
|
||||
.map-rec-panel.active {
|
||||
transform: translateY(0);
|
||||
pointer-events: all;
|
||||
}
|
||||
.map-rec-stats {
|
||||
display: flex;
|
||||
justify-content: space-around;
|
||||
margin-bottom: var(--space-4);
|
||||
}
|
||||
.map-rec-stat {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
}
|
||||
.map-rec-stat--main .map-rec-val { font-size: 2.4rem; }
|
||||
.map-rec-val {
|
||||
font-size: 1.8rem;
|
||||
font-weight: 800;
|
||||
line-height: 1;
|
||||
font-variant-numeric: tabular-nums;
|
||||
letter-spacing: -0.02em;
|
||||
}
|
||||
.map-rec-lbl {
|
||||
font-size: 0.65rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
color: rgba(255,255,255,0.55);
|
||||
}
|
||||
.map-rec-actions {
|
||||
display: flex;
|
||||
gap: var(--space-3);
|
||||
margin-bottom: var(--space-3);
|
||||
}
|
||||
.map-rec-action-btn { flex: 1; }
|
||||
.map-rec-hint {
|
||||
text-align: center;
|
||||
font-size: var(--text-xs);
|
||||
color: rgba(255,255,255,0.45);
|
||||
}
|
||||
.map-rec-panel.paused .map-rec-val { color: #F59E0B; }
|
||||
.map-rec-panel.paused .map-rec-hint::before { content: '⏸ Pausiert — '; }
|
||||
|
||||
/* Fadenkreuz-Overlay */
|
||||
.map-crosshair {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -100%);
|
||||
z-index: 410;
|
||||
pointer-events: none;
|
||||
display: none;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
}
|
||||
.map-crosshair.active { display: flex; }
|
||||
.map-crosshair-pin {
|
||||
font-size: 2.4rem;
|
||||
line-height: 1;
|
||||
filter: drop-shadow(0 3px 6px rgba(0,0,0,0.45));
|
||||
transition: transform 0.15s cubic-bezier(0.34,1.56,0.64,1);
|
||||
}
|
||||
.map-crosshair.dragging .map-crosshair-pin {
|
||||
transform: translateY(-10px) scale(1.15);
|
||||
}
|
||||
.map-crosshair-shadow {
|
||||
width: 10px;
|
||||
height: 4px;
|
||||
background: rgba(0,0,0,0.25);
|
||||
border-radius: 50%;
|
||||
margin-top: 1px;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
.map-crosshair.dragging .map-crosshair-shadow {
|
||||
width: 6px; opacity: 0.4;
|
||||
}
|
||||
|
||||
/* Bestätigen-Leiste unten */
|
||||
.map-place-bar {
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
background: var(--c-surface);
|
||||
border-top: 1px solid var(--c-border-light);
|
||||
padding: var(--space-3) var(--space-4) calc(var(--space-3) + env(safe-area-inset-bottom, 0px));
|
||||
display: none;
|
||||
flex-direction: column;
|
||||
gap: var(--space-2);
|
||||
z-index: 410;
|
||||
box-shadow: 0 -4px 20px rgba(0,0,0,0.08);
|
||||
}
|
||||
.map-place-bar.active { display: flex; }
|
||||
.map-place-hint {
|
||||
text-align: center;
|
||||
font-size: var(--text-sm);
|
||||
color: var(--c-text-secondary);
|
||||
}
|
||||
.map-place-btns {
|
||||
display: flex;
|
||||
gap: var(--space-3);
|
||||
}
|
||||
.map-place-btns .btn { flex: 1; }
|
||||
|
||||
/* Statusleiste: nur Info, unten links */
|
||||
.map-statusbar {
|
||||
position: absolute;
|
||||
bottom: var(--space-3);
|
||||
left: var(--space-3);
|
||||
z-index: 1000;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
background: rgba(255,255,255,0.88);
|
||||
backdrop-filter: blur(4px);
|
||||
border: 1px solid var(--c-border-light);
|
||||
border-radius: var(--radius-full);
|
||||
padding: 4px 12px;
|
||||
font-size: 11px;
|
||||
color: var(--c-text-secondary);
|
||||
box-shadow: 0 1px 4px rgba(0,0,0,0.1);
|
||||
max-width: calc(100% - 80px); /* Platz für FABs */
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* Giftköder-Marker — pulsierend, rot, sofort erkennbar */
|
||||
.poison-marker {
|
||||
position: relative;
|
||||
width: 48px; height: 48px;
|
||||
}
|
||||
.poison-dot {
|
||||
position: absolute;
|
||||
top: 50%; left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
width: 44px; height: 44px;
|
||||
background: #DC2626;
|
||||
border: 3px solid #fff;
|
||||
border-radius: 50%;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
font-size: 20px;
|
||||
box-shadow: 0 2px 10px rgba(220,38,38,0.7);
|
||||
z-index: 2;
|
||||
}
|
||||
.poison-ring {
|
||||
position: absolute;
|
||||
top: 50%; left: 50%;
|
||||
width: 44px; height: 44px;
|
||||
border-radius: 50%;
|
||||
background: rgba(220,38,38,0.35);
|
||||
animation: poison-pulse 1.8s ease-out infinite;
|
||||
z-index: 1;
|
||||
}
|
||||
.poison-ring:nth-child(2) { animation-delay: 0.6s; }
|
||||
@keyframes poison-pulse {
|
||||
0% { transform: translate(-50%,-50%) scale(1); opacity: 0.8; }
|
||||
100% { transform: translate(-50%,-50%) scale(2.8); opacity: 0; }
|
||||
}
|
||||
|
||||
/* Pulsierender Standort-Marker */
|
||||
.loc-icon { position: relative; }
|
||||
.loc-dot {
|
||||
width: 16px; height: 16px;
|
||||
background: #3B82F6;
|
||||
border: 3px solid #fff;
|
||||
border-radius: 50%;
|
||||
box-shadow: 0 1px 4px rgba(0,0,0,0.3);
|
||||
position: absolute;
|
||||
top: 4px; left: 4px;
|
||||
}
|
||||
.loc-ring {
|
||||
width: 24px; height: 24px;
|
||||
background: rgba(59,130,246,0.3);
|
||||
border-radius: 50%;
|
||||
position: absolute;
|
||||
top: 0; left: 0;
|
||||
animation: loc-pulse 2s ease-out infinite;
|
||||
}
|
||||
@keyframes loc-pulse {
|
||||
0% { transform: scale(0.8); opacity: 1; }
|
||||
100% { transform: scale(2.2); opacity: 0; }
|
||||
}
|
||||
|
||||
/* Pin-Typ-Auswahl im Modal */
|
||||
.poi-type-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
gap: var(--space-2);
|
||||
}
|
||||
.poi-type-btn {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 10px 4px;
|
||||
border: 2px solid var(--c-border);
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--c-surface);
|
||||
cursor: pointer;
|
||||
transition: border-color 0.15s, background 0.15s;
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
}
|
||||
.poi-type-btn.selected {
|
||||
border-color: var(--pt-color, var(--c-primary));
|
||||
background: color-mix(in srgb, var(--pt-color, var(--c-primary)) 12%, transparent);
|
||||
}
|
||||
.poi-type-icon { font-size: 22px; line-height: 1; }
|
||||
.poi-type-label { font-size: 10px; color: var(--c-text-secondary); text-align: center; line-height: 1.2; }
|
||||
|
||||
/* ------------------------------------------------------------
|
||||
GASSI-TREFFEN (walks.js)
|
||||
|
|
|
|||
|
|
@ -22,8 +22,8 @@
|
|||
|
||||
<!-- CSS: Reihenfolge ist wichtig — ?v= zwingt Browser zur Neuladung -->
|
||||
<link rel="stylesheet" href="/css/design-system.css">
|
||||
<link rel="stylesheet" href="/css/layout.css?v=32">
|
||||
<link rel="stylesheet" href="/css/components.css?v=32">
|
||||
<link rel="stylesheet" href="/css/layout.css?v=62">
|
||||
<link rel="stylesheet" href="/css/components.css?v=62">
|
||||
</head>
|
||||
<body>
|
||||
|
||||
|
|
@ -55,10 +55,7 @@
|
|||
<div class="sidebar-item" data-page="routes">
|
||||
<span class="sidebar-item-icon">🥾</span> Routen
|
||||
</div>
|
||||
<div class="sidebar-item" data-page="places">
|
||||
<span class="sidebar-item-icon">📍</span> Orte
|
||||
</div>
|
||||
<div class="sidebar-item" data-page="events">
|
||||
<div class="sidebar-item" data-page="events">
|
||||
<span class="sidebar-item-icon">🎯</span> Events
|
||||
</div>
|
||||
|
||||
|
|
@ -136,11 +133,7 @@
|
|||
<div class="page-body page-container"></div>
|
||||
</section>
|
||||
|
||||
<section class="page" id="page-places">
|
||||
<div class="page-body page-container"></div>
|
||||
</section>
|
||||
|
||||
<section class="page" id="page-events">
|
||||
<section class="page" id="page-events">
|
||||
<div class="page-body page-container"></div>
|
||||
</section>
|
||||
|
||||
|
|
@ -214,9 +207,9 @@
|
|||
<div id="modal-container"></div>
|
||||
|
||||
<!-- JS: Reihenfolge ist wichtig — erst Basis, dann Features -->
|
||||
<script src="/js/api.js?v=32"></script>
|
||||
<script src="/js/ui.js?v=32"></script>
|
||||
<script src="/js/app.js?v=32"></script>
|
||||
<script src="/js/api.js?v=62"></script>
|
||||
<script src="/js/ui.js?v=62"></script>
|
||||
<script src="/js/app.js?v=62"></script>
|
||||
|
||||
<!-- Feature-Seiten werden lazy geladen -->
|
||||
|
||||
|
|
|
|||
|
|
@ -188,6 +188,14 @@ const API = (() => {
|
|||
create(data) { return post('/routes', data); },
|
||||
update(id, data) { return patch(`/routes/${id}`, data); },
|
||||
delete(id) { return del(`/routes/${id}`); },
|
||||
rate(id, wertung) { return post(`/routes/${id}/rate`, { wertung }); },
|
||||
addPhoto(id, file) {
|
||||
const fd = new FormData();
|
||||
fd.append('file', file);
|
||||
return fetch(`/api/routes/${id}/photo`, {
|
||||
method: 'POST', credentials: 'include', body: fd,
|
||||
}).then(r => r.ok ? r.json() : Promise.reject(new Error('Foto-Upload fehlgeschlagen')));
|
||||
},
|
||||
};
|
||||
|
||||
// ----------------------------------------------------------
|
||||
|
|
|
|||
|
|
@ -3,6 +3,8 @@
|
|||
Router, State-Management, Navigation, Initialisierung.
|
||||
============================================================ */
|
||||
|
||||
const APP_VER = '62'; // ← bei jedem Deploy mit Frontend-Änderungen erhöhen
|
||||
|
||||
const App = (() => {
|
||||
|
||||
// ----------------------------------------------------------
|
||||
|
|
@ -26,8 +28,7 @@ const App = (() => {
|
|||
'dog-profile': { title: 'Mein Hund', module: null },
|
||||
map: { title: 'Karte', module: null },
|
||||
routes: { title: 'Routen', module: null },
|
||||
places: { title: 'Orte', module: null },
|
||||
events: { title: 'Events', module: null },
|
||||
events: { title: 'Events', module: null },
|
||||
poison: { title: 'Giftköder-Alarm', module: null },
|
||||
walks: { title: 'Gassi-Treffen', module: null },
|
||||
sitting: { title: 'Sitting', module: null },
|
||||
|
|
@ -119,10 +120,12 @@ const App = (() => {
|
|||
}
|
||||
|
||||
function _loadScript(src) {
|
||||
// Versionierter URL damit SW/Browser-Cache bei jedem Deploy invalidiert wird
|
||||
const versioned = `${src}?v=${APP_VER}`;
|
||||
return new Promise((resolve, reject) => {
|
||||
if (document.querySelector(`script[src="${src}"]`)) { resolve(); return; }
|
||||
if (document.querySelector(`script[src="${versioned}"]`)) { resolve(); return; }
|
||||
const s = document.createElement('script');
|
||||
s.src = src;
|
||||
s.src = versioned;
|
||||
s.onload = resolve;
|
||||
s.onerror = reject;
|
||||
document.head.appendChild(s);
|
||||
|
|
@ -193,11 +196,7 @@ const App = (() => {
|
|||
}
|
||||
|
||||
function _openSidebar() {
|
||||
const s = document.getElementById('sidebar');
|
||||
if (!s) { UI.toast.error('sidebar: NULL'); return; }
|
||||
s.classList.add('open');
|
||||
const cs = getComputedStyle(s);
|
||||
UI.toast.info(`display:${cs.display} | transform:${cs.transform} | z:${cs.zIndex}`);
|
||||
document.getElementById('sidebar')?.classList.add('open');
|
||||
document.getElementById('sidebar-backdrop')?.classList.add('visible');
|
||||
}
|
||||
|
||||
|
|
@ -223,10 +222,7 @@ const App = (() => {
|
|||
<button class="btn btn-danger w-full" data-quick="poison">
|
||||
⚠️ Giftköder melden
|
||||
</button>
|
||||
<button class="btn btn-secondary w-full" data-quick="place">
|
||||
📍 Ort hinzufügen
|
||||
</button>
|
||||
<button class="btn btn-nature w-full" data-quick="walk">
|
||||
<button class="btn btn-nature w-full" data-quick="walk">
|
||||
🦮 Gassi-Treffen erstellen
|
||||
</button>
|
||||
</div>
|
||||
|
|
@ -246,8 +242,7 @@ const App = (() => {
|
|||
if (action === 'diary') { navigate('diary'); pages['diary'].module?.openNew?.(); }
|
||||
if (action === 'health') { navigate('health'); pages['health'].module?.openNew?.(); }
|
||||
if (action === 'poison') { navigate('poison'); pages['poison'].module?.openNew?.(); }
|
||||
if (action === 'place') { navigate('places'); pages['places'].module?.openNew?.(); }
|
||||
if (action === 'walk') { navigate('walks'); pages['walks'].module?.openNew?.(); }
|
||||
if (action === 'walk') { navigate('walks'); pages['walks'].module?.openNew?.(); }
|
||||
}, 350);
|
||||
}, { once: true });
|
||||
}
|
||||
|
|
|
|||
2
backend/static/js/leaflet.markercluster.js
Normal file
2
backend/static/js/leaflet.markercluster.js
Normal file
File diff suppressed because one or more lines are too long
File diff suppressed because it is too large
Load diff
File diff suppressed because it is too large
Load diff
|
|
@ -1,10 +1,11 @@
|
|||
/* ============================================================
|
||||
BAN YARO — Service Worker
|
||||
Offline-Cache + Push Notifications
|
||||
Offline-Cache + Push Notifications + Tile-Cache
|
||||
============================================================ */
|
||||
|
||||
const CACHE_VERSION = 'by-v33';
|
||||
const CACHE_VERSION = 'by-v62';
|
||||
const CACHE_STATIC = `${CACHE_VERSION}-static`;
|
||||
const CACHE_TILES = 'ban-yaro-tiles-v1'; // bleibt über SW-Updates erhalten
|
||||
|
||||
// index.html wird NICHT pre-gecacht (immer Network-First)
|
||||
const STATIC_ASSETS = [
|
||||
|
|
@ -14,6 +15,9 @@ const STATIC_ASSETS = [
|
|||
'/js/api.js',
|
||||
'/js/ui.js',
|
||||
'/js/app.js',
|
||||
'/js/leaflet.markercluster.js',
|
||||
'/css/MarkerCluster.css',
|
||||
'/css/MarkerCluster.Default.css',
|
||||
'/manifest.json',
|
||||
'/icons/icon-192.png',
|
||||
];
|
||||
|
|
@ -30,13 +34,15 @@ self.addEventListener('install', event => {
|
|||
});
|
||||
|
||||
// ----------------------------------------------------------
|
||||
// ACTIVATE — alte Caches aufräumen
|
||||
// ACTIVATE — alte Caches aufräumen (CACHE_TILES behalten)
|
||||
// ----------------------------------------------------------
|
||||
self.addEventListener('activate', event => {
|
||||
event.waitUntil(
|
||||
caches.keys()
|
||||
.then(keys => Promise.all(
|
||||
keys.filter(k => k !== CACHE_STATIC).map(k => caches.delete(k))
|
||||
keys
|
||||
.filter(k => k !== CACHE_STATIC && k !== CACHE_TILES)
|
||||
.map(k => caches.delete(k))
|
||||
))
|
||||
.then(() => self.clients.claim())
|
||||
);
|
||||
|
|
@ -59,6 +65,38 @@ self.addEventListener('fetch', event => {
|
|||
return;
|
||||
}
|
||||
|
||||
// OSM-Kartenkacheln: eigener persistenter Cache
|
||||
if (url.hostname.endsWith('tile.openstreetmap.org')) {
|
||||
event.respondWith(
|
||||
caches.open(CACHE_TILES).then(cache =>
|
||||
cache.match(event.request).then(cached => {
|
||||
if (cached) return cached;
|
||||
return fetch(event.request).then(response => {
|
||||
if (response.ok) cache.put(event.request, response.clone());
|
||||
return response;
|
||||
});
|
||||
})
|
||||
).catch(() => new Response('', { status: 503 }))
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Seiten-Module (/js/pages/…): immer Network-First (versioniert über ?v=, kein alter Cache-Treffer)
|
||||
if (url.pathname.startsWith('/js/pages/')) {
|
||||
event.respondWith(
|
||||
fetch(event.request)
|
||||
.then(response => {
|
||||
if (response.ok) {
|
||||
const clone = response.clone();
|
||||
caches.open(CACHE_STATIC).then(c => c.put(event.request, clone));
|
||||
}
|
||||
return response;
|
||||
})
|
||||
.catch(() => caches.match(event.request))
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Navigation (index.html): immer Network-First
|
||||
if (event.request.mode === 'navigate') {
|
||||
event.respondWith(
|
||||
|
|
@ -93,6 +131,48 @@ self.addEventListener('fetch', event => {
|
|||
);
|
||||
});
|
||||
|
||||
// ----------------------------------------------------------
|
||||
// MESSAGE — Tile-Vorausladung (Offline-Speicherung)
|
||||
// ----------------------------------------------------------
|
||||
self.addEventListener('message', event => {
|
||||
if (event.data?.type !== 'CACHE_TILES') return;
|
||||
|
||||
const urls = event.data.urls || [];
|
||||
const source = event.source;
|
||||
let done = 0;
|
||||
const total = urls.length;
|
||||
|
||||
if (total === 0) {
|
||||
source?.postMessage({ type: 'CACHE_TILES_PROGRESS', done: 0, total: 0 });
|
||||
return;
|
||||
}
|
||||
|
||||
caches.open(CACHE_TILES).then(cache => {
|
||||
const queue = [...urls];
|
||||
|
||||
function fetchBatch() {
|
||||
if (queue.length === 0) {
|
||||
source?.postMessage({ type: 'CACHE_TILES_PROGRESS', done: total, total });
|
||||
return;
|
||||
}
|
||||
const batch = queue.splice(0, 8);
|
||||
Promise.all(batch.map(url =>
|
||||
cache.match(url).then(cached => {
|
||||
if (cached) { done++; return; }
|
||||
return fetch(url, { mode: 'cors' })
|
||||
.then(r => { if (r.ok) cache.put(url, r); done++; })
|
||||
.catch(() => { done++; });
|
||||
})
|
||||
)).then(() => {
|
||||
source?.postMessage({ type: 'CACHE_TILES_PROGRESS', done, total });
|
||||
setTimeout(fetchBatch, 30);
|
||||
});
|
||||
}
|
||||
|
||||
fetchBatch();
|
||||
});
|
||||
});
|
||||
|
||||
// ----------------------------------------------------------
|
||||
// PUSH NOTIFICATIONS
|
||||
// ----------------------------------------------------------
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue