diff --git a/backend/database.py b/backend/database.py index 2dd12cd..1faf38c 100644 --- a/backend/database.py +++ b/backend/database.py @@ -177,6 +177,32 @@ def init_db(): anz_bewertungen INTEGER DEFAULT 0, created_at TEXT NOT NULL DEFAULT (datetime('now')) ); + CREATE TABLE IF NOT EXISTS route_walks ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + route_id INTEGER REFERENCES routes(id) ON DELETE SET NULL, + walked_km REAL NOT NULL, + walked_at TEXT NOT NULL DEFAULT (datetime('now')) + ); + CREATE INDEX IF NOT EXISTS idx_route_walks_user ON route_walks(user_id); + + CREATE TABLE IF NOT EXISTS exercise_progress ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + exercise_id TEXT NOT NULL, + status TEXT, + updated_at TEXT NOT NULL DEFAULT (datetime('now')), + UNIQUE(user_id, exercise_id) + ); + CREATE INDEX IF NOT EXISTS idx_exercise_progress_user ON exercise_progress(user_id); + + CREATE TABLE IF NOT EXISTS training_plan_progress ( + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + item_key TEXT NOT NULL, + checked INTEGER NOT NULL DEFAULT 1, + checked_at TEXT NOT NULL DEFAULT (datetime('now')), + PRIMARY KEY (user_id, item_key) + ); -- GASSI-TREFFEN CREATE TABLE IF NOT EXISTS walks ( @@ -486,6 +512,16 @@ def _migrate(conn_factory): ("forum_threads", "thread_lat", "REAL"), ("forum_threads", "thread_lon", "REAL"), ("forum_threads", "thread_ort", "TEXT"), + # Referral (idempotent, falls try/except-Block oben fehlgeschlagen ist) + ("users", "referral_code", "TEXT"), + ("users", "referred_by", "INTEGER"), + # Routen: Original-Werte für Gamification (bleiben nach Kürzen erhalten) + ("routes", "original_km", "REAL"), + ("routes", "original_dauer_min","INTEGER"), + # Gamification: Streaks + ("users", "current_streak", "INTEGER NOT NULL DEFAULT 0"), + ("users", "max_streak", "INTEGER NOT NULL DEFAULT 0"), + ("users", "last_activity_date","TEXT"), ] with conn_factory() as conn: for table, column, col_type in migrations: @@ -848,3 +884,16 @@ def _migrate(conn_factory): logger.info("Migration: referral_code + referred_by zu users hinzugefügt.") except Exception: pass + + # Gamification: Badge-Tabelle + conn.executescript(""" + CREATE TABLE IF NOT EXISTS user_badges ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + badge_id TEXT NOT NULL, + earned_at TEXT NOT NULL DEFAULT (datetime('now')), + UNIQUE(user_id, badge_id) + ); + CREATE INDEX IF NOT EXISTS idx_user_badges_user ON user_badges(user_id); + """) + logger.info("Migration: user_badges Tabelle bereit.") diff --git a/backend/main.py b/backend/main.py index 8eeda81..7ccfaa1 100644 --- a/backend/main.py +++ b/backend/main.py @@ -96,6 +96,9 @@ from routes.notifications import router as notifications_router from routes.services import router as services_router from routes.ratings import router as ratings_router from routes.sitting_access import router as sitting_access_router +from routes.stats import router as stats_router +from routes.achievements import router as achievements_router +from routes.training import router as training_router app.include_router(auth_router, prefix="/api/auth", tags=["Auth"]) app.include_router(dogs_router, prefix="/api/dogs", tags=["Hunde"]) @@ -129,6 +132,9 @@ app.include_router(notifications_router, prefix="/api/notifications", tags=["N app.include_router(services_router, prefix="/api/services", tags=["Services"]) app.include_router(ratings_router, prefix="/api/ratings", tags=["Ratings"]) app.include_router(sitting_access_router, prefix="/api/sitting-access", tags=["SittingAccess"]) +app.include_router(stats_router, prefix="/api/stats", tags=["Stats"]) +app.include_router(achievements_router, prefix="/api/achievements", tags=["Achievements"]) +app.include_router(training_router, prefix="/api/training", tags=["Training"]) # ------------------------------------------------------------------ diff --git a/backend/routes/achievements.py b/backend/routes/achievements.py new file mode 100644 index 0000000..4ad6b98 --- /dev/null +++ b/backend/routes/achievements.py @@ -0,0 +1,279 @@ +"""Ban Yaro — Gamification: Badges, Streaks, Punkte""" + +from datetime import date, timedelta +from fastapi import APIRouter, Depends +from database import db +from auth import get_current_user + +router = APIRouter() + +# ------------------------------------------------------------------ +# Tier-Definitionen +# ------------------------------------------------------------------ +TIERS = { + "bronze": {"name": "Bronze", "color": "#cd7f32", "dark": "#7c4a0a", "text": "#fff"}, + "silber": {"name": "Silber", "color": "#94a3b8", "dark": "#475569", "text": "#fff"}, + "gold": {"name": "Gold", "color": "#f59e0b", "dark": "#b45309", "text": "#fff"}, + "platin": {"name": "Platin", "color": "#cbd5e1", "dark": "#94a3b8", "text": "#1e293b"}, + "diamant": {"name": "Diamant", "color": "#67e8f9", "dark": "#0891b2", "text": "#fff"}, +} + +TIER_ORDER = ["bronze", "silber", "gold", "platin", "diamant"] + +# ------------------------------------------------------------------ +# Badge-Kategorien mit Stufen +# ------------------------------------------------------------------ +CATEGORIES = [ + { + "id": "km", + "name": "Kilometer", + "emoji": "🐾", + "metrik": "total_km", + "einheit": "km", + "stufen": [ + ("bronze", 5, "Erste Schritte"), + ("silber", 25, "Vielläufer"), + ("gold", 100, "100-km-Club"), + ("platin", 500, "Ausdauer-Profi"), + ("diamant", 2000, "Unermüdlich"), + ], + }, + { + "id": "routen", + "name": "Routen", + "emoji": "🗺️", + "metrik": "routen", + "einheit": "", + "stufen": [ + ("bronze", 1, "Entdecker"), + ("silber", 5, "Kartograph"), + ("gold", 20, "Routen-Profi"), + ("platin", 50, "Gassi-Legende"), + ("diamant", 100, "Route-König"), + ], + }, + { + "id": "pois", + "name": "POIs", + "emoji": "📍", + "metrik": "pois", + "einheit": "", + "stufen": [ + ("bronze", 1, "Pfadfinder"), + ("silber", 5, "Community-Held"), + ("gold", 20, "Botschafter"), + ("platin", 50, "Karten-Profi"), + ("diamant", 100, "Legende"), + ], + }, + { + "id": "streak", + "name": "Streak", + "emoji": "🔥", + "metrik": "streak", + "einheit": " Tage", + "stufen": [ + ("bronze", 3, "Auf Trab"), + ("silber", 7, "Wochenheld"), + ("gold", 30, "Monatsläufer"), + ("platin", 100, "Eiserne Pfote"), + ("diamant", 365, "Ein ganzes Jahr"), + ], + }, +] + +# Flat-Liste aller Badge-IDs für DB-Kompatibilität +def _all_badge_ids(): + ids = [] + for cat in CATEGORIES: + for tier, _, _ in cat["stufen"]: + ids.append(f"{cat['id']}_{tier}") + return ids + + +# ------------------------------------------------------------------ +# Streak aktualisieren +# ------------------------------------------------------------------ +def update_streak(user_id: int, conn): + today = date.today().isoformat() + row = conn.execute( + "SELECT current_streak, max_streak, last_activity_date FROM users WHERE id=?", + (user_id,) + ).fetchone() + if not row: + return + + last = row["last_activity_date"] + cur = row["current_streak"] or 0 + mx = row["max_streak"] or 0 + + if last == today: + return + + yesterday = (date.today() - timedelta(days=1)).isoformat() + cur = cur + 1 if last == yesterday else 1 + mx = max(mx, cur) + conn.execute( + "UPDATE users SET current_streak=?, max_streak=?, last_activity_date=? WHERE id=?", + (cur, mx, today, user_id) + ) + + +# ------------------------------------------------------------------ +# Badges prüfen und vergeben +# ------------------------------------------------------------------ +def check_and_award(user_id: int, conn): + stats = conn.execute(""" + SELECT ROUND( + COALESCE((SELECT SUM(r.distanz_km) FROM routes r WHERE r.user_id=? AND r.is_public=1), 0) + + COALESCE((SELECT SUM(w.walked_km) FROM route_walks w WHERE w.user_id=?), 0), + 1) AS total_km, + (SELECT COUNT(*) FROM routes r WHERE r.user_id=? AND r.is_public=1) AS routen, + (SELECT COUNT(*) FROM user_map_pois p WHERE p.user_id=?) AS pois + FROM (SELECT 1) + """, (user_id, user_id, user_id, user_id)).fetchone() + + streak_row = conn.execute( + "SELECT current_streak FROM users WHERE id=?", (user_id,) + ).fetchone() + + metrics = { + "total_km": stats["total_km"] if stats else 0, + "routen": stats["routen"] if stats else 0, + "pois": stats["pois"] if stats else 0, + "streak": (streak_row["current_streak"] if streak_row else 0), + } + + earned = {r["badge_id"] for r in + conn.execute("SELECT badge_id FROM user_badges WHERE user_id=?", + (user_id,)).fetchall()} + + new_badges = [] + for cat in CATEGORIES: + val = metrics.get(cat["metrik"], 0) + for tier, schwelle, badge_name in cat["stufen"]: + bid = f"{cat['id']}_{tier}" + if bid not in earned and val >= schwelle: + conn.execute( + "INSERT OR IGNORE INTO user_badges (user_id, badge_id) VALUES (?,?)", + (user_id, bid) + ) + new_badges.append({ + "id": bid, "name": badge_name, + "emoji": cat["emoji"], + "tier": TIERS[tier]["name"], + }) + return new_badges + + +# ------------------------------------------------------------------ +# API +# ------------------------------------------------------------------ +@router.get("/me") +async def my_achievements(user=Depends(get_current_user)): + uid = user["id"] + with db() as conn: + update_streak(uid, conn) + new_badges = check_and_award(uid, conn) + + stats = conn.execute(""" + SELECT ROUND( + COALESCE((SELECT SUM(r.distanz_km) FROM routes r WHERE r.user_id=? AND r.is_public=1), 0) + + COALESCE((SELECT SUM(w.walked_km) FROM route_walks w WHERE w.user_id=?), 0), + 1) AS total_km, + (SELECT COUNT(*) FROM routes r WHERE r.user_id=? AND r.is_public=1) AS routen, + (SELECT COUNT(*) FROM user_map_pois p WHERE p.user_id=?) AS pois, + ROUND( + COALESCE((SELECT SUM(r.distanz_km) FROM routes r WHERE r.user_id=? AND r.is_public=1), 0) + + COALESCE((SELECT SUM(w.walked_km) FROM route_walks w WHERE w.user_id=?), 0), + 1)*1 + + (SELECT COUNT(*) FROM user_map_pois p WHERE p.user_id=?)*5 + + (SELECT COUNT(*) FROM routes r WHERE r.user_id=? AND r.is_public=1)*10 AS punkte + FROM (SELECT 1) + """, (uid, uid, uid, uid, uid, uid, uid, uid)).fetchone() + + streak_row = conn.execute( + "SELECT current_streak, max_streak FROM users WHERE id=?", (uid,) + ).fetchone() + + earned_rows = conn.execute( + "SELECT badge_id FROM user_badges WHERE user_id=?", (uid,) + ).fetchall() + earned_ids = {r["badge_id"] for r in earned_rows} + + rank_row = conn.execute(""" + SELECT COUNT(*)+1 AS rang FROM ( + SELECT u.id, + ROUND(COALESCE(SUM(r.distanz_km),0),1)*1 + +COUNT(DISTINCT p.id)*5 + +COUNT(DISTINCT r.id)*10 AS punkte + FROM users u + LEFT JOIN routes r ON r.user_id=u.id AND r.is_public=1 + LEFT JOIN user_map_pois p ON p.user_id=u.id + GROUP BY u.id + ) WHERE punkte > ? + """, (stats["punkte"] if stats else 0,)).fetchone() + + metrics = { + "total_km": stats["total_km"] if stats else 0, + "routen": stats["routen"] if stats else 0, + "pois": stats["pois"] if stats else 0, + "streak": (streak_row["current_streak"] if streak_row else 0), + } + + # Kategorien mit aktuellem Tier + Fortschritt aufbauen + badge_categories = [] + for cat in CATEGORIES: + val = metrics.get(cat["metrik"], 0) + current_tier = None + next_tier = None + next_thresh = None + prev_thresh = 0 + + for tier, schwelle, badge_name in cat["stufen"]: + bid = f"{cat['id']}_{tier}" + if val >= schwelle: + current_tier = {"tier": tier, "badge_id": bid, + "name": badge_name, **TIERS[tier]} + prev_thresh = schwelle + else: + if next_tier is None: + next_tier = {"tier": tier, "badge_id": bid, + "name": badge_name, "schwelle": schwelle, + **TIERS[tier]} + next_thresh = schwelle + break + + # Fortschritt zur nächsten Stufe in % + if next_thresh: + progress = round(min((val - prev_thresh) / (next_thresh - prev_thresh) * 100, 99)) + else: + progress = 100 + + badge_categories.append({ + "id": cat["id"], + "name": cat["name"], + "emoji": cat["emoji"], + "einheit": cat["einheit"], + "current_value": val, + "current_tier": current_tier, + "next_tier": next_tier, + "progress": progress, + "alle_stufen": [ + { + "tier": tier, "schwelle": schwelle, "name": badge_name, + "earned": f"{cat['id']}_{tier}" in earned_ids, + **TIERS[tier], + } + for tier, schwelle, badge_name in cat["stufen"] + ], + }) + + return { + "stats": dict(stats) if stats else {}, + "streak": {"current": streak_row["current_streak"] if streak_row else 0, + "max": streak_row["max_streak"] if streak_row else 0}, + "rang": rank_row["rang"] if rank_row else 1, + "categories": badge_categories, + "new_badges": new_badges, + } diff --git a/backend/routes/admin.py b/backend/routes/admin.py index 7f5b3c6..b6070b1 100644 --- a/backend/routes/admin.py +++ b/backend/routes/admin.py @@ -171,7 +171,11 @@ async def list_users( u.is_moderator, u.is_banned, u.ban_reason, u.created_at, u.last_login, (SELECT COUNT(*) FROM dogs d WHERE d.user_id=u.id) AS dog_count, - (SELECT COUNT(*) FROM forum_threads t WHERE t.user_id=u.id AND t.is_deleted=0) AS thread_count + (SELECT COUNT(*) FROM forum_threads t WHERE t.user_id=u.id AND t.is_deleted=0) AS thread_count, + ROUND(COALESCE((SELECT SUM(r.distanz_km) FROM routes r WHERE r.user_id=u.id), 0), 1) AS total_km, + (SELECT COUNT(*) FROM routes r WHERE r.user_id=u.id) AS route_count, + (SELECT COUNT(*) FROM user_map_pois p WHERE p.user_id=u.id) AS poi_count, + (SELECT MAX(r.created_at) FROM routes r WHERE r.user_id=u.id) AS last_route FROM users u {where} ORDER BY u.created_at DESC diff --git a/backend/routes/auth.py b/backend/routes/auth.py index 46169a5..a32beda 100644 --- a/backend/routes/auth.py +++ b/backend/routes/auth.py @@ -116,14 +116,20 @@ async def logout(response: Response): async def get_referral_info(user=Depends(get_current_user)): with db() as conn: row = conn.execute( - "SELECT referral_code, (SELECT COUNT(*) FROM users WHERE referred_by=?) AS count FROM users WHERE id=?", + """SELECT referral_code, + COALESCE((SELECT COUNT(*) FROM users WHERE referred_by=?), 0) AS count + FROM users WHERE id=?""", (user['id'], user['id']) ).fetchone() + code = row["referral_code"] if row else None + if not code: + code = _gen_referral_code() + conn.execute("UPDATE users SET referral_code=? WHERE id=?", (code, user['id'])) base = os.getenv("APP_URL", "https://banyaro.app") return { - "code": row["referral_code"], - "count": row["count"], - "link": f"{base}/?ref={row['referral_code']}", + "code": code, + "count": row["count"] if row else 0, + "link": f"{base}/?ref={code}", } diff --git a/backend/routes/diary.py b/backend/routes/diary.py index d10eb34..ef16586 100644 --- a/backend/routes/diary.py +++ b/backend/routes/diary.py @@ -449,6 +449,8 @@ async def delete_diary(dog_id: int, entry_id: int, user=Depends(get_current_user def _guess_media_type(content_type: str, filename: str) -> str: ct = (content_type or "").lower() + if ct == "application/pdf" or (filename or "").lower().endswith(".pdf"): + return "pdf" if ct.startswith("video/"): return "video" ext = os.path.splitext(filename or "")[1].lower() @@ -475,13 +477,14 @@ async def upload_media(dog_id: int, entry_id: int, ALLOWED = { "image/jpeg", "image/png", "image/gif", "image/webp", "image/heic", "image/heif", "video/mp4", "video/quicktime", "video/webm", "video/x-m4v", + "application/pdf", } ct = file.content_type or "" if ct not in ALLOWED: ext_low = os.path.splitext(file.filename or "")[1].lower() if ext_low not in {".jpg",".jpeg",".png",".gif",".webp",".heic",".heif", - ".mp4",".mov",".webm",".m4v"}: - raise HTTPException(415, "Nur Bilder und Videos erlaubt.") + ".mp4",".mov",".webm",".m4v",".pdf"}: + raise HTTPException(415, "Nur Bilder, Videos und PDFs erlaubt.") ext = os.path.splitext(file.filename or "")[1] or ".jpg" filename = f"diary_{entry_id}_{uuid.uuid4().hex[:8]}{ext}" diff --git a/backend/routes/forum.py b/backend/routes/forum.py index 304722c..1e4fa7e 100644 --- a/backend/routes/forum.py +++ b/backend/routes/forum.py @@ -68,9 +68,14 @@ class ResolveReport(BaseModel): # ------------------------------------------------------------------ # Helpers # ------------------------------------------------------------------ +_FORUM_ALLOWED_EXT = {".jpg",".jpeg",".png",".gif",".webp",".heic",".heif", + ".mp4",".mov",".webm",".m4v",".pdf",".avi"} + def _save_upload(file: UploadFile, data: bytes) -> str: os.makedirs(FORUM_DIR, exist_ok=True) - ext = os.path.splitext(file.filename or "")[1] or ".jpg" + ext = os.path.splitext(file.filename or "")[1].lower() or ".jpg" + if ext not in _FORUM_ALLOWED_EXT: + raise HTTPException(415, "Dateityp nicht erlaubt.") filename = f"{uuid.uuid4().hex}{ext}" path = os.path.join(FORUM_DIR, filename) with open(path, "wb") as f: @@ -573,7 +578,7 @@ async def members_map(): AND forum_lat IS NOT NULL AND forum_lon IS NOT NULL""" ).fetchall() - return [{'vorname': r['vorname'] or '?', 'lat': round(r['lat'], 2), 'lon': round(r['lon'], 2)} + return [{'vorname': r['vorname'] or '?', 'lat': round(r['lat'], 3), 'lon': round(r['lon'], 3)} for r in rows] @@ -583,16 +588,13 @@ async def members_map(): @router.patch("/members/location") async def set_member_location(data: LocationBody, user=Depends(get_current_user)): if data.show and data.lat is not None and data.lon is not None: - # Snap to ~1km grid (2 decimal places ≈ 1.1km) - snapped_lat = round(data.lat, 2) - snapped_lon = round(data.lon, 2) with db() as conn: conn.execute( """UPDATE users SET forum_lat=?, forum_lon=?, forum_show_location=1 WHERE id=?""", - (snapped_lat, snapped_lon, user['id']) + (round(data.lat, 4), round(data.lon, 4), user['id']) ) - return {"ok": True, "lat": snapped_lat, "lon": snapped_lon} + return {"ok": True, "lat": data.lat, "lon": data.lon} else: with db() as conn: conn.execute( diff --git a/backend/routes/routen.py b/backend/routes/routen.py index aaf44a3..77ba12d 100644 --- a/backend/routes/routen.py +++ b/backend/routes/routen.py @@ -1,11 +1,14 @@ """BAN YARO — Gassi-Routen""" import json, math, os, uuid +import httpx 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, get_current_user_optional +from routes.achievements import update_streak, check_and_award +from routes.push import send_push_to_user router = APIRouter() @@ -26,6 +29,7 @@ def _haversine(lat1: float, lon1: float, lat2: float, lon2: float) -> float: class GPSPoint(BaseModel): lat: float lon: float + alt: Optional[float] = None class RouteCreate(BaseModel): name: str @@ -147,6 +151,8 @@ async def create_route(data: RouteCreate, user=Depends(get_current_user)): data.hunde_tauglichkeit, )) row = conn.execute("SELECT * FROM routes WHERE id = ?", (cur.lastrowid,)).fetchone() + update_streak(user['id'], conn) + check_and_award(user['id'], conn) return _parse(row) @@ -189,6 +195,59 @@ async def update_route(route_id: int, data: RouteUpdate, user=Depends(get_curren return _parse(row) +# ------------------------------------------------------------------ +# PATCH /api/routes/{id}/trim — Route kürzen (Datenschutz) +# ------------------------------------------------------------------ +class RouteTrim(BaseModel): + gps_track: List[GPSPoint] + +@router.patch("/{route_id}/trim") +async def trim_route(route_id: int, data: RouteTrim, user=Depends(get_current_user)): + if len(data.gps_track) < 2: + raise HTTPException(400, "Mindestens 2 GPS-Punkte erforderlich.") + + 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.") + + # Original-Werte beim ersten Kürzen einmalig sichern + if row['original_km'] is None: + conn.execute( + "UPDATE routes SET original_km=?, original_dauer_min=? WHERE id=?", + (row['distanz_km'], row['dauer_min'], route_id) + ) + orig_km = row['distanz_km'] or 0 + orig_min = row['dauer_min'] or 0 + else: + orig_km = row['original_km'] + orig_min = row['original_dauer_min'] or 0 + + # Neue Distanz berechnen + new_track = [p.model_dump() for p in data.gps_track] + new_km = 0.0 + for i in range(1, len(new_track)): + p1, p2 = new_track[i-1], new_track[i] + dlat = math.radians(p2['lat'] - p1['lat']) + dlon = math.radians(p2['lon'] - p1['lon']) + a = math.sin(dlat/2)**2 + math.cos(math.radians(p1['lat'])) * math.cos(math.radians(p2['lat'])) * math.sin(dlon/2)**2 + new_km += 6371 * 2 * math.atan2(math.sqrt(a), math.sqrt(1-a)) + new_km = round(new_km, 2) + + # Dauer proportional schätzen (Original-Pace) + pace = orig_min / orig_km if orig_km > 0 else 10 + new_min = max(1, round(new_km * pace)) + + conn.execute( + "UPDATE routes SET gps_track=?, distanz_km=?, dauer_min=? WHERE id=?", + (json.dumps(new_track), new_km, new_min, route_id) + ) + row = conn.execute("SELECT * FROM routes WHERE id=?", (route_id,)).fetchone() + return _parse(row) + + # ------------------------------------------------------------------ # DELETE /api/routes/{id} # ------------------------------------------------------------------ @@ -227,6 +286,46 @@ async def rate_route(route_id: int, data: RouteRate, user=Depends(get_current_us return {'bewertung': round(avg, 2), 'anz_bewertungen': n} +# ------------------------------------------------------------------ +# POST /api/routes/{id}/walked — Gelaufene km ins Profil eintragen +# ------------------------------------------------------------------ +class WalkRecord(BaseModel): + walked_km: float + progress_pct: int + +@router.post("/{route_id}/walked", status_code=201) +async def record_walk(route_id: int, body: WalkRecord, user=Depends(get_current_user)): + if body.progress_pct < 50: + raise HTTPException(400, "Mindestens 50 % der Route müssen absolviert sein.") + uid = user["id"] + with db() as conn: + conn.execute( + "INSERT INTO route_walks (user_id, route_id, walked_km) VALUES (?,?,?)", + (uid, route_id, round(max(0.01, body.walked_km), 2)) + ) + update_streak(uid, conn) + new_badges = check_and_award(uid, conn) + return {"ok": True, "new_badges": new_badges} + + +# ------------------------------------------------------------------ +# POST /api/routes/{id}/reverse — GPS-Track umkehren +# ------------------------------------------------------------------ +@router.post("/{route_id}/reverse", status_code=200) +async def reverse_route(route_id: int, user=Depends(get_current_user)): + uid = user["id"] + with db() as conn: + row = conn.execute("SELECT user_id, gps_track FROM routes WHERE id=?", (route_id,)).fetchone() + if not row: + raise HTTPException(404, "Route nicht gefunden.") + if row["user_id"] != uid: + raise HTTPException(403, "Nur der Ersteller kann die Route umkehren.") + track = json.loads(row["gps_track"]) + track.reverse() + conn.execute("UPDATE routes SET gps_track=? WHERE id=?", (json.dumps(track), route_id)) + return {"ok": True} + + # ------------------------------------------------------------------ # POST /api/routes/{id}/photo — Foto hochladen # ------------------------------------------------------------------ @@ -259,3 +358,70 @@ async def add_route_photo( 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} + + +# ------------------------------------------------------------------ +# POST /api/routes/{id}/feedback — Feedback an Route-Ersteller +# ------------------------------------------------------------------ +class RouteFeedback(BaseModel): + text: str + +@router.post("/{route_id}/feedback", status_code=201) +async def route_feedback(route_id: int, data: RouteFeedback, user=Depends(get_current_user)): + if len(data.text.strip()) < 5: + raise HTTPException(400, "Feedback zu kurz.") + with db() as conn: + row = conn.execute( + "SELECT user_id, name FROM routes WHERE id=?", (route_id,) + ).fetchone() + if not row: + raise HTTPException(404, "Route nicht gefunden.") + if row["user_id"] == user["id"]: + raise HTTPException(400, "Eigene Route kann nicht bewertet werden.") + + send_push_to_user(row["user_id"], { + "type": "route_feedback", + "title": "📍 Feedback zu \u201e" + row['name'] + "\u201c", + "body": data.text.strip()[:120], + "route_id": route_id, + }) + return {"ok": True} + + +# ------------------------------------------------------------------ +# GET /api/routes/{id}/elevation — Höhenprofil via OpenTopoData +# ------------------------------------------------------------------ +@router.get("/{route_id}/elevation") +async def route_elevation(route_id: int, _user=Depends(get_current_user_optional)): + with db() as conn: + row = conn.execute("SELECT gps_track FROM routes WHERE id=?", (route_id,)).fetchone() + if not row: + raise HTTPException(404) + + track = json.loads(row["gps_track"] or "[]") + if not track: + return {"elevations": []} + + # Bereits mit Höhe gespeichert? + if all(p.get("alt") is not None for p in track): + return {"elevations": [{"lat": p["lat"], "lon": p["lon"], "alt": p["alt"]} for p in track]} + + # Auf max. 60 Punkte reduzieren + step = max(1, len(track) // 60) + sample = track[::step] + if track[-1] not in sample: + sample.append(track[-1]) + + locations = "|".join(f"{p['lat']},{p['lon']}" for p in sample) + try: + async with httpx.AsyncClient(timeout=8) as client: + r = await client.get( + f"https://api.opentopodata.org/v1/srtm90m?locations={locations}" + ) + results = r.json().get("results", []) + return {"elevations": [ + {"lat": res["location"]["lat"], "lon": res["location"]["lng"], "alt": res.get("elevation", 0)} + for res in results + ]} + except Exception: + return {"elevations": []} diff --git a/backend/routes/stats.py b/backend/routes/stats.py new file mode 100644 index 0000000..1cb8835 --- /dev/null +++ b/backend/routes/stats.py @@ -0,0 +1,44 @@ +from fastapi import APIRouter, Depends +from database import db +from auth import get_current_user, get_current_user_optional + +router = APIRouter() + +_STATS_SQL = """ + SELECT u.id, u.name, u.avatar_url, + ROUND(COALESCE(SUM(r.distanz_km), 0), 1) AS total_km, + COUNT(DISTINCT r.id) AS routen, + COUNT(DISTINCT p.id) AS pois, + ROUND(COALESCE(SUM(r.distanz_km), 0), 1) * 1 + + COUNT(DISTINCT p.id) * 5 + + COUNT(DISTINCT r.id) * 10 AS punkte + FROM users u + LEFT JOIN routes r ON r.user_id = u.id AND r.is_public = 1 + LEFT JOIN user_map_pois p ON p.user_id = u.id + GROUP BY u.id +""" + + +@router.get("/leaderboard") +async def leaderboard(_user=Depends(get_current_user_optional)): + with db() as conn: + rows = conn.execute(f""" + SELECT * FROM ({_STATS_SQL}) + ORDER BY punkte DESC, total_km DESC + LIMIT 20 + """).fetchall() + return [dict(r) for r in rows] + + +@router.get("/me") +async def my_stats(user=Depends(get_current_user)): + with db() as conn: + row = conn.execute(f""" + SELECT s.*, rank_tbl.rang FROM ({_STATS_SQL}) s + JOIN ( + SELECT id, ROW_NUMBER() OVER (ORDER BY punkte DESC, total_km DESC) AS rang + FROM ({_STATS_SQL}) + ) rank_tbl ON rank_tbl.id = s.id + WHERE s.id = ? + """, (user["id"],)).fetchone() + return dict(row) if row else {} diff --git a/backend/routes/training.py b/backend/routes/training.py new file mode 100644 index 0000000..91163b1 --- /dev/null +++ b/backend/routes/training.py @@ -0,0 +1,156 @@ +"""BAN YARO — Übungs- & Trainingsfortschritt""" + +from fastapi import APIRouter, Depends +from pydantic import BaseModel +from typing import Optional +from database import db +from auth import get_current_user + +router = APIRouter() + +# ------------------------------------------------------------------ +# Übungs-Status +# ------------------------------------------------------------------ +class ProgressUpdate(BaseModel): + exercise_id: str + status: Optional[str] = None # null/noch-nicht/manchmal/meistens/sitzt + +@router.get("/progress") +async def get_progress(user=Depends(get_current_user)): + uid = user["id"] + with db() as conn: + rows = conn.execute( + "SELECT exercise_id, status, updated_at FROM exercise_progress WHERE user_id=?", + (uid,) + ).fetchall() + return [dict(r) for r in rows] + +@router.post("/progress") +async def upsert_progress(body: ProgressUpdate, user=Depends(get_current_user)): + uid = user["id"] + with db() as conn: + conn.execute(""" + INSERT INTO exercise_progress (user_id, exercise_id, status) + VALUES (?,?,?) + ON CONFLICT(user_id, exercise_id) DO UPDATE + SET status=excluded.status, updated_at=datetime('now') + """, (uid, body.exercise_id, body.status)) + return {"ok": True} + +# ------------------------------------------------------------------ +# Trainingsplan-Checkboxen +# ------------------------------------------------------------------ +class PlanProgress(BaseModel): + item_key: str + checked: bool + +@router.get("/plan-progress") +async def get_plan_progress(user=Depends(get_current_user)): + uid = user["id"] + with db() as conn: + rows = conn.execute( + "SELECT item_key, checked FROM training_plan_progress WHERE user_id=?", + (uid,) + ).fetchall() + return [dict(r) for r in rows] + +@router.post("/plan-progress") +async def upsert_plan_progress(body: PlanProgress, user=Depends(get_current_user)): + uid = user["id"] + with db() as conn: + if body.checked: + conn.execute(""" + INSERT OR REPLACE INTO training_plan_progress (user_id, item_key, checked) + VALUES (?,?,1) + """, (uid, body.item_key)) + else: + conn.execute( + "DELETE FROM training_plan_progress WHERE user_id=? AND item_key=?", + (uid, body.item_key) + ) + return {"ok": True} + +# ------------------------------------------------------------------ +# Empfehlungen (rule-based) +# ------------------------------------------------------------------ +GRUNDKOMMANDOS_ORDER = ['Sitz', 'Platz', 'Bleib', 'Hier / Komm', 'Fuß', 'Aus / Lass es', 'Warte'] +TRICKS_FIRST = ['Pfote / Schütteln', 'Dreh', 'Auf die Decke', 'Nasenarbeit / Suchen'] + +@router.get("/suggestions") +async def get_suggestions(user=Depends(get_current_user)): + uid = user["id"] + with db() as conn: + rows = conn.execute( + "SELECT exercise_id, status FROM exercise_progress WHERE user_id=?", + (uid,) + ).fetchall() + + progress = {r["exercise_id"]: r["status"] for r in rows} + + def key(name): return f"grundkommandos_{name.replace(' ', '_').replace('/', '')}" + def tkey(name): return f"tricks_{name.replace(' ', '_').replace('/', '')}" + + suggestions = [] + + # Noch-nicht Übungen — direkte Hilfe + stuck = [n for n in GRUNDKOMMANDOS_ORDER if progress.get(key(n)) == 'noch-nicht'] + if stuck: + suggestions.append({ + "type": "help", + "icon": "warning", + "title": f'\u201e{stuck[0]}\u201c klappt noch nicht', + "text": "Mach einen Schritt zurück: Kürzere Einheiten, mehr Leckerlis, weniger Ablenkung. Schau dir die Trainingsgrundlagen an.", + "action_tab": "grundkommandos", + "action_name": stuck[0], + }) + + # Manchmal-Übungen — intensivieren + almost = [n for n in GRUNDKOMMANDOS_ORDER if progress.get(key(n)) == 'manchmal'] + if almost: + suggestions.append({ + "type": "boost", + "icon": "fire", + "title": f'Fast da: \u201e{almost[0]}\u201c', + "text": "Du bist auf dem richtigen Weg! Übe täglich 3–5 Minuten — dann sitzt es bald.", + "action_tab": "grundkommandos", + "action_name": almost[0], + }) + + # Nächste ungestartete Grundkommando + grundk_mastered = [n for n in GRUNDKOMMANDOS_ORDER if progress.get(key(n)) == 'sitzt'] + next_up = next((n for n in GRUNDKOMMANDOS_ORDER + if progress.get(key(n)) in (None, 'noch-nicht') and n not in stuck), None) + + if len(grundk_mastered) == len(GRUNDKOMMANDOS_ORDER): + # Alle Grundkommandos gemeistert → Tricks empfehlen + next_trick = next((n for n in TRICKS_FIRST if progress.get(tkey(n)) is None), None) + if next_trick: + suggestions.append({ + "type": "next", + "icon": "star", + "title": "Alle Grundkommandos sitzen! 🎉", + "text": f'Zeit für Tricks! Starte mit \u201e{next_trick}\u201c \u2014 Spaß garantiert.', + "action_tab": "tricks", + "action_name": next_trick, + }) + elif next_up and not almost: + suggestions.append({ + "type": "next", + "icon": "arrow-right", + "title": f"Bereit für den nächsten Schritt?", + "text": f'Starte jetzt mit \u201e{next_up}\u201c. {"Du hast bereits " + str(len(grundk_mastered)) + " Grundkommandos gemeistert!" if grundk_mastered else "Es ist die perfekte Basis für alles Weitere."}', + "action_tab": "grundkommandos", + "action_name": next_up, + }) + elif not progress: + # Noch gar kein Fortschritt + suggestions.append({ + "type": "start", + "icon": "flag", + "title": "Womit fangen wir an?", + "text": "\u201eSitz\u201c ist das erste Kommando für jeden Hund \u2014 einfach, schnell erlernt und die Basis für alles.", + "action_tab": "grundkommandos", + "action_name": "Sitz", + }) + + return suggestions[:2] # max 2 Empfehlungen diff --git a/backend/static/css/components.css b/backend/static/css/components.css index 769bb89..6a20924 100644 --- a/backend/static/css/components.css +++ b/backend/static/css/components.css @@ -1994,6 +1994,8 @@ html.modal-open { border-bottom: 1px solid var(--c-border-light); padding: var(--space-3) var(--space-4); flex-shrink: 0; + position: relative; + z-index: 10; } .rk-search-row { display: flex; @@ -3824,6 +3826,22 @@ html.modal-open { } .forum-foto-img:hover { opacity: 0.85; } +.forum-pdf-card { + display: inline-flex; + align-items: center; + gap: var(--space-2); + padding: var(--space-2) var(--space-3); + border-radius: var(--radius-md); + background: var(--c-surface-2); + border: 1px solid var(--c-border); + color: var(--c-text); + text-decoration: none; + font-size: var(--text-sm); + transition: background var(--transition-fast); +} +.forum-pdf-card:hover { background: var(--c-surface); } +.forum-pdf-card .ph-icon { color: var(--c-danger); flex-shrink: 0; } + /* Upload */ .forum-upload-area { display: flex; gap: var(--space-2); align-items: center; } .forum-upload-previews { @@ -4984,6 +5002,7 @@ html.modal-open { } .chat-conv-item:hover, .chat-conv-item:active { background: var(--c-surface-2); } +.chat-conv-item.active { background: var(--c-primary-subtle); border-left: 3px solid var(--c-primary); } .chat-conv-avatar { width: 44px; @@ -5055,10 +5074,12 @@ html.modal-open { display: flex; align-items: center; gap: var(--space-3); - padding: var(--space-3) var(--space-4); + padding: 0 var(--space-4); + height: 56px; background: var(--c-surface); border-bottom: 1px solid var(--c-border); flex-shrink: 0; + box-sizing: border-box; } .chat-thread-partner { diff --git a/backend/static/css/layout.css b/backend/static/css/layout.css index 92f0893..8d6e8ec 100644 --- a/backend/static/css/layout.css +++ b/backend/static/css/layout.css @@ -132,16 +132,19 @@ /* Gassi-Treffen + Sitting: volle Höhe, internes Scroll */ #page-walks, -#page-sitting { +#page-sitting, +#page-chat { height: 100%; overflow: hidden; } #page-walks > .page-body, -#page-sitting > .page-body { +#page-sitting > .page-body, +#page-chat > .page-body { padding: 0 !important; gap: 0 !important; overflow: hidden; height: 100%; + position: relative; } /* Routen: volle Höhe damit .rk-layout height:100% auflöst und @@ -320,7 +323,7 @@ } .sidebar-logo { - padding: var(--space-6) var(--space-5); + padding: calc(var(--space-6) + var(--safe-top)) var(--space-5) var(--space-6); display: flex; align-items: center; gap: var(--space-3); diff --git a/backend/static/icons/phosphor.svg b/backend/static/icons/phosphor.svg index 36ad812..7512a15 100644 --- a/backend/static/icons/phosphor.svg +++ b/backend/static/icons/phosphor.svg @@ -1,5 +1,6 @@ + diff --git a/backend/static/index.html b/backend/static/index.html index aceea61..5468512 100644 --- a/backend/static/index.html +++ b/backend/static/index.html @@ -15,7 +15,7 @@ - + Ban Yaro @@ -159,6 +159,16 @@ Ban Yaro
+ diff --git a/backend/static/js/api.js b/backend/static/js/api.js index 9a362a5..e55833c 100644 --- a/backend/static/js/api.js +++ b/backend/static/js/api.js @@ -225,8 +225,13 @@ const API = (() => { 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}`); }, - rate(id, wertung) { return post(`/routes/${id}/rate`, { wertung }); }, + trim(id, gps_track) { return patch(`/routes/${id}/trim`, { gps_track }); }, + feedback(id, text) { return post(`/routes/${id}/feedback`, { text }); }, + elevation(id) { return get(`/routes/${id}/elevation`); }, + delete(id) { return del(`/routes/${id}`); }, + rate(id, wertung) { return post(`/routes/${id}/rate`, { wertung }); }, + walked(id, walked_km, progress_pct) { return post(`/routes/${id}/walked`, { walked_km, progress_pct }); }, + reverse(id) { return post(`/routes/${id}/reverse`, {}); }, addPhoto(id, file) { const fd = new FormData(); fd.append('file', file); @@ -236,6 +241,17 @@ const API = (() => { }, }; + // ---------------------------------------------------------- + // TRAINING & ÜBUNGSFORTSCHRITT + // ---------------------------------------------------------- + const training = { + getProgress() { return get('/training/progress'); }, + setProgress(id, status) { return post('/training/progress', { exercise_id: id, status }); }, + getSuggestions() { return get('/training/suggestions'); }, + getPlanProgress() { return get('/training/plan-progress'); }, + setPlanProgress(key, checked) { return post('/training/plan-progress', { item_key: key, checked }); }, + }; + // ---------------------------------------------------------- // GASSI-TREFFEN // ---------------------------------------------------------- @@ -556,7 +572,7 @@ const API = (() => { get, post, put, patch, del, upload, auth, dogs, diary, health, tieraerzte, poison, places, routes, walks, events, sitting, forum, lost, knigge, weather, push, - friends, chat, webcal, importData, sharing, widget, notifications, services, ratings, sittingAccess, + friends, chat, webcal, importData, sharing, widget, notifications, services, ratings, sittingAccess, training, subscribeToPush, getLocation, APIError, }; diff --git a/backend/static/js/app.js b/backend/static/js/app.js index cd642ba..9a1de2b 100644 --- a/backend/static/js/app.js +++ b/backend/static/js/app.js @@ -3,7 +3,7 @@ Router, State-Management, Navigation, Initialisierung. ============================================================ */ -const APP_VER = '237'; // ← bei jedem Deploy mit Frontend-Änderungen erhöhen +const APP_VER = '261'; // ← bei jedem Deploy mit Frontend-Änderungen erhöhen const App = (() => { @@ -268,6 +268,12 @@ const App = (() => { return; } + // Header-User-Button → Settings + if (e.target.closest('#header-user-btn')) { + navigate('settings'); + return; + } + // Sidebar-Logo → Willkommensseite (oder Hund-Profil wenn Hund aktiv) // Hinweis: dog-sw-title hat eigenen Listener; dieser Fallback greift nur // wenn kein Hund aktiv ist (statischer "Ban Yaro"-Text ohne dog-sw-title). @@ -410,6 +416,8 @@ const App = (() => { async function _onLoggedIn() { document.getElementById('sidebar-username').textContent = state.user.name; + document.getElementById('header-login-btn')?.remove(); + _updateHeaderUserBtn(true); // Admin/Moderator-Item einblenden const adminItem = document.getElementById('sidebar-admin'); if (adminItem) { @@ -486,6 +494,8 @@ const App = (() => { _renderDogSwitcher(); + _updateHeaderUserBtn(false); + // Wenn aktuelle Seite geschützt ist → zu freier Seite wechseln if (pages[state.page]?.requiresAuth) { navigate('map', false); @@ -495,6 +505,30 @@ const App = (() => { } } + function _updateHeaderUserBtn(loggedIn) { + const btn = document.getElementById('header-user-btn'); + const icon = document.getElementById('header-user-icon'); + if (!btn) return; + if (loggedIn) { + const av = state.user?.avatar_url; + if (av) { + btn.innerHTML = ``; + } else { + btn.innerHTML = ``; + } + btn.style.borderColor = 'var(--c-primary)'; + btn.title = 'Mein Profil'; + } else { + btn.innerHTML = ` +
`; + btn.style.borderColor = 'var(--c-border)'; + btn.title = 'Anmelden'; + } + } + async function _loadDogs() { try { state.dogs = await API.dogs.list(); diff --git a/backend/static/js/pages/admin.js b/backend/static/js/pages/admin.js index df55f65..c2ff528 100644 --- a/backend/static/js/pages/admin.js +++ b/backend/static/js/pages/admin.js @@ -225,6 +225,11 @@ window.Page_admin = (() => { · ${u.dog_count} Hund${u.dog_count !== 1 ? 'e' : ''} · ${u.thread_count} Threads +
+ 🗺 ${u.route_count} Routen · ${u.total_km} km + · 📍 ${u.poi_count} POIs + ${u.last_route ? '· zuletzt ' + new Date(u.last_route).toLocaleDateString('de-DE') : ''} +
diff --git a/backend/static/js/pages/chat.js b/backend/static/js/pages/chat.js index 51c3a42..acd9c64 100644 --- a/backend/static/js/pages/chat.js +++ b/backend/static/js/pages/chat.js @@ -34,25 +34,68 @@ window.Page_chat = (() => { // ---------------------------------------------------------- // Conversation list // ---------------------------------------------------------- + const _isDesktop = () => window.innerWidth >= 768; + + function _listPaneHTML() { + return ` +
+

Nachrichten

+ +
+
`; + } + async function _showList() { _view = 'list'; _stopPolling(); _convId = null; - _container.innerHTML = ` -
-
-

Nachrichten

- -
-
-
- `; + if (_isDesktop()) { + // Split-Pane: linke Spalte bleibt, rechte zeigt Placeholder + if (!document.getElementById('chat-split')) { + _container.innerHTML = ` +
+
+ ${_listPaneHTML()} +
+
+ ${UI.icon('chat-circle-dots')} Gespräch auswählen +
+
`; + document.getElementById('chat-new-btn')?.addEventListener('click', _showNewMessagePicker); + } else { + // Split existiert — nur rechte Seite zurücksetzen + const pane = document.getElementById('chat-thread-pane'); + if (pane) { + pane.style.cssText = 'flex:1;display:flex;align-items:center;justify-content:center;background:var(--c-bg);color:var(--c-text-muted);font-size:var(--text-sm)'; + pane.innerHTML = `${UI.icon('chat-circle-dots')} Gespräch auswählen`; + } + } + } else { + _container.innerHTML = ` +
+
+

Nachrichten

+ +
+
+
`; + document.getElementById('chat-new-btn')?.addEventListener('click', _showNewMessagePicker); + } - document.getElementById('chat-new-btn')?.addEventListener('click', _showNewMessagePicker); await _loadList(); await _updateChatBadge(); } @@ -122,12 +165,18 @@ window.Page_chat = (() => { _view = 'thread'; _stopPolling(); - _container.innerHTML = ` + // Aktive Markierung in der Liste + document.querySelectorAll('.chat-conv-item').forEach(el => + el.classList.toggle('active', el.getAttribute('onclick')?.includes(String(convId))) + ); + + const threadHTML = `
+ ${_isDesktop() ? '' : ` + `}
?
@@ -154,6 +203,14 @@ window.Page_chat = (() => {
`; + const threadPane = document.getElementById('chat-thread-pane'); + if (_isDesktop() && threadPane) { + threadPane.style.cssText = 'flex:1;min-width:0;display:flex;flex-direction:column'; + threadPane.innerHTML = threadHTML; + } else { + _container.innerHTML = threadHTML; + } + // Auto-resize textarea const input = document.getElementById('chat-input'); input.addEventListener('input', () => { diff --git a/backend/static/js/pages/diary.js b/backend/static/js/pages/diary.js index 5209648..349a251 100644 --- a/backend/static/js/pages/diary.js +++ b/backend/static/js/pages/diary.js @@ -371,17 +371,53 @@ window.Page_diary = (() => { // ---------------------------------------------------------- // LIGHTBOX // ---------------------------------------------------------- - function _showLightbox(src) { + // ---------------------------------------------------------- + // LIGHTBOX — Fotos mit Vor/Zurück-Navigation + // ---------------------------------------------------------- + function _showLightbox(urls, startIdx = 0) { + const photos = Array.isArray(urls) ? urls : [urls]; + let idx = startIdx; + const lb = document.createElement('div'); - lb.style.cssText = 'position:fixed;inset:0;background:rgba(0,0,0,.92);z-index:9999;display:flex;align-items:center;justify-content:center;cursor:zoom-out'; - lb.innerHTML = ` - `; - lb.addEventListener('click', () => lb.remove()); + lb.id = 'diary-lightbox'; + lb.style.cssText = 'position:fixed;inset:0;z-index:1100;background:#000;display:flex;flex-direction:column'; + + const render = () => { + lb.innerHTML = ` +
+ + ${photos.length > 1 + ? `${idx+1} / ${photos.length}` + : ''} +
+
+
+ + ${photos.length > 1 ? ` + + + ` : ''} +
+ `; + lb.querySelector('#lb-close').addEventListener('click', () => lb.remove()); + lb.querySelector('#lb-prev')?.addEventListener('click', () => { if (idx > 0) { idx--; render(); } }); + lb.querySelector('#lb-next')?.addEventListener('click', () => { if (idx < photos.length-1) { idx++; render(); } }); + }; + render(); document.body.appendChild(lb); } // ---------------------------------------------------------- - // DETAIL-ANSICHT + // DETAIL-ANSICHT — Fullscreen (DayOne-Stil) // ---------------------------------------------------------- function _openDetail(entryId) { const entry = _entries.find(e => e.id === entryId); @@ -390,113 +426,110 @@ window.Page_diary = (() => { const typ = TYPEN[entry.typ] || TYPEN.eintrag; const isMile = entry.is_milestone || entry.typ === 'meilenstein'; const tags = (entry.tags || []).filter(t => t && t.trim()); - const allMedia = _allMedia(entry); - const photo = allMedia.length > 0 - ? (allMedia.length === 1 - ? `
- ${_mediaHtml(allMedia[0].url)} -
` - : ``) - : ''; - - // Hunde-Anzeige wenn mehrere beteiligt const dogIds = entry.dog_ids || [entry.dog_id]; + const dogsHtml = dogIds.length > 1 - ? `
+ ? `
${dogIds.map(did => { const dog = _appState.dogs.find(d => d.id === did); return dog ? `
${dog.foto_url ? `` : `${UI.icon('dog')}`} -
- ${UI.escape(dog.name)} -
` : ''; +
${UI.escape(dog.name)}
` : ''; }).join('')} -
` - : ''; +
` : ''; - const body = ` - ${isMile ? `
${UI.icon('trophy')} Meilenstein
` : ''} -
- ${typ.icon} ${typ.label} - - ${entry.datum ? UI.time.format(entry.datum + 'T00:00:00') : ''} - + const view = document.createElement('div'); + view.id = 'diary-detail-view'; + view.style.cssText = 'position:fixed;inset:0;z-index:800;background:var(--c-bg);overflow-y:auto;display:flex;flex-direction:column'; + + // Medien-HTML für Hero-Bereich + const _heroHtml = (m) => m.media_type === 'pdf' + ? ` + + ${UI.escape(m.url.split('/').pop())} + PDF öffnen + ` + : m.media_type === 'video' + ? `` + : ``; + + let mediaSection = ''; + if (allMedia.length === 1) { + mediaSection = `
${_heroHtml(allMedia[0])}
`; + } else if (allMedia.length > 1) { + mediaSection = ` +
${_heroHtml(allMedia[0])}
+
+ ${allMedia.map((m, i) => ` +
+ ${m.media_type === 'pdf' + ? `
` + : m.media_type === 'video' + ? `` + : ``} +
`).join('')} +
`; + } + + view.innerHTML = ` +
+ + ${!_appState?.activeDog?.is_guest + ? `` + : '
'} +
+ + ${mediaSection} + +
+ ${isMile ? `
${UI.icon('trophy')} Meilenstein
` : ''} + ${entry.titel ? `

${UI.escape(entry.titel)}

` : ''} +
+ ${typ.icon} ${typ.label} + + ${entry.datum ? UI.time.format(entry.datum + 'T00:00:00') : ''} + +
+ ${entry.location_name ? ` +
+ + ${entry.gps_lat + ? `${UI.escape(entry.location_name)}` + : UI.escape(entry.location_name)} +
` : ''} + ${dogsHtml} + ${entry.text + ? `

${UI.escape(_cleanText(entry.text))}

` + : ''} + ${tags.length + ? `
+ ${tags.map(t => `${t}`).join('')} +
` + : ''}
- ${entry.location_name ? ` -
- - ${entry.gps_lat ? `${UI.escape(entry.location_name)}` : UI.escape(entry.location_name)} -
` : ''} - ${entry.text - ? `

${UI.escape(_cleanText(entry.text))}

` - : ''} - ${tags.length - ? `
- ${tags.map(t => `${t}`).join('')} -
` - : ''} - ${dogsHtml} - ${photo} - ${!_appState?.activeDog?.is_guest ? `` : ''} `; - UI.modal.open({ title: entry.titel || typ.label, body }); + document.body.appendChild(view); - // Bilder anklickbar machen (Lightbox) - document.querySelector('#modal-container .modal-body')?.querySelectorAll('img').forEach(img => { - img.style.cursor = 'zoom-in'; - img.addEventListener('click', () => _showLightbox(img.src)); - }); + // Zurück + view.querySelector('#diary-dv-back').addEventListener('click', () => view.remove()); - // Stern-Buttons: Cover-Bild setzen - document.querySelectorAll('.diary-cover-btn').forEach(btn => { - btn.addEventListener('click', async (ev) => { - ev.stopPropagation(); - const mediaId = parseInt(btn.dataset.mediaId); - try { - await API.diary.setCover(_appState.activeDog.id, entry.id, mediaId); - // Lokalen State aktualisieren - if (entry.media_items) { - entry.media_items.forEach(m => { m.is_cover = m.id === mediaId ? 1 : 0; }); - } - entry.cover_url = (entry.media_items || []).find(m => m.id === mediaId)?.url || null; - _updateEntryInList(entry); - // Alle Sterne im Modal aktualisieren - document.querySelectorAll('.diary-cover-btn').forEach(b => { - const active = parseInt(b.dataset.mediaId) === mediaId; - b.classList.toggle('diary-cover-btn--active', active); - b.style.background = active ? '#f5c518' : 'rgba(0,0,0,.45)'; - b.style.color = active ? '#fff' : 'rgba(255,255,255,.7)'; - b.setAttribute('aria-label', active ? 'Cover-Bild' : 'Als Cover setzen'); - b.setAttribute('title', active ? 'Cover-Bild' : 'Als Cover setzen'); - const use = b.querySelector('use'); - if (use) use.setAttribute('href', `/icons/phosphor.svg#${active ? 'star-fill' : 'star'}`); - }); - UI.toast.success('Cover-Bild gesetzt.'); - } catch { - UI.toast.error('Cover konnte nicht gesetzt werden.'); - } - }); - }); - - document.getElementById('detail-edit')?.addEventListener('click', async () => { - UI.modal.close(); - // Nur nachladen wenn location_name/gps_lat fehlen (älterer In-Memory-Eintrag) + // Bearbeiten + view.querySelector('#diary-dv-edit')?.addEventListener('click', async () => { + view.remove(); if (entry.location_name !== undefined || entry.gps_lat !== undefined) { _showForm(entry); } else { @@ -505,11 +538,41 @@ window.Page_diary = (() => { const idx = _entries.findIndex(e => e.id === entry.id); if (idx !== -1) _entries[idx] = fresh; _showForm(fresh); - } catch { - _showForm(entry); - } + } catch { _showForm(entry); } } }); + + // Foto in Hero → Lightbox + const photoUrls = allMedia.filter(m => m.media_type !== 'video').map(m => m.url); + view.querySelector('#diary-dv-hero')?.querySelector('img')?.addEventListener('click', e => { + const clickedIdx = parseInt(e.target.dataset.idx ?? 0); + const photoIdx = allMedia.slice(0, clickedIdx+1).filter(m => m.media_type !== 'video').length - 1; + _showLightbox(photoUrls, Math.max(0, photoIdx)); + }); + + // Thumbnail-Strip → Hero wechseln + view.querySelector('#diary-dv-thumbs')?.addEventListener('click', e => { + const thumb = e.target.closest('[data-idx]'); + if (!thumb) return; + const i = parseInt(thumb.dataset.idx); + const hero = view.querySelector('#diary-dv-hero'); + if (hero) hero.innerHTML = _heroHtml(allMedia[i]); + // Foto in neuem Hero → Lightbox + hero?.querySelector('img')?.addEventListener('click', ev => { + const clickedIdx = parseInt(ev.target.dataset.idx ?? i); + const photoIdx = allMedia.slice(0, clickedIdx+1).filter(m => m.media_type !== 'video').length - 1; + _showLightbox(photoUrls, Math.max(0, photoIdx)); + }); + // Aktive Markierung + view.querySelectorAll('#diary-dv-thumbs [data-idx]').forEach((t, j) => { + t.style.borderColor = j === i ? 'var(--c-primary)' : 'transparent'; + }); + }); + + // Cover-Button: Stern-Icon auf aktiven Medien (optional, nur für eingeloggte) + if (!_appState?.activeDog?.is_guest && allMedia.some(m => m.id)) { + // Cover-Verwaltung über Edit-Dialog + } } // ---------------------------------------------------------- @@ -618,11 +681,11 @@ window.Page_diary = (() => { - +
@@ -661,7 +724,13 @@ window.Page_diary = (() => { grid.style.cssText = 'display:grid;grid-template-columns:repeat(auto-fill,minmax(90px,1fr));gap:8px;margin-bottom:8px'; grid.innerHTML = _newFiles.map((f, i) => { const objUrl = URL.createObjectURL(f); - const thumb = f.type.startsWith('video/') + const thumb = f.type === 'application/pdf' || f.name?.endsWith('.pdf') + ? `
+ +
${f.name}
+
` + : f.type.startsWith('video/') ? `` : ``; return `
@@ -786,6 +855,7 @@ window.Page_diary = (() => { } }); + document.getElementById('diary-form-cancel')?.addEventListener('click', UI.modal.close); // Milestone-Toggle diff --git a/backend/static/js/pages/forum.js b/backend/static/js/pages/forum.js index 0947758..f5fa480 100644 --- a/backend/static/js/pages/forum.js +++ b/backend/static/js/pages/forum.js @@ -16,6 +16,7 @@ window.Page_forum = (() => { let _mapLoaded = false; let _leafletLoaded = false; let _map = null; + let _clusterGroup = null; let _activeSection = 'list'; // 'list' | 'map' const LIMIT = 30; @@ -238,7 +239,9 @@ window.Page_forum = (() => { const pinBadge = t.is_pinned ? `${UI.icon('push-pin')}` : ''; const lockBadge = t.is_locked ? `${UI.icon('lock')}` : ''; const fotoHtml = t.foto_preview - ? `` + ? /\.(mp4|mov|webm|m4v|avi)$/i.test(t.foto_preview) + ? `
${UI.icon('video-camera')}
` + : `` : ''; return ` @@ -321,10 +324,17 @@ window.Page_forum = (() => {
` : ''; + const _forumMediaHtml = (u) => { + if (u.endsWith('.pdf')) + return ` + ${UI.icon('file-text')} ${_esc(u.split('/').pop())}`; + if (/\.(mp4|mov|webm|m4v|avi)$/i.test(u)) + return ``; + return ``; + }; const fotoGallery = (thread.foto_urls?.length) - ? `
${thread.foto_urls.map(u => - `` - ).join('')}
` + ? `
${thread.foto_urls.map(_forumMediaHtml).join('')}
` : ''; const likeClass = thread.user_liked ? 'forum-like-btn active' : 'forum-like-btn'; @@ -789,10 +799,10 @@ window.Page_forum = (() => {
- +
- - + +
@@ -817,17 +827,52 @@ window.Page_forum = (() => { document.getElementById('ff-cancel')?.addEventListener('click', UI.modal.close); document.getElementById('ff-rules-link')?.addEventListener('click', _showRules); - // Foto-Vorschau - document.getElementById('forum-thread-files')?.addEventListener('change', e => { + // Foto-Vorschau — eigenes Array damit iOS-Mehrfachauswahl akkumuliert + let _threadFiles = []; + + const _renderThreadPreviews = () => { const previews = document.getElementById('forum-thread-previews'); if (!previews) return; previews.innerHTML = ''; - Array.from(e.target.files || []).slice(0, 5).forEach(file => { - const img = document.createElement('img'); - img.src = URL.createObjectURL(file); - img.className = 'forum-upload-thumb'; - previews.appendChild(img); + _threadFiles.forEach((file, i) => { + const wrap = document.createElement('div'); + wrap.style.cssText = 'position:relative;display:inline-block'; + let thumb; + if (file.type === 'application/pdf' || file.name.endsWith('.pdf')) { + thumb = document.createElement('div'); + thumb.className = 'forum-upload-thumb'; + thumb.style.cssText = 'display:flex;align-items:center;justify-content:center;background:var(--c-surface-2);font-size:11px;color:var(--c-text-secondary);text-align:center;padding:4px'; + thumb.textContent = '📄 PDF'; + } else if (file.type.startsWith('video/')) { + thumb = document.createElement('video'); + thumb.src = URL.createObjectURL(file); + thumb.className = 'forum-upload-thumb'; + thumb.muted = true; + } else { + thumb = document.createElement('img'); + thumb.src = URL.createObjectURL(file); + thumb.className = 'forum-upload-thumb'; + } + const del = document.createElement('button'); + del.type = 'button'; + del.textContent = '×'; + del.style.cssText = 'position:absolute;top:-4px;right:-4px;width:18px;height:18px;border-radius:50%;' + + 'background:var(--c-danger);color:#fff;border:none;font-size:12px;line-height:1;cursor:pointer;' + + 'display:flex;align-items:center;justify-content:center;padding:0'; + del.addEventListener('click', () => { _threadFiles.splice(i, 1); _renderThreadPreviews(); }); + wrap.appendChild(thumb); + wrap.appendChild(del); + previews.appendChild(wrap); }); + }; + + document.getElementById('forum-thread-files')?.addEventListener('change', e => { + const neu = Array.from(e.target.files || []); + neu.forEach(f => { + if (_threadFiles.length < 5) _threadFiles.push(f); + }); + e.target.value = ''; // reset damit dieselbe Datei nochmal wählbar ist + _renderThreadPreviews(); }); document.getElementById('forum-thread-form')?.addEventListener('submit', async e => { @@ -853,8 +898,7 @@ window.Page_forum = (() => { }); // Fotos hochladen - const files = Array.from(document.getElementById('forum-thread-files')?.files || []); - for (const file of files.slice(0, 5)) { + for (const file of _threadFiles.slice(0, 5)) { try { await API.forum.uploadThreadFoto(created.id, file); } catch (e) { /* ignorieren */ } @@ -899,8 +943,31 @@ window.Page_forum = (() => { if (show) { try { const pos = await API.getLocation(); - await API.forum.setLocation(pos.lat, pos.lon, true); - UI.toast.success('Standort geteilt.'); + // Ortsmitte via Nominatim: erst Ort (zoom=10), dann Nominatim-Suche nach Ortsname + let lat = pos.lat, lon = pos.lon; + try { + const rev = await fetch( + `https://nominatim.openstreetmap.org/reverse?lat=${lat}&lon=${lon}&format=json&zoom=10&addressdetails=1&accept-language=de`, + { cache: 'no-store' } + ); + const d = await rev.json(); + const a = d.address || {}; + const ort = a.city || a.town || a.village || a.municipality || ''; + if (ort) { + // Ortsname vorwärts-geocodieren um echte Ortsmitte zu bekommen + const fwd = await fetch( + `https://nominatim.openstreetmap.org/search?q=${encodeURIComponent(ort)}&format=json&limit=1&accept-language=de`, + { cache: 'no-store' } + ); + const results = await fwd.json(); + if (results[0]?.lat && results[0]?.lon) { + lat = parseFloat(results[0].lat); + lon = parseFloat(results[0].lon); + } + } + } catch {} + await API.forum.setLocation(lat, lon, true); + UI.toast.success('Ortsmitte geteilt — dein genauer Standort bleibt privat.'); _loadMembersOnMap(); } catch (err) { e.target.checked = false; @@ -910,6 +977,7 @@ window.Page_forum = (() => { try { await API.forum.setLocation(null, null, false); UI.toast.success('Standort versteckt.'); + _loadMembersOnMap(); } catch (err) { UI.toast.error(err.message); } } }); @@ -930,7 +998,25 @@ window.Page_forum = (() => { async function _loadMembersOnMap() { if (!_map) return; try { + // MarkerCluster laden falls nicht vorhanden + if (!window.L.markerClusterGroup) { + await Promise.all([ + new Promise((res, rej) => { + if (document.querySelector('link[href*="MarkerCluster"]')) { res(); return; } + const l1 = document.createElement('link'); l1.rel='stylesheet'; l1.href='/css/MarkerCluster.css'; l1.onload=res; l1.onerror=rej; document.head.appendChild(l1); + }), + new Promise((res, rej) => { + const s = document.createElement('script'); s.src='/js/leaflet.markercluster.js'; s.onload=res; s.onerror=rej; document.head.appendChild(s); + }), + ]); + } + const members = await API.forum.membersMap(); + + // Alte Cluster-Gruppe sauber entfernen + if (_clusterGroup) { _map.removeLayer(_clusterGroup); _clusterGroup = null; } + + _clusterGroup = L.markerClusterGroup({ maxClusterRadius: 60 }); members.forEach(m => { const icon = L.divIcon({ className: '', @@ -941,10 +1027,12 @@ window.Page_forum = (() => { border:2px solid rgba(255,255,255,0.8)">${_esc((m.vorname||'?')[0].toUpperCase())}`, iconSize: [32, 32], iconAnchor: [16, 16], }); - L.marker([m.lat, m.lon], { icon }) - .bindPopup(`${_esc(m.vorname || '?')}`) - .addTo(_map); + _clusterGroup.addLayer( + L.marker([m.lat, m.lon], { icon }) + .bindPopup(`${_esc(m.vorname || '?')}`) + ); }); + _map.addLayer(_clusterGroup); } catch (err) { console.error('Mitgliederkarte Fehler:', err); } diff --git a/backend/static/js/pages/routes.js b/backend/static/js/pages/routes.js index 9a1acf5..51c56f2 100644 --- a/backend/static/js/pages/routes.js +++ b/backend/static/js/pages/routes.js @@ -20,6 +20,26 @@ window.Page_routes = (() => { let _isRecording = false; let _filterOpen = false; + // Navigation-Overlay state + let _navOvl = null, _navMap = null, _navWatchId = null; + let _navWakeLock = null, _navInactTimer = null, _navDimmed = false; + let _navCurrentIdx = 0; + let _navPois = []; + let _navMaxIdx = 0; + let _navWalkMeta = null; // { routeId, totalKm, trackLen } + let _navOrientCleanup = null; + let _navLastBearing = null; + let _navCompassHeading = null; + let _navHeadingSmoothed = null; + + // Recording-Overlay state + let _recOvl = null, _recMap = null; + let _recActive = false; + let _recTrack = [], _recDistKm = 0, _recStartTime = null; + let _recTimerInt = null, _recWatchId = null; + let _recPolyline = null, _recLocMarker = null; + let _recWakeLock = null, _recInactTimer = null, _recDimmed = false; + // 'mine' | 'discover' let _browseMode = 'mine'; @@ -41,9 +61,9 @@ window.Page_routes = (() => { // POI-Typen entlang der Route — nur relevante/interessante Orte const NEARBY_TYPES = [ - { type: 'restaurant', icon: '🍽️', label: 'Restaurant/Café' }, - { type: 'tierarzt', icon: '🏥', label: 'Tierarzt' }, - { type: 'shop', icon: '🐾', label: 'Zoobedarf' }, + { type: 'restaurant', icon: '🍽️', label: 'Restaurant/Café', svgIcon: 'fork-knife', color: '#F97316' }, + { type: 'tierarzt', icon: '🏥', label: 'Tierarzt', svgIcon: 'first-aid', color: '#EF4444' }, + { type: 'shop', icon: '🐾', label: 'Zoobedarf', svgIcon: 'shopping-cart', color: '#3B82F6' }, ]; // _esc und _emptyState ersetzt durch UI.escape() / UI.emptyState() @@ -70,20 +90,17 @@ window.Page_routes = (() => { if (btnRow) { btnRow.innerHTML = ` -