From b9df6365353c74aaa715dd7a4d0ba02e574aeedd Mon Sep 17 00:00:00 2001 From: rene Date: Tue, 14 Apr 2026 06:03:37 +0200 Subject: [PATCH] Sprint 6: Karte / Orte / Routen mit GPS-Aufzeichnung MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - backend/routes/places.py: CRUD für hundefreundliche Orte (6 Typen) - backend/routes/routen.py: CRUD für Gassi-Routen mit GPS-Track (JSON) - main.py: beide Router eingehängt (/api/places, /api/routes) - api.js: places + routes erweitert (list, update, delete) - pages/places.js: Karte + Liste, Typ-Filter, Ort anlegen/bearbeiten - pages/routes.js: Routen entdecken + GPS-Aufzeichnung mit Stoppuhr - pages/map.js: zentrale Übersichtskarte (Orte + Giftköder, Layer-Toggle) - components.css: Styles für alle drei neuen Seiten - sw.js: by-v19 → by-v20 --- backend/main.py | 4 + backend/routes/places.py | 166 +++++++++ backend/routes/routen.py | 177 +++++++++ backend/static/css/components.css | 289 +++++++++++++++ backend/static/js/api.js | 24 +- backend/static/js/pages/map.js | 230 ++++++++++++ backend/static/js/pages/places.js | 482 ++++++++++++++++++++++++ backend/static/js/pages/routes.js | 583 ++++++++++++++++++++++++++++++ backend/static/sw.js | 2 +- 9 files changed, 1948 insertions(+), 9 deletions(-) create mode 100644 backend/routes/places.py create mode 100644 backend/routes/routen.py create mode 100644 backend/static/js/pages/map.js create mode 100644 backend/static/js/pages/places.js create mode 100644 backend/static/js/pages/routes.js diff --git a/backend/main.py b/backend/main.py index ef48703..9fc945f 100644 --- a/backend/main.py +++ b/backend/main.py @@ -57,6 +57,8 @@ from routes.poison import router as poison_router from routes.push import router as push_router from routes.ki import router as ki_router from routes.tieraerzte import router as tieraerzte_router +from routes.places import router as places_router +from routes.routen import router as routen_router app.include_router(auth_router, prefix="/api/auth", tags=["Auth"]) app.include_router(dogs_router, prefix="/api/dogs", tags=["Hunde"]) @@ -66,6 +68,8 @@ app.include_router(poison_router, prefix="/api/poison", tags=["Giftköde app.include_router(push_router, prefix="/api/push", tags=["Push"]) app.include_router(ki_router, prefix="/api/ki", tags=["KI"]) app.include_router(tieraerzte_router, prefix="/api/tieraerzte", tags=["Tierärzte"]) +app.include_router(places_router, prefix="/api/places", tags=["Orte"]) +app.include_router(routen_router, prefix="/api/routes", tags=["Routen"]) # ------------------------------------------------------------------ diff --git a/backend/routes/places.py b/backend/routes/places.py new file mode 100644 index 0000000..f72355e --- /dev/null +++ b/backend/routes/places.py @@ -0,0 +1,166 @@ +"""BAN YARO — Hundefreundliche Orte""" + +import math +from fastapi import APIRouter, Depends, HTTPException +from pydantic import BaseModel +from typing import Optional +from database import db +from auth import get_current_user + +router = APIRouter() + +TYPEN = {'restaurant', 'shop', 'freilauf', 'kotbeutel', 'tierarzt', 'hundeschule'} + + +def _haversine(lat1: float, lon1: float, lat2: float, lon2: float) -> float: + R = 6_371_000 + p1 = math.radians(lat1) + p2 = math.radians(lat2) + dp = math.radians(lat2 - lat1) + dl = math.radians(lon2 - lon1) + a = math.sin(dp / 2) ** 2 + math.cos(p1) * math.cos(p2) * math.sin(dl / 2) ** 2 + return 2 * R * math.asin(math.sqrt(a)) + + +# ------------------------------------------------------------------ +# Schemas +# ------------------------------------------------------------------ +class PlaceCreate(BaseModel): + name: str + typ: str + lat: float + lon: float + adresse: Optional[str] = None + website: Optional[str] = None + hund_rein: Optional[bool] = None + leine_pflicht: Optional[bool] = None + wasser_fuer_hunde: Optional[bool] = None + +class PlaceUpdate(BaseModel): + name: Optional[str] = None + typ: Optional[str] = None + lat: Optional[float]= None + lon: Optional[float]= None + adresse: Optional[str] = None + website: Optional[str] = None + hund_rein: Optional[bool] = None + leine_pflicht: Optional[bool] = None + wasser_fuer_hunde: Optional[bool] = None + + +def _row_to_dict(row) -> dict: + d = dict(row) + for k in ('hund_rein', 'leine_pflicht', 'wasser_fuer_hunde'): + if d.get(k) is not None: + d[k] = bool(d[k]) + return d + + +# ------------------------------------------------------------------ +# GET /api/places — alle Orte (optional: Umkreis + Typ-Filter) +# ------------------------------------------------------------------ +@router.get("") +async def list_places( + lat: Optional[float] = None, + lon: Optional[float] = None, + radius: int = 5000, + typ: Optional[str] = None, +): + with db() as conn: + q = "SELECT p.*, u.name AS user_name FROM places p LEFT JOIN users u ON u.id = p.user_id" + params = [] + if typ: + q += " WHERE p.typ = ?" + params.append(typ) + q += " ORDER BY p.created_at DESC" + rows = conn.execute(q, params).fetchall() + + result = [_row_to_dict(r) for r in rows] + if lat is not None and lon is not None: + result = [r for r in result if _haversine(lat, lon, r['lat'], r['lon']) <= radius] + return result + + +# ------------------------------------------------------------------ +# POST /api/places — neuen Ort anlegen (Login erforderlich) +# ------------------------------------------------------------------ +@router.post("", status_code=201) +async def create_place(data: PlaceCreate, user=Depends(get_current_user)): + if data.typ not in TYPEN: + raise HTTPException(400, f"Ungültiger Typ. Erlaubt: {', '.join(sorted(TYPEN))}") + with db() as conn: + cur = conn.execute(""" + INSERT INTO places + (user_id, name, typ, lat, lon, adresse, website, + hund_rein, leine_pflicht, wasser_fuer_hunde) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + """, ( + user['id'], data.name, data.typ, data.lat, data.lon, + data.adresse, data.website, + int(data.hund_rein) if data.hund_rein is not None else None, + int(data.leine_pflicht) if data.leine_pflicht is not None else None, + int(data.wasser_fuer_hunde) if data.wasser_fuer_hunde is not None else None, + )) + row = conn.execute( + "SELECT p.*, u.name AS user_name FROM places p LEFT JOIN users u ON u.id = p.user_id WHERE p.id = ?", + (cur.lastrowid,) + ).fetchone() + return _row_to_dict(row) + + +# ------------------------------------------------------------------ +# GET /api/places/{id} +# ------------------------------------------------------------------ +@router.get("/{place_id}") +async def get_place(place_id: int): + with db() as conn: + row = conn.execute( + "SELECT p.*, u.name AS user_name FROM places p LEFT JOIN users u ON u.id = p.user_id WHERE p.id = ?", + (place_id,) + ).fetchone() + if not row: + raise HTTPException(404, "Ort nicht gefunden.") + return _row_to_dict(row) + + +# ------------------------------------------------------------------ +# PATCH /api/places/{id} — bearbeiten (nur eigene) +# ------------------------------------------------------------------ +@router.patch("/{place_id}") +async def update_place(place_id: int, data: PlaceUpdate, user=Depends(get_current_user)): + with db() as conn: + row = conn.execute("SELECT * FROM places WHERE id = ?", (place_id,)).fetchone() + if not row: + raise HTTPException(404, "Ort nicht gefunden.") + if row['user_id'] != user['id']: + raise HTTPException(403, "Nicht berechtigt.") + + updates = data.model_dump(exclude_none=True) + if not updates: + return _row_to_dict(row) + + for key in ('hund_rein', 'leine_pflicht', 'wasser_fuer_hunde'): + if key in updates: + updates[key] = int(updates[key]) + + cols = ', '.join(f"{k} = ?" for k in updates) + conn.execute(f"UPDATE places SET {cols} WHERE id = ?", [*updates.values(), place_id]) + row = conn.execute( + "SELECT p.*, u.name AS user_name FROM places p LEFT JOIN users u ON u.id = p.user_id WHERE p.id = ?", + (place_id,) + ).fetchone() + return _row_to_dict(row) + + +# ------------------------------------------------------------------ +# DELETE /api/places/{id} +# ------------------------------------------------------------------ +@router.delete("/{place_id}", status_code=204) +async def delete_place(place_id: int, user=Depends(get_current_user)): + with db() as conn: + row = conn.execute("SELECT * FROM places WHERE id = ?", (place_id,)).fetchone() + if not row: + raise HTTPException(404, "Ort nicht gefunden.") + if row['user_id'] != user['id']: + raise HTTPException(403, "Nicht berechtigt.") + conn.execute("DELETE FROM places WHERE id = ?", (place_id,)) diff --git a/backend/routes/routen.py b/backend/routes/routen.py new file mode 100644 index 0000000..6b13690 --- /dev/null +++ b/backend/routes/routen.py @@ -0,0 +1,177 @@ +"""BAN YARO — Gassi-Routen""" + +import json, math +from fastapi import APIRouter, Depends, HTTPException +from pydantic import BaseModel +from typing import Optional, List +from database import db +from auth import get_current_user + +router = APIRouter() + + +def _haversine(lat1: float, lon1: float, lat2: float, lon2: float) -> float: + R = 6_371_000 + p1 = math.radians(lat1) + p2 = math.radians(lat2) + dp = math.radians(lat2 - lat1) + dl = math.radians(lon2 - lon1) + a = math.sin(dp / 2) ** 2 + math.cos(p1) * math.cos(p2) * math.sin(dl / 2) ** 2 + return 2 * R * math.asin(math.sqrt(a)) + + +# ------------------------------------------------------------------ +# Schemas +# ------------------------------------------------------------------ +class GPSPoint(BaseModel): + lat: float + lon: float + +class RouteCreate(BaseModel): + name: str + beschreibung: Optional[str] = None + gps_track: List[GPSPoint] + distanz_km: Optional[float] = None + dauer_min: Optional[int] = None + schwierigkeit: Optional[str] = "leicht" # leicht | mittel | anspruchsvoll + untergrund: Optional[str] = None # wald | asphalt | wiese | mix + schatten: Optional[bool] = None + leine_empfohlen: Optional[bool] = None + +class RouteUpdate(BaseModel): + name: Optional[str] = None + beschreibung: Optional[str] = None + schwierigkeit: Optional[str] = None + untergrund: Optional[str] = None + schatten: Optional[bool] = None + leine_empfohlen: Optional[bool] = None + + +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'): + if d.get(k) is not None: + d[k] = bool(d[k]) + return d + + +# ------------------------------------------------------------------ +# GET /api/routes — Routen (optional: Umkreis vom Startpunkt) +# ------------------------------------------------------------------ +@router.get("") +async def list_routes( + lat: Optional[float] = None, + lon: Optional[float] = None, + radius: int = 10000, +): + with db() as conn: + rows = conn.execute(""" + SELECT r.id, r.user_id, r.name, r.beschreibung, + r.distanz_km, r.dauer_min, r.schwierigkeit, + r.untergrund, r.schatten, r.leine_empfohlen, + r.bewertung, r.anz_bewertungen, r.created_at, + 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 + FROM routes r + LEFT JOIN users u ON u.id = r.user_id + ORDER BY r.created_at DESC + """).fetchall() + + result = [] + for row in rows: + d = dict(row) + for k in ('schatten', 'leine_empfohlen'): + if d.get(k) is not None: + d[k] = bool(d[k]) + result.append(d) + + if lat is not None and lon is not None: + result = [ + r for r in result + if r['start_lat'] and _haversine(lat, lon, r['start_lat'], r['start_lon']) <= radius + ] + return result + + +# ------------------------------------------------------------------ +# POST /api/routes — neue Route speichern (Login erforderlich) +# ------------------------------------------------------------------ +@router.post("", status_code=201) +async def create_route(data: RouteCreate, user=Depends(get_current_user)): + if len(data.gps_track) < 2: + raise HTTPException(400, "GPS-Track braucht mindestens 2 Punkte.") + + gps_json = json.dumps([p.model_dump() for p in data.gps_track]) + + with db() as conn: + cur = conn.execute(""" + INSERT INTO routes + (user_id, name, beschreibung, gps_track, distanz_km, dauer_min, + schwierigkeit, untergrund, schatten, leine_empfohlen) + 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, + )) + row = conn.execute("SELECT * FROM routes WHERE id = ?", (cur.lastrowid,)).fetchone() + return _parse(row) + + +# ------------------------------------------------------------------ +# GET /api/routes/{id} — Route mit vollem GPS-Track +# ------------------------------------------------------------------ +@router.get("/{route_id}") +async def get_route(route_id: int): + with db() as conn: + row = conn.execute( + "SELECT r.*, u.name AS user_name FROM routes r LEFT JOIN users u ON u.id = r.user_id WHERE r.id = ?", + (route_id,) + ).fetchone() + if not row: + raise HTTPException(404, "Route nicht gefunden.") + return _parse(row) + + +# ------------------------------------------------------------------ +# PATCH /api/routes/{id} +# ------------------------------------------------------------------ +@router.patch("/{route_id}") +async def update_route(route_id: int, data: RouteUpdate, 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 row['user_id'] != user['id']: + raise HTTPException(403, "Nicht berechtigt.") + + updates = data.model_dump(exclude_none=True) + if updates: + for key in ('schatten', 'leine_empfohlen'): + if key in updates: + updates[key] = int(updates[key]) + cols = ', '.join(f"{k} = ?" for k in updates) + conn.execute(f"UPDATE routes SET {cols} WHERE id = ?", [*updates.values(), route_id]) + + row = conn.execute("SELECT * FROM routes WHERE id = ?", (route_id,)).fetchone() + return _parse(row) + + +# ------------------------------------------------------------------ +# DELETE /api/routes/{id} +# ------------------------------------------------------------------ +@router.delete("/{route_id}", status_code=204) +async def delete_route(route_id: int, 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 row['user_id'] != user['id']: + raise HTTPException(403, "Nicht berechtigt.") + conn.execute("DELETE FROM routes WHERE id = ?", (route_id,)) diff --git a/backend/static/css/components.css b/backend/static/css/components.css index 1c8b332..fa591c7 100644 --- a/backend/static/css/components.css +++ b/backend/static/css/components.css @@ -1330,3 +1330,292 @@ textarea.form-control { color: var(--c-text-secondary); 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 { + font-size: 1.3rem; + width: 38px; + height: 38px; + border-radius: 50%; + background: var(--c-surface); + border: 1.5px solid var(--c-border); + box-shadow: 0 2px 6px rgba(0,0,0,0.15); + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; +} + +/* ============================================================ + ROUTEN (routes.js) + ============================================================ */ +.routes-layout { + display: flex; + flex-direction: column; + height: 100%; + overflow: hidden; +} +.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; + flex-shrink: 0; +} +.routes-list { + flex: 1; + overflow-y: auto; + padding: var(--space-3) var(--space-4); + display: flex; + flex-direction: column; + gap: var(--space-2); + scrollbar-width: thin; + scrollbar-color: var(--c-primary) var(--c-surface); +} +.routes-card { + display: flex; + align-items: flex-start; + gap: var(--space-3); + padding: var(--space-3); + background: var(--c-surface); + border: 1.5px solid var(--c-border-light); + border-radius: var(--radius-lg); + cursor: pointer; + transition: box-shadow 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; + justify-content: center; + flex-shrink: 0; +} +.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 { + 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; +} +.routes-rec-stats { + display: flex; + gap: var(--space-4); + margin-bottom: var(--space-4); + justify-content: center; +} +.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); + line-height: 1; +} +.routes-rec-stat-lbl { + font-size: var(--text-xs); + color: var(--c-text-muted); + margin-top: 2px; +} + +/* ============================================================ + ZENTRALE KARTE (map.js) + ============================================================ */ +.map-full-layout { + position: relative; + height: 100%; + overflow: hidden; +} +.map-full { + width: 100%; + height: 100%; +} +.map-legend { + position: absolute; + top: var(--space-3); + left: 50%; + transform: translateX(-50%); + z-index: 1000; + display: flex; + flex-wrap: wrap; + gap: var(--space-1); + max-width: calc(100vw - var(--space-6)); + justify-content: center; +} +.map-legend-btn { + padding: var(--space-1) var(--space-2); + border-radius: var(--radius-full); + background: rgba(255,255,255,0.92); + border: 1.5px solid var(--layer-color, var(--c-border)); + color: var(--c-text-secondary); + font-size: var(--text-xs); + cursor: pointer; + backdrop-filter: blur(4px); + transition: all 0.15s; + box-shadow: 0 1px 4px rgba(0,0,0,0.15); +} +.map-legend-btn.active { + 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; + width: 44px; + height: 44px; + border-radius: 50%; + background: var(--c-surface); + border: 1.5px solid var(--c-border); + box-shadow: 0 2px 8px rgba(0,0,0,0.2); + cursor: pointer; + font-size: 1.3rem; + display: flex; + align-items: center; + justify-content: center; +} diff --git a/backend/static/js/api.js b/backend/static/js/api.js index aee3b52..9cbd305 100644 --- a/backend/static/js/api.js +++ b/backend/static/js/api.js @@ -160,26 +160,34 @@ const API = (() => { // KARTE & ORTE // ---------------------------------------------------------- const places = { - listNearby(lat, lon, type = null, radius = 3000) { + list(typ = null) { + return get(`/places${typ ? '?typ=' + encodeURIComponent(typ) : ''}`); + }, + listNearby(lat, lon, typ = null, radius = 5000) { const params = new URLSearchParams({ lat, lon, radius }); - if (type) params.set('type', type); + if (typ) params.set('typ', typ); return get(`/places?${params}`); }, - get(id) { return get(`/places/${id}`); }, - create(data) { return post('/places', data); }, - rate(id, rating, comment) { return post(`/places/${id}/ratings`, { rating, comment }); }, + get(id) { return get(`/places/${id}`); }, + create(data) { return post('/places', data); }, + update(id, data) { return patch(`/places/${id}`, data); }, + delete(id) { return del(`/places/${id}`); }, }; // ---------------------------------------------------------- // GASSI-ROUTEN // ---------------------------------------------------------- const routes = { + list() { + return get('/routes'); + }, listNearby(lat, lon, radius = 10000) { return get(`/routes?lat=${lat}&lon=${lon}&radius=${radius}`); }, - get(id) { return get(`/routes/${id}`); }, - create(data) { return post('/routes', data); }, - rate(id, d) { return post(`/routes/${id}/ratings`, d); }, + get(id) { return get(`/routes/${id}`); }, + create(data) { return post('/routes', data); }, + update(id, data) { return patch(`/routes/${id}`, data); }, + delete(id) { return del(`/routes/${id}`); }, }; // ---------------------------------------------------------- diff --git a/backend/static/js/pages/map.js b/backend/static/js/pages/map.js new file mode 100644 index 0000000..c18d0b5 --- /dev/null +++ b/backend/static/js/pages/map.js @@ -0,0 +1,230 @@ +/* ============================================================ + BAN YARO — Zentrale Karte + Alle Layer auf einer Karte: Orte + Giftköder + ============================================================ */ + +window.Page_map = (() => { + + let _container = null; + let _appState = null; + let _map = null; + let _leafletLoaded = false; + let _userPos = null; + + // Layer-Marker + let _layers = { + restaurant: [], + freilauf: [], + shop: [], + kotbeutel: [], + tierarzt: [], + hundeschule: [], + poison: [], + }; + + // Layer-Sichtbarkeit + let _visible = { + restaurant: true, + freilauf: true, + shop: true, + kotbeutel: true, + tierarzt: true, + hundeschule: true, + poison: true, + }; + + const TYPEN = { + restaurant: { icon: '🍽️', label: 'Restaurant', color: '#F97316' }, + freilauf: { icon: '🐕', label: 'Freilauf', color: '#22C55E' }, + shop: { icon: '🛒', label: 'Shop', color: '#3B82F6' }, + kotbeutel: { icon: '🧻', label: 'Kotbeutel', color: '#6B7280' }, + tierarzt: { icon: '🩺', label: 'Tierarzt', color: '#EF4444' }, + hundeschule: { icon: '🎓', label: 'Hundeschule', color: '#8B5CF6' }, + poison: { icon: '⚠️', label: 'Giftköder', color: '#DC2626' }, + }; + + // ---------------------------------------------------------- + // INIT + // ---------------------------------------------------------- + async function init(container, appState) { + _container = container; + _appState = appState; + _render(); + try { _userPos = await API.getLocation(); } catch {} + await _loadLeaflet(); + _initMap(); + _loadAll(); + } + + function refresh() { _loadAll(); } + function onDogChange() {} + + // ---------------------------------------------------------- + // RENDER + // ---------------------------------------------------------- + function _render() { + _container.innerHTML = ` +
+ + +
+ ${Object.entries(TYPEN).map(([k, t]) => ` + + `).join('')} +
+ + +
+ + + + +
+ `; + + // Layer-Toggle + document.getElementById('map-legend').addEventListener('click', e => { + const btn = e.target.closest('.map-legend-btn'); + if (!btn) return; + const layer = btn.dataset.layer; + _visible[layer] = !_visible[layer]; + btn.classList.toggle('active', _visible[layer]); + _applyVisibility(layer); + }); + + // GPS-Locate + document.getElementById('map-locate-btn').addEventListener('click', async () => { + try { + _userPos = await API.getLocation({ enableHighAccuracy: true }); + _map?.setView([_userPos.lat, _userPos.lon], 14); + } catch { UI.toast.error('Standort konnte nicht ermittelt werden.'); } + }); + } + + // ---------------------------------------------------------- + // Leaflet laden + // ---------------------------------------------------------- + async function _loadLeaflet() { + if (_leafletLoaded || window.L) { _leafletLoaded = true; return; } + const link = document.createElement('link'); + link.rel = 'stylesheet'; + link.href = '/css/leaflet.css'; + document.head.appendChild(link); + await new Promise(resolve => { + const s = document.createElement('script'); + s.src = '/js/leaflet.js'; + s.onload = resolve; + document.head.appendChild(s); + }); + _leafletLoaded = true; + } + + // ---------------------------------------------------------- + // Karte initialisieren + // ---------------------------------------------------------- + function _initMap() { + const el = document.getElementById('central-map'); + if (!el || !window.L || _map) return; + + const center = _userPos ? [_userPos.lat, _userPos.lon] : [51.1657, 10.4515]; + const zoom = _userPos ? 13 : 6; + + _map = L.map('central-map', { zoomControl: true, attributionControl: false }) + .setView(center, zoom); + + L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', { maxZoom: 19 }) + .addTo(_map); + } + + // ---------------------------------------------------------- + // Alle Layer laden + // ---------------------------------------------------------- + async function _loadAll() { + // Alles zurücksetzen + Object.values(_layers).flat().forEach(m => m.remove?.()); + _layers = { restaurant: [], freilauf: [], shop: [], kotbeutel: [], tierarzt: [], hundeschule: [], poison: [] }; + + // Parallel laden + const [places, poisonList] = await Promise.allSettled([ + API.places.list(), + _userPos + ? API.poison.listNearby(_userPos.lat, _userPos.lon, 10000) + : Promise.resolve([]), + ]); + + if (places.status === 'fulfilled') _addPlaces(places.value); + if (poisonList.status === 'fulfilled') _addPoison(poisonList.value); + } + + // ---------------------------------------------------------- + // Orte-Marker + // ---------------------------------------------------------- + function _addPlaces(places) { + if (!_map || !window.L) return; + places.forEach(place => { + const t = TYPEN[place.typ]; + if (!t) return; + const m = _createMarker(place.lat, place.lon, t, place.name, () => _showPlacePopup(place)); + _layers[place.typ]?.push(m); + if (!_visible[place.typ]) m.setOpacity(0); + }); + } + + function _showPlacePopup(place) { + const t = TYPEN[place.typ] || { icon: '📍', label: place.typ }; + UI.toast.info(`${t.icon} ${place.name}${place.adresse ? ' · ' + place.adresse : ''}`); + } + + // ---------------------------------------------------------- + // Giftköder-Marker + // ---------------------------------------------------------- + function _addPoison(items) { + if (!_map || !window.L) return; + items.forEach(p => { + const t = TYPEN.poison; + const m = _createMarker(p.lat, p.lon, t, `Giftköder-Alarm${p.beschreibung ? ': ' + p.beschreibung : ''}`, () => { + App.navigate('poison'); + }); + _layers.poison.push(m); + if (!_visible.poison) m.setOpacity(0); + }); + } + + // ---------------------------------------------------------- + // Marker-Hilfsfunktion + // ---------------------------------------------------------- + function _createMarker(lat, lon, t, tooltip, onClick) { + const icon = L.divIcon({ + className: '', + html: `
${t.icon}
`, + iconSize: [32, 32], + iconAnchor: [16, 16], + }); + return L.marker([lat, lon], { icon }) + .addTo(_map) + .bindTooltip(tooltip, { direction: 'top', offset: [0, -16] }) + .on('click', onClick); + } + + // ---------------------------------------------------------- + // Layer ein/ausblenden + // ---------------------------------------------------------- + function _applyVisibility(layer) { + (_layers[layer] || []).forEach(m => { + // Leaflet hat keine native hide — Opacity-Trick + if (m.setOpacity) m.setOpacity(_visible[layer] ? 1 : 0); + }); + } + + return { init, refresh, onDogChange }; + +})(); diff --git a/backend/static/js/pages/places.js b/backend/static/js/pages/places.js new file mode 100644 index 0000000..1c04cf8 --- /dev/null +++ b/backend/static/js/pages/places.js @@ -0,0 +1,482 @@ +/* ============================================================ + BAN YARO — Orte (Hundefreundliche Orte) + Karte + Liste, Eigene Orte anlegen/bearbeiten + ============================================================ */ + +window.Page_places = (() => { + + let _container = null; + let _appState = null; + let _map = null; + let _markers = []; + let _data = []; + let _activeTyp = null; // null = alle + let _leafletLoaded = false; + let _userPos = null; + + // ---------------------------------------------------------- + // Typen-Konfiguration + // ---------------------------------------------------------- + const TYPEN = { + restaurant: { icon: '🍽️', label: 'Restaurant & Café', color: '#F97316' }, + freilauf: { icon: '🐕', label: 'Freilauffläche', color: '#22C55E' }, + shop: { icon: '🛒', label: 'Shop', color: '#3B82F6' }, + kotbeutel: { icon: '🧻', label: 'Kotbeutel-Station', color: '#6B7280' }, + tierarzt: { icon: '🩺', label: 'Tierarzt', color: '#EF4444' }, + hundeschule: { icon: '🎓', label: 'Hundeschule', color: '#8B5CF6' }, + }; + + function _esc(s) { + return String(s || '').replace(/&/g,'&').replace(//g,'>').replace(/"/g,'"'); + } + + // ---------------------------------------------------------- + // INIT + // ---------------------------------------------------------- + async function init(container, appState) { + _container = container; + _appState = appState; + _render(); + _loadData(); + try { _userPos = await API.getLocation(); } catch {} + } + + function refresh() { _loadData(); } + function onDogChange() {} + + // ---------------------------------------------------------- + // RENDER — Grundstruktur + // ---------------------------------------------------------- + function _render() { + _container.innerHTML = ` +
+ + +
+
+ + ${Object.entries(TYPEN).map(([k, t]) => + `` + ).join('')} +
+ +
+ + +
+ + +
+
+

+ Lädt… +

+
+
+ +
+ `; + + // Events + document.getElementById('places-filter').addEventListener('click', e => { + const btn = e.target.closest('.places-filter-btn'); + if (!btn) return; + document.querySelectorAll('.places-filter-btn').forEach(b => b.classList.remove('active')); + btn.classList.add('active'); + _activeTyp = btn.dataset.typ || null; + _applyFilter(); + }); + + document.getElementById('places-add-btn').addEventListener('click', () => { + if (!_appState.user) { + UI.toast.warning('Bitte zuerst anmelden.'); + App.navigate('settings'); + return; + } + _showForm(null); + }); + + _loadLeaflet().then(_initMap); + } + + // ---------------------------------------------------------- + // Leaflet laden (wie poison.js) + // ---------------------------------------------------------- + async function _loadLeaflet() { + if (_leafletLoaded || window.L) { _leafletLoaded = true; return; } + const link = document.createElement('link'); + link.rel = 'stylesheet'; + link.href = '/css/leaflet.css'; + document.head.appendChild(link); + await new Promise(resolve => { + const s = document.createElement('script'); + s.src = '/js/leaflet.js'; + s.onload = resolve; + document.head.appendChild(s); + }); + _leafletLoaded = true; + } + + // ---------------------------------------------------------- + // Karte initialisieren + // ---------------------------------------------------------- + function _initMap() { + const el = document.getElementById('places-map'); + if (!el || !window.L || _map) return; + + const center = _userPos ? [_userPos.lat, _userPos.lon] : [51.1657, 10.4515]; + const zoom = _userPos ? 13 : 6; + + _map = L.map('places-map', { zoomControl: true, attributionControl: false }) + .setView(center, zoom); + + L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', { maxZoom: 19 }) + .addTo(_map); + + // GPS-Locate-Button + L.Control.Locate = L.Control.extend({ + onAdd() { + const btn = L.DomUtil.create('button', 'places-locate-btn'); + btn.innerHTML = '📍'; + btn.title = 'Meinen Standort'; + btn.onclick = async () => { + try { + const pos = await API.getLocation({ enableHighAccuracy: true }); + _userPos = pos; + _map.setView([pos.lat, pos.lon], 14); + } catch { UI.toast.error('Standort konnte nicht ermittelt werden.'); } + }; + return btn; + }, + onRemove() {}, + }); + new L.Control.Locate({ position: 'bottomright' }).addTo(_map); + + _renderMarkers(); + } + + // ---------------------------------------------------------- + // Daten laden + // ---------------------------------------------------------- + async function _loadData() { + try { + _data = await API.places.list(); + _renderList(); + _renderMarkers(); + } catch (err) { + UI.toast.error(err.message || 'Fehler beim Laden der Orte.'); + } + } + + // ---------------------------------------------------------- + // Filter anwenden + // ---------------------------------------------------------- + function _filtered() { + return _activeTyp ? _data.filter(p => p.typ === _activeTyp) : _data; + } + + function _applyFilter() { + _renderList(); + _renderMarkers(); + } + + // ---------------------------------------------------------- + // Marker rendern + // ---------------------------------------------------------- + function _renderMarkers() { + if (!_map || !window.L) return; + _markers.forEach(m => m.remove()); + _markers = []; + + _filtered().forEach(place => { + const t = TYPEN[place.typ] || { icon: '📍', color: '#6B7280' }; + const icon = L.divIcon({ + className: '', + html: `
${t.icon}
`, + iconSize: [34, 34], + iconAnchor: [17, 17], + }); + const marker = L.marker([place.lat, place.lon], { icon }) + .addTo(_map) + .on('click', () => _openDetail(place)); + _markers.push(marker); + }); + } + + // ---------------------------------------------------------- + // Liste rendern + // ---------------------------------------------------------- + function _renderList() { + const list = document.getElementById('places-list'); + if (!list) return; + const items = _filtered(); + + if (!items.length) { + list.innerHTML = ` +
+

+ ${_activeTyp ? 'Keine Orte in dieser Kategorie.' : 'Noch keine Orte eingetragen.'} +

+
`; + return; + } + + list.innerHTML = ` +
+ ${items.map(p => _cardHTML(p)).join('')} +
`; + + list.querySelectorAll('.places-card').forEach(card => { + const id = parseInt(card.dataset.id); + const place = _data.find(p => p.id === id); + if (place) card.addEventListener('click', () => _openDetail(place)); + }); + } + + function _cardHTML(p) { + const t = TYPEN[p.typ] || { icon: '📍', label: p.typ, color: '#6B7280' }; + const flags = [ + p.hund_rein === true ? '🐕 Hund rein' : null, + p.leine_pflicht === true ? '🔗 Leinenpflicht' : null, + p.wasser_fuer_hunde === true ? '💧 Wasser' : null, + ].filter(Boolean); + + return ` +
+
${t.icon}
+
+
${_esc(p.name)}
+
+ ${t.label} + ${p.adresse ? `· ${_esc(p.adresse)}` : ''} +
+ ${flags.length ? `
${flags.map(f => `${f}`).join('')}
` : ''} +
+
+
`; + } + + // ---------------------------------------------------------- + // Detail-Modal + // ---------------------------------------------------------- + function _openDetail(place) { + const t = TYPEN[place.typ] || { icon: '📍', label: place.typ, color: '#6B7280' }; + const isOwn = _appState.user?.id === place.user_id; + + const flags = [ + place.hund_rein === true ? '🐕 Hund erlaubt' : (place.hund_rein === false ? '🚫 Kein Hund' : null), + place.leine_pflicht === true ? '🔗 Leinenpflicht' : (place.leine_pflicht === false ? '✅ Leine optional' : null), + place.wasser_fuer_hunde === true ? '💧 Wasser vorhanden': null, + ].filter(Boolean); + + const body = ` +
+
${t.icon}
+
+
${_esc(place.name)}
+
${t.label}
+
+
+ ${place.adresse ? `

📍 ${_esc(place.adresse)}

` : ''} + ${place.website ? `

🌐 ${_esc(place.website)}

` : ''} + ${flags.length ? `
${flags.map(f => `${f}`).join('')}
` : ''} +

+ Eingetragen von ${_esc(place.user_name || 'Unbekannt')} +

+ `; + + const footer = isOwn ? ` + + + + ` : ` + + `; + + UI.modal.open({ title: `${t.icon} ${place.name}`, body, footer }); + + document.getElementById('place-detail-close')?.addEventListener('click', UI.modal.close); + + document.getElementById('place-detail-edit')?.addEventListener('click', () => { + UI.modal.close(); + _showForm(place); + }); + + document.getElementById('place-detail-delete')?.addEventListener('click', async () => { + const ok = await UI.modal.confirm({ + title: 'Ort löschen?', + message: `„${place.name}" wird dauerhaft entfernt.`, + confirmText: 'Löschen', + danger: true, + }); + if (!ok) return; + try { + await API.places.delete(place.id); + _data = _data.filter(p => p.id !== place.id); + UI.modal.close(); + _renderList(); + _renderMarkers(); + UI.toast.success('Ort gelöscht.'); + } catch (err) { + UI.toast.error(err.message || 'Fehler beim Löschen.'); + } + }); + + // Auf Karte zentrieren + if (_map) _map.setView([place.lat, place.lon], 15); + } + + // ---------------------------------------------------------- + // Formular — Ort anlegen / bearbeiten + // ---------------------------------------------------------- + function _showForm(place) { + const isEdit = !!place; + + const typOpts = Object.entries(TYPEN) + .map(([k, t]) => ``) + .join(''); + + const body = ` +
+ +
+ + +
+ +
+ + +
+ +
+ +
+ + + +
+ + + + ${place ? '✅ Position gespeichert' : 'GPS-Button drücken oder Standort ermitteln'} + +
+ +
+ + +
+ +
+ + +
+ +
+ + + + +
+ +
+ `; + + const footer = ` + + + `; + + UI.modal.open({ title: isEdit ? `${place.name} bearbeiten` : '📍 Neuer Ort', body, footer }); + + document.getElementById('place-form-cancel')?.addEventListener('click', UI.modal.close); + + // GPS-Button + document.getElementById('pf-gps-btn')?.addEventListener('click', async () => { + const btn = document.getElementById('pf-gps-btn'); + UI.setLoading(btn, true); + try { + const pos = await API.getLocation({ enableHighAccuracy: true }); + _userPos = pos; + document.getElementById('pf-lat').value = pos.lat; + document.getElementById('pf-lon').value = pos.lon; + document.getElementById('pf-lat-disp').value = pos.lat.toFixed(6); + document.getElementById('pf-lon-disp').value = pos.lon.toFixed(6); + document.getElementById('pf-gps-hint').textContent = '✅ Standort ermittelt'; + } catch { + UI.toast.error('GPS nicht verfügbar.'); + } + UI.setLoading(btn, false); + }); + + document.getElementById('place-form')?.addEventListener('submit', async e => { + e.preventDefault(); + const btn = document.querySelector('[form="place-form"][type="submit"]') || e.target.querySelector('[type="submit"]'); + const fd = UI.formData(e.target); + + if (!fd.lat || !fd.lon) { + UI.toast.warning('Bitte GPS-Position ermitteln.'); + return; + } + + await UI.asyncButton(btn, async () => { + const payload = { + name: fd.name?.trim(), + typ: fd.typ, + lat: parseFloat(fd.lat), + lon: parseFloat(fd.lon), + adresse: fd.adresse || null, + website: fd.website || null, + hund_rein: 'hund_rein' in fd, + leine_pflicht: 'leine_pflicht' in fd, + wasser_fuer_hunde: 'wasser_fuer_hunde' in fd, + }; + + if (isEdit) { + const updated = await API.places.update(place.id, payload); + const idx = _data.findIndex(p => p.id === place.id); + if (idx !== -1) _data[idx] = updated; + UI.toast.success('Gespeichert.'); + } else { + const created = await API.places.create(payload); + _data.unshift(created); + UI.toast.success('Ort hinzugefügt!'); + } + + UI.modal.close(); + _renderList(); + _renderMarkers(); + }); + }); + } + + return { init, refresh, onDogChange }; + +})(); diff --git a/backend/static/js/pages/routes.js b/backend/static/js/pages/routes.js new file mode 100644 index 0000000..d408e27 --- /dev/null +++ b/backend/static/js/pages/routes.js @@ -0,0 +1,583 @@ +/* ============================================================ + BAN YARO — Gassi-Routen + Routen entdecken (Karte + Liste) + GPS-Aufzeichnung + ============================================================ */ + +window.Page_routes = (() => { + + let _container = null; + let _appState = null; + let _map = null; + let _polylines = []; + let _data = []; + let _activeTab = 'entdecken'; // 'entdecken' | 'aufzeichnen' + let _leafletLoaded = false; + let _userPos = null; + + // Aufzeichnung + let _recording = false; + let _watchId = null; + let _track = []; // [{lat, lon}] + let _distanceKm = 0; + let _startTime = null; + let _timerInt = null; + let _recPolyline = null; + let _recMarker = null; + + const SCHWIERIGKEIT = ['leicht', 'mittel', 'anspruchsvoll']; + const UNTERGRUND = { wald: '🌲 Wald', asphalt: '🛣️ Asphalt', wiese: '🌿 Wiese', mix: '🔀 Mix' }; + + function _esc(s) { + return String(s || '').replace(/&/g,'&').replace(//g,'>').replace(/"/g,'"'); + } + + function _haversine(lat1, lon1, lat2, lon2) { + const R = 6371000; + const p1 = lat1 * Math.PI / 180, p2 = lat2 * Math.PI / 180; + const dp = (lat2 - lat1) * Math.PI / 180; + const dl = (lon2 - lon1) * Math.PI / 180; + const a = Math.sin(dp/2)**2 + Math.cos(p1)*Math.cos(p2)*Math.sin(dl/2)**2; + return 2 * R * Math.asin(Math.sqrt(a)); + } + + // ---------------------------------------------------------- + // INIT + // ---------------------------------------------------------- + async function init(container, appState) { + _container = container; + _appState = appState; + _render(); + _loadData(); + try { _userPos = await API.getLocation(); } catch {} + } + + function refresh() { _loadData(); } + function onDogChange() {} + + // ---------------------------------------------------------- + // RENDER — Grundstruktur + // ---------------------------------------------------------- + function _render() { + _container.innerHTML = ` +
+ + +
+ + +
+ + +
+
+
+

Lädt…

+
+
+ + + + +
+ `; + + // Tab-Switching + document.getElementById('routes-tabs').addEventListener('click', e => { + const btn = e.target.closest('.routes-tab'); + if (!btn) return; + _switchTab(btn.dataset.tab); + }); + + document.getElementById('rec-start-btn')?.addEventListener('click', _startRecording); + + _loadLeaflet().then(() => { + _initDiscoverMap(); + _initRecordMap(); + }); + } + + function _switchTab(tab) { + _activeTab = tab; + document.querySelectorAll('.routes-tab').forEach(b => + b.classList.toggle('active', b.dataset.tab === tab)); + document.getElementById('tab-entdecken').style.display = tab === 'entdecken' ? '' : 'none'; + document.getElementById('tab-aufzeichnen').style.display = tab === 'aufzeichnen' ? '' : 'none'; + if (tab === 'entdecken' && _map) setTimeout(() => _map.invalidateSize(), 50); + if (tab === 'aufzeichnen' && _recMap) setTimeout(() => _recMap.invalidateSize(), 50); + } + + // ---------------------------------------------------------- + // Leaflet laden + // ---------------------------------------------------------- + async function _loadLeaflet() { + if (_leafletLoaded || window.L) { _leafletLoaded = true; return; } + const link = document.createElement('link'); + link.rel = 'stylesheet'; + link.href = '/css/leaflet.css'; + document.head.appendChild(link); + await new Promise(resolve => { + const s = document.createElement('script'); + s.src = '/js/leaflet.js'; + s.onload = resolve; + document.head.appendChild(s); + }); + _leafletLoaded = true; + } + + // ---------------------------------------------------------- + // Entdecken-Karte + // ---------------------------------------------------------- + function _initDiscoverMap() { + const el = document.getElementById('routes-map'); + if (!el || !window.L || _map) return; + + const center = _userPos ? [_userPos.lat, _userPos.lon] : [51.1657, 10.4515]; + const zoom = _userPos ? 12 : 6; + + _map = L.map('routes-map', { zoomControl: true, attributionControl: false }) + .setView(center, zoom); + L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', { maxZoom: 19 }).addTo(_map); + _renderPolylines(); + } + + let _recMap = null; + + function _initRecordMap() { + const el = document.getElementById('rec-map'); + if (!el || !window.L || _recMap) return; + + const center = _userPos ? [_userPos.lat, _userPos.lon] : [51.1657, 10.4515]; + + _recMap = L.map('rec-map', { zoomControl: false, attributionControl: false }) + .setView(center, _userPos ? 15 : 6); + L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', { maxZoom: 19 }).addTo(_recMap); + } + + // ---------------------------------------------------------- + // Daten laden + // ---------------------------------------------------------- + async function _loadData() { + try { + _data = await API.routes.list(); + _renderList(); + _renderPolylines(); + } catch (err) { + UI.toast.error(err.message || 'Fehler beim Laden der Routen.'); + } + } + + // ---------------------------------------------------------- + // Polylines auf Entdecken-Karte + // ---------------------------------------------------------- + function _renderPolylines() { + if (!_map || !window.L) return; + _polylines.forEach(p => p.remove()); + _polylines = []; + + // Nur Routen mit bekanntem Startpunkt (List-Response hat start_lat/lon) + _data.forEach((route, i) => { + if (!route.start_lat) return; + // Startpunkt-Marker + const colors = ['#C4843A','#3B82F6','#22C55E','#EF4444','#8B5CF6','#F97316']; + const color = colors[i % colors.length]; + const icon = L.divIcon({ + className: '', + html: `
${i + 1}
`, + iconSize: [28, 28], iconAnchor: [14, 14], + }); + const m = L.marker([route.start_lat, route.start_lon], { icon }) + .addTo(_map) + .on('click', () => _openDetail(route.id)); + _polylines.push(m); + }); + } + + // ---------------------------------------------------------- + // Listen-Karten + // ---------------------------------------------------------- + function _renderList() { + const list = document.getElementById('routes-list'); + if (!list) return; + + if (!_data.length) { + list.innerHTML = `

+ Noch keine Routen vorhanden.
+ +

`; + return; + } + + list.innerHTML = _data.map((r, i) => _routeCardHTML(r, i)).join(''); + + list.querySelectorAll('.routes-card').forEach(card => { + card.addEventListener('click', () => _openDetail(parseInt(card.dataset.id))); + }); + } + + function _routeCardHTML(r, i) { + const colors = ['#C4843A','#3B82F6','#22C55E','#EF4444','#8B5CF6','#F97316']; + const color = colors[i % colors.length]; + const schwTag = r.schwierigkeit + ? `${r.schwierigkeit}` : ''; + const ug = r.untergrund ? (UNTERGRUND[r.untergrund] || r.untergrund) : null; + + return ` +
+
${i + 1}
+
+
${_esc(r.name)}
+
+ ${r.distanz_km ? `🗺️ ${r.distanz_km.toFixed(1)} km` : ''} + ${r.dauer_min ? `· ⏱ ${_formatDuration(r.dauer_min)}` : ''} + ${ug ? `· ${ug}` : ''} +
+
+ ${schwTag} + ${r.schatten ? '🌳 Schatten' : ''} + ${r.leine_empfohlen ? '🔗 Leine empfohlen' : ''} +
+
+ von ${_esc(r.user_name || 'Unbekannt')} +
+
+
`; + } + + function _formatDuration(min) { + if (min < 60) return `${min} min`; + return `${Math.floor(min / 60)}h ${min % 60 ? (min % 60) + 'min' : ''}`.trim(); + } + + // ---------------------------------------------------------- + // Detail-Modal mit vollständiger Polyline + // ---------------------------------------------------------- + async function _openDetail(routeId) { + let route; + try { + route = await API.routes.get(routeId); + } catch (err) { + UI.toast.error(err.message); + return; + } + + const isOwn = _appState.user?.id === route.user_id; + const ug = route.untergrund ? (UNTERGRUND[route.untergrund] || route.untergrund) : null; + const track = route.gps_track || []; + + // Mini-Map als div mit ID, wird nach open() befüllt + const body = ` +
+
+ ${route.distanz_km ? `🗺️ ${route.distanz_km.toFixed(2)} km` : ''} + ${route.dauer_min ? `⏱ ${_formatDuration(route.dauer_min)}` : ''} + ${route.schwierigkeit ? `${route.schwierigkeit}` : ''} + ${ug ? `${ug}` : ''} + ${route.schatten ? '🌳 Schatten' : ''} + ${route.leine_empfohlen ? '🔗 Leine empfohlen' : ''} +
+ ${route.beschreibung ? `

${_esc(route.beschreibung)}

` : ''} +

+ ${track.length} GPS-Punkte · von ${_esc(route.user_name || 'Unbekannt')} +

+ `; + + const footer = isOwn ? ` + + + ` : ` + + `; + + UI.modal.open({ title: `🥾 ${route.name}`, body, footer }); + + document.getElementById('rd-close')?.addEventListener('click', UI.modal.close); + document.getElementById('rd-delete')?.addEventListener('click', async () => { + const ok = await UI.modal.confirm({ + title: 'Route löschen?', message: `„${route.name}" wird dauerhaft entfernt.`, + confirmText: 'Löschen', danger: true, + }); + if (!ok) return; + try { + await API.routes.delete(route.id); + _data = _data.filter(r => r.id !== route.id); + UI.modal.close(); + _renderList(); + _renderPolylines(); + UI.toast.success('Route gelöscht.'); + } catch (err) { UI.toast.error(err.message); } + }); + + // Mini-Map mit Polyline nach DOM-Einfüge + setTimeout(() => { + const el = document.getElementById('route-detail-map'); + if (!el || !window.L || !track.length) return; + const detailMap = L.map('route-detail-map', { zoomControl: false, attributionControl: false }); + L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', { maxZoom: 19 }).addTo(detailMap); + const latlngs = track.map(p => [p.lat, p.lon]); + const polyline = L.polyline(latlngs, { color: '#C4843A', weight: 4, opacity: 0.85 }).addTo(detailMap); + // Start/End-Marker + L.circleMarker(latlngs[0], { radius: 7, color: '#22C55E', fillColor: '#22C55E', fillOpacity: 1 }).addTo(detailMap); + L.circleMarker(latlngs[latlngs.length-1], { radius: 7, color: '#EF4444', fillColor: '#EF4444', fillOpacity: 1 }).addTo(detailMap); + detailMap.fitBounds(polyline.getBounds(), { padding: [10, 10] }); + }, 100); + } + + // ---------------------------------------------------------- + // GPS-AUFZEICHNUNG + // ---------------------------------------------------------- + function _startRecording() { + if (!_appState.user) { + UI.toast.warning('Bitte zuerst anmelden.'); + App.navigate('settings'); + return; + } + if (!navigator.geolocation) { + UI.toast.error('GPS wird von diesem Browser nicht unterstützt.'); + return; + } + + _recording = true; + _track = []; + _distanceKm = 0; + _startTime = Date.now(); + + // UI umschalten + document.getElementById('rec-idle').style.display = 'none'; + document.getElementById('rec-active').style.display = ''; + document.getElementById('rec-stats').style.display = ''; + + // Stopuhr + _timerInt = setInterval(_updateRecTimer, 1000); + + // GPS-Tracking + _watchId = navigator.geolocation.watchPosition( + pos => _onGpsPoint(pos.coords.latitude, pos.coords.longitude), + err => UI.toast.warning('GPS-Fehler: ' + err.message), + { enableHighAccuracy: true, maximumAge: 0, timeout: 15000 } + ); + + // Buttons binden + document.getElementById('rec-stop-btn').onclick = _stopRecording; + document.getElementById('rec-pause-btn').onclick = _togglePause; + + UI.toast.success('Aufzeichnung gestartet — los geht\'s!'); + } + + let _paused = false; + + function _togglePause() { + _paused = !_paused; + const btn = document.getElementById('rec-pause-btn'); + if (btn) btn.textContent = _paused ? '▶ Weiter' : '⏸ Pause'; + if (_paused && _timerInt) clearInterval(_timerInt); + if (!_paused) _timerInt = setInterval(_updateRecTimer, 1000); + } + + function _onGpsPoint(lat, lon) { + if (_paused) return; + const pt = { lat, lon }; + if (_track.length > 0) { + const prev = _track[_track.length - 1]; + _distanceKm += _haversine(prev.lat, prev.lon, lat, lon) / 1000; + } + _track.push(pt); + _updateRecStats(); + _updateRecMap(lat, lon); + } + + function _updateRecMap(lat, lon) { + if (!_recMap || !window.L) return; + const latlng = [lat, lon]; + if (!_recPolyline) { + _recPolyline = L.polyline([latlng], { color: '#EF4444', weight: 5, opacity: 0.9 }).addTo(_recMap); + } else { + _recPolyline.addLatLng(latlng); + } + if (!_recMarker) { + _recMarker = L.circleMarker(latlng, { + radius: 8, color: '#EF4444', fillColor: '#fff', fillOpacity: 1, weight: 3, + }).addTo(_recMap); + } else { + _recMarker.setLatLng(latlng); + } + _recMap.setView(latlng); + } + + function _updateRecStats() { + const dist = document.getElementById('rec-dist'); + const pts = document.getElementById('rec-pts'); + if (dist) dist.textContent = _distanceKm.toFixed(2); + if (pts) pts.textContent = _track.length; + } + + function _updateRecTimer() { + const el = document.getElementById('rec-time'); + if (!el) return; + const secs = Math.floor((Date.now() - _startTime) / 1000); + const mm = String(Math.floor(secs / 60)).padStart(2, '0'); + const ss = String(secs % 60).padStart(2, '0'); + el.textContent = `${mm}:${ss}`; + } + + function _stopRecording() { + if (_watchId !== null) { navigator.geolocation.clearWatch(_watchId); _watchId = null; } + if (_timerInt) { clearInterval(_timerInt); _timerInt = null; } + _recording = false; + _paused = false; + + if (_track.length < 2) { + UI.toast.warning('Zu wenige GPS-Punkte — bitte etwas länger gehen.'); + _resetRecUI(); + return; + } + + const dauer = Math.floor((Date.now() - _startTime) / 1000 / 60); + _showSaveForm(_track, _distanceKm, dauer); + } + + function _resetRecUI() { + document.getElementById('rec-idle').style.display = ''; + document.getElementById('rec-active').style.display = 'none'; + document.getElementById('rec-stats').style.display = 'none'; + if (_recPolyline) { _recPolyline.remove(); _recPolyline = null; } + if (_recMarker) { _recMarker.remove(); _recMarker = null; } + _track = []; _distanceKm = 0; + } + + // ---------------------------------------------------------- + // Speicher-Formular nach Aufzeichnung + // ---------------------------------------------------------- + function _showSaveForm(track, distKm, dauMin) { + const schwOpts = SCHWIERIGKEIT + .map(s => ``) + .join(''); + const ugOpts = Object.entries(UNTERGRUND) + .map(([v, l]) => ``) + .join(''); + + const body = ` +

+ 🎉 ${track.length} GPS-Punkte · ${distKm.toFixed(2)} km · ca. ${dauMin} min +

+
+
+ + +
+
+
+ + +
+
+ + +
+
+
+ + +
+
+ + +
+
+ `; + + const footer = ` + + + `; + + UI.modal.open({ title: '🥾 Route benennen', body, footer }); + + document.getElementById('rs-discard')?.addEventListener('click', () => { + UI.modal.close(); + _resetRecUI(); + }); + + document.getElementById('route-save-form')?.addEventListener('submit', async e => { + e.preventDefault(); + const btn = document.querySelector('[form="route-save-form"][type="submit"]') || e.target.querySelector('[type="submit"]'); + const fd = UI.formData(e.target); + + await UI.asyncButton(btn, async () => { + const payload = { + name: fd.name?.trim(), + beschreibung: fd.beschreibung || null, + gps_track: track, + distanz_km: Math.round(distKm * 100) / 100, + dauer_min: dauMin, + schwierigkeit: fd.schwierigkeit || 'leicht', + untergrund: fd.untergrund || null, + schatten: 'schatten' in fd, + leine_empfohlen: 'leine_empfohlen' in fd, + }; + + const saved = await API.routes.create(payload); + // Für die Liste: start_lat/lon aus Track ergänzen + saved.start_lat = track[0].lat; + saved.start_lon = track[0].lon; + _data.unshift(saved); + + UI.modal.close(); + _resetRecUI(); + _renderList(); + _renderPolylines(); + _switchTab('entdecken'); + UI.toast.success(`Route „${saved.name}" gespeichert! 🎉`); + }); + }); + } + + return { init, refresh, onDogChange }; + +})(); diff --git a/backend/static/sw.js b/backend/static/sw.js index a2d2c54..983ee26 100644 --- a/backend/static/sw.js +++ b/backend/static/sw.js @@ -3,7 +3,7 @@ Offline-Cache + Push Notifications ============================================================ */ -const CACHE_VERSION = 'by-v19'; +const CACHE_VERSION = 'by-v20'; const CACHE_STATIC = `${CACHE_VERSION}-static`; // Diese Dateien werden beim Install gecacht (App Shell)