Session 2026-04-19: Navigation, Kompass, Übungsfortschritt

Routen-Navigation:
- POI-Marker: farbige Kreise mit Phosphor-Icons (wie Hauptkarte)
- Screensaver: Navi-Pfeil dreht sich via DeviceOrientationEvent (iOS+Android)
- Pfeil-Dämpfung: EMA α=0.12 mit Wrap-Around
- GPS-Distanz-Bug: Fortschritt nur wenn <500m zur Route
- fitBounds: User-Position nur wenn <20km von Route
- Screensaver: "zur Route" vs "verbleibend" kontextabhängig
- Richtungspfeile entlang Route (blau, max 7 Stück)
- Umkehren ins Route-Detail verschoben, Detail-Map rebuildet sich
- rk-header z-index:10 (Leaflet-Tiles liefen drüber)
- 2-Sek. Screensaver-Entsperrung

km-Tracking:
- route_walks Tabelle
- POST /api/routes/{id}/walked (≥50%)
- total_km = erstellte Routes + gelaufene route_walks
- Toast bei neuem Badge

Übungsfortschritt:
- exercise_progress + training_plan_progress Tabellen
- GET/POST /api/training/progress, /plan-progress, /suggestions
- uebungen.js: API-first + localStorage-Fallback + Auto-Migration
- Empfehlungs-Banner (regelbasiert)
- Toast bei "sitzt"
This commit is contained in:
rene 2026-04-19 20:33:01 +02:00
parent 390176383f
commit 9a78121a3e
25 changed files with 2487 additions and 248 deletions

View file

@ -177,6 +177,32 @@ def init_db():
anz_bewertungen INTEGER DEFAULT 0, anz_bewertungen INTEGER DEFAULT 0,
created_at TEXT NOT NULL DEFAULT (datetime('now')) 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 -- GASSI-TREFFEN
CREATE TABLE IF NOT EXISTS walks ( CREATE TABLE IF NOT EXISTS walks (
@ -486,6 +512,16 @@ def _migrate(conn_factory):
("forum_threads", "thread_lat", "REAL"), ("forum_threads", "thread_lat", "REAL"),
("forum_threads", "thread_lon", "REAL"), ("forum_threads", "thread_lon", "REAL"),
("forum_threads", "thread_ort", "TEXT"), ("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: with conn_factory() as conn:
for table, column, col_type in migrations: 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.") logger.info("Migration: referral_code + referred_by zu users hinzugefügt.")
except Exception: except Exception:
pass 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.")

View file

@ -96,6 +96,9 @@ from routes.notifications import router as notifications_router
from routes.services import router as services_router from routes.services import router as services_router
from routes.ratings import router as ratings_router from routes.ratings import router as ratings_router
from routes.sitting_access import router as sitting_access_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(auth_router, prefix="/api/auth", tags=["Auth"])
app.include_router(dogs_router, prefix="/api/dogs", tags=["Hunde"]) 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(services_router, prefix="/api/services", tags=["Services"])
app.include_router(ratings_router, prefix="/api/ratings", tags=["Ratings"]) 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(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"])
# ------------------------------------------------------------------ # ------------------------------------------------------------------

View file

@ -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,
}

View file

@ -171,7 +171,11 @@ async def list_users(
u.is_moderator, u.is_banned, u.ban_reason, u.is_moderator, u.is_banned, u.ban_reason,
u.created_at, u.last_login, u.created_at, u.last_login,
(SELECT COUNT(*) FROM dogs d WHERE d.user_id=u.id) AS dog_count, (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 FROM users u
{where} {where}
ORDER BY u.created_at DESC ORDER BY u.created_at DESC

View file

@ -116,14 +116,20 @@ async def logout(response: Response):
async def get_referral_info(user=Depends(get_current_user)): async def get_referral_info(user=Depends(get_current_user)):
with db() as conn: with db() as conn:
row = conn.execute( 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']) (user['id'], user['id'])
).fetchone() ).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") base = os.getenv("APP_URL", "https://banyaro.app")
return { return {
"code": row["referral_code"], "code": code,
"count": row["count"], "count": row["count"] if row else 0,
"link": f"{base}/?ref={row['referral_code']}", "link": f"{base}/?ref={code}",
} }

View file

@ -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: def _guess_media_type(content_type: str, filename: str) -> str:
ct = (content_type or "").lower() ct = (content_type or "").lower()
if ct == "application/pdf" or (filename or "").lower().endswith(".pdf"):
return "pdf"
if ct.startswith("video/"): if ct.startswith("video/"):
return "video" return "video"
ext = os.path.splitext(filename or "")[1].lower() ext = os.path.splitext(filename or "")[1].lower()
@ -475,13 +477,14 @@ async def upload_media(dog_id: int, entry_id: int,
ALLOWED = { ALLOWED = {
"image/jpeg", "image/png", "image/gif", "image/webp", "image/heic", "image/heif", "image/jpeg", "image/png", "image/gif", "image/webp", "image/heic", "image/heif",
"video/mp4", "video/quicktime", "video/webm", "video/x-m4v", "video/mp4", "video/quicktime", "video/webm", "video/x-m4v",
"application/pdf",
} }
ct = file.content_type or "" ct = file.content_type or ""
if ct not in ALLOWED: if ct not in ALLOWED:
ext_low = os.path.splitext(file.filename or "")[1].lower() ext_low = os.path.splitext(file.filename or "")[1].lower()
if ext_low not in {".jpg",".jpeg",".png",".gif",".webp",".heic",".heif", if ext_low not in {".jpg",".jpeg",".png",".gif",".webp",".heic",".heif",
".mp4",".mov",".webm",".m4v"}: ".mp4",".mov",".webm",".m4v",".pdf"}:
raise HTTPException(415, "Nur Bilder und Videos erlaubt.") raise HTTPException(415, "Nur Bilder, Videos und PDFs erlaubt.")
ext = os.path.splitext(file.filename or "")[1] or ".jpg" ext = os.path.splitext(file.filename or "")[1] or ".jpg"
filename = f"diary_{entry_id}_{uuid.uuid4().hex[:8]}{ext}" filename = f"diary_{entry_id}_{uuid.uuid4().hex[:8]}{ext}"

View file

@ -68,9 +68,14 @@ class ResolveReport(BaseModel):
# ------------------------------------------------------------------ # ------------------------------------------------------------------
# Helpers # 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: def _save_upload(file: UploadFile, data: bytes) -> str:
os.makedirs(FORUM_DIR, exist_ok=True) 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}" filename = f"{uuid.uuid4().hex}{ext}"
path = os.path.join(FORUM_DIR, filename) path = os.path.join(FORUM_DIR, filename)
with open(path, "wb") as f: with open(path, "wb") as f:
@ -573,7 +578,7 @@ async def members_map():
AND forum_lat IS NOT NULL AND forum_lat IS NOT NULL
AND forum_lon IS NOT NULL""" AND forum_lon IS NOT NULL"""
).fetchall() ).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] for r in rows]
@ -583,16 +588,13 @@ async def members_map():
@router.patch("/members/location") @router.patch("/members/location")
async def set_member_location(data: LocationBody, user=Depends(get_current_user)): 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: 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: with db() as conn:
conn.execute( conn.execute(
"""UPDATE users SET forum_lat=?, forum_lon=?, forum_show_location=1 """UPDATE users SET forum_lat=?, forum_lon=?, forum_show_location=1
WHERE id=?""", 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: else:
with db() as conn: with db() as conn:
conn.execute( conn.execute(

View file

@ -1,11 +1,14 @@
"""BAN YARO — Gassi-Routen""" """BAN YARO — Gassi-Routen"""
import json, math, os, uuid import json, math, os, uuid
import httpx
from fastapi import APIRouter, Depends, HTTPException, UploadFile, File from fastapi import APIRouter, Depends, HTTPException, UploadFile, File
from pydantic import BaseModel from pydantic import BaseModel
from typing import Optional, List from typing import Optional, List
from database import db from database import db
from auth import get_current_user, get_current_user_optional 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() router = APIRouter()
@ -26,6 +29,7 @@ def _haversine(lat1: float, lon1: float, lat2: float, lon2: float) -> float:
class GPSPoint(BaseModel): class GPSPoint(BaseModel):
lat: float lat: float
lon: float lon: float
alt: Optional[float] = None
class RouteCreate(BaseModel): class RouteCreate(BaseModel):
name: str name: str
@ -147,6 +151,8 @@ async def create_route(data: RouteCreate, user=Depends(get_current_user)):
data.hunde_tauglichkeit, data.hunde_tauglichkeit,
)) ))
row = conn.execute("SELECT * FROM routes WHERE id = ?", (cur.lastrowid,)).fetchone() 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) return _parse(row)
@ -189,6 +195,59 @@ async def update_route(route_id: int, data: RouteUpdate, user=Depends(get_curren
return _parse(row) 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} # 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} 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 # POST /api/routes/{id}/photo — Foto hochladen
# ------------------------------------------------------------------ # ------------------------------------------------------------------
@ -259,3 +358,70 @@ async def add_route_photo(
urls.append(foto_url) urls.append(foto_url)
conn.execute("UPDATE routes SET foto_urls=? WHERE id=?", (json.dumps(urls), route_id)) conn.execute("UPDATE routes SET foto_urls=? WHERE id=?", (json.dumps(urls), route_id))
return {'foto_url': foto_url, 'foto_urls': urls} 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": []}

44
backend/routes/stats.py Normal file
View file

@ -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 {}

156
backend/routes/training.py Normal file
View file

@ -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 35 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

View file

@ -1994,6 +1994,8 @@ html.modal-open {
border-bottom: 1px solid var(--c-border-light); border-bottom: 1px solid var(--c-border-light);
padding: var(--space-3) var(--space-4); padding: var(--space-3) var(--space-4);
flex-shrink: 0; flex-shrink: 0;
position: relative;
z-index: 10;
} }
.rk-search-row { .rk-search-row {
display: flex; display: flex;
@ -3824,6 +3826,22 @@ html.modal-open {
} }
.forum-foto-img:hover { opacity: 0.85; } .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 */ /* Upload */
.forum-upload-area { display: flex; gap: var(--space-2); align-items: center; } .forum-upload-area { display: flex; gap: var(--space-2); align-items: center; }
.forum-upload-previews { .forum-upload-previews {
@ -4984,6 +5002,7 @@ html.modal-open {
} }
.chat-conv-item:hover, .chat-conv-item:hover,
.chat-conv-item:active { background: var(--c-surface-2); } .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 { .chat-conv-avatar {
width: 44px; width: 44px;
@ -5055,10 +5074,12 @@ html.modal-open {
display: flex; display: flex;
align-items: center; align-items: center;
gap: var(--space-3); gap: var(--space-3);
padding: var(--space-3) var(--space-4); padding: 0 var(--space-4);
height: 56px;
background: var(--c-surface); background: var(--c-surface);
border-bottom: 1px solid var(--c-border); border-bottom: 1px solid var(--c-border);
flex-shrink: 0; flex-shrink: 0;
box-sizing: border-box;
} }
.chat-thread-partner { .chat-thread-partner {

View file

@ -132,16 +132,19 @@
/* Gassi-Treffen + Sitting: volle Höhe, internes Scroll */ /* Gassi-Treffen + Sitting: volle Höhe, internes Scroll */
#page-walks, #page-walks,
#page-sitting { #page-sitting,
#page-chat {
height: 100%; height: 100%;
overflow: hidden; overflow: hidden;
} }
#page-walks > .page-body, #page-walks > .page-body,
#page-sitting > .page-body { #page-sitting > .page-body,
#page-chat > .page-body {
padding: 0 !important; padding: 0 !important;
gap: 0 !important; gap: 0 !important;
overflow: hidden; overflow: hidden;
height: 100%; height: 100%;
position: relative;
} }
/* Routen: volle Höhe damit .rk-layout height:100% auflöst und /* Routen: volle Höhe damit .rk-layout height:100% auflöst und
@ -320,7 +323,7 @@
} }
.sidebar-logo { .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; display: flex;
align-items: center; align-items: center;
gap: var(--space-3); gap: var(--space-3);

View file

@ -1,5 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?> <?xml version="1.0" encoding="UTF-8"?>
<svg xmlns="http://www.w3.org/2000/svg" style="display:none"> <svg xmlns="http://www.w3.org/2000/svg" style="display:none">
<symbol id="arrows-clockwise" viewBox="0 0 256 256"><path d="M224,48V96a8,8,0,0,1-8,8H168a8,8,0,0,1,0-16h28.69L182.06,73.37a79.56,79.56,0,0,0-56.13-23.43C94,49.85,65.52,67.72,51.19,96a8,8,0,0,1-14.32-7.17C54.59,53.81,89.44,33.86,125.93,34a95.43,95.43,0,0,1,67.15,28L208,47.31V24a8,8,0,0,1,16,0ZM202.81,160a79.56,79.56,0,0,1-56.13,23.43C119,183.35,89.38,166,74.93,143.14A8,8,0,1,0,61.06,151C78.22,178.24,108.42,199.27,146.68,199.44c.44,0,.88,0,1.32,0a95.43,95.43,0,0,0,67.15-28L230,187.31V208a8,8,0,0,0,16,0V160a8,8,0,0,0-8-8H190a8,8,0,0,0,0,16Z"/></symbol>
<symbol id="arrow-left" viewBox="0 0 256 256"><path d="M224,128a8,8,0,0,1-8,8H59.31l58.35,58.34a8,8,0,0,1-11.32,11.32l-72-72a8,8,0,0,1,0-11.32l72-72a8,8,0,0,1,11.32,11.32L59.31,120H216A8,8,0,0,1,224,128Z"/></symbol> <symbol id="arrow-left" viewBox="0 0 256 256"><path d="M224,128a8,8,0,0,1-8,8H59.31l58.35,58.34a8,8,0,0,1-11.32,11.32l-72-72a8,8,0,0,1,0-11.32l72-72a8,8,0,0,1,11.32,11.32L59.31,120H216A8,8,0,0,1,224,128Z"/></symbol>
<symbol id="arrow-right" viewBox="0 0 256 256"><path d="M221.66,133.66l-72,72a8,8,0,0,1-11.32-11.32L196.69,136H40a8,8,0,0,1,0-16H196.69L138.34,61.66a8,8,0,0,1,11.32-11.32l72,72A8,8,0,0,1,221.66,133.66Z"/></symbol> <symbol id="arrow-right" viewBox="0 0 256 256"><path d="M221.66,133.66l-72,72a8,8,0,0,1-11.32-11.32L196.69,136H40a8,8,0,0,1,0-16H196.69L138.34,61.66a8,8,0,0,1,11.32-11.32l72,72A8,8,0,0,1,221.66,133.66Z"/></symbol>
<symbol id="bell" viewBox="0 0 256 256"><path d="M221.8,175.94C216.25,166.38,208,139.33,208,104a80,80,0,1,0-160,0c0,35.34-8.26,62.38-13.81,71.94A16,16,0,0,0,48,200H88.81a40,40,0,0,0,78.38,0H208a16,16,0,0,0,13.8-24.06ZM128,216a24,24,0,0,1-22.62-16h45.24A24,24,0,0,1,128,216ZM48,184c7.7-13.24,16-43.92,16-80a64,64,0,1,1,128,0c0,36.05,8.28,66.73,16,80Z"/></symbol> <symbol id="bell" viewBox="0 0 256 256"><path d="M221.8,175.94C216.25,166.38,208,139.33,208,104a80,80,0,1,0-160,0c0,35.34-8.26,62.38-13.81,71.94A16,16,0,0,0,48,200H88.81a40,40,0,0,0,78.38,0H208a16,16,0,0,0,13.8-24.06ZM128,216a24,24,0,0,1-22.62-16h45.24A24,24,0,0,1,128,216ZM48,184c7.7-13.24,16-43.92,16-80a64,64,0,1,1,128,0c0,36.05,8.28,66.73,16,80Z"/></symbol>

Before

Width:  |  Height:  |  Size: 35 KiB

After

Width:  |  Height:  |  Size: 35 KiB

Before After
Before After

View file

@ -15,7 +15,7 @@
<link rel="manifest" href="/manifest.json"> <link rel="manifest" href="/manifest.json">
<link rel="apple-touch-icon" sizes="180x180" href="/icons/icon-180.png"> <link rel="apple-touch-icon" sizes="180x180" href="/icons/icon-180.png">
<meta name="apple-mobile-web-app-capable" content="yes"> <meta name="apple-mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-status-bar-style" content="default"> <meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
<meta name="apple-mobile-web-app-title" content="Ban Yaro"> <meta name="apple-mobile-web-app-title" content="Ban Yaro">
<title>Ban Yaro</title> <title>Ban Yaro</title>
@ -159,6 +159,16 @@
<span class="header-title" id="header-title">Ban Yaro</span> <span class="header-title" id="header-title">Ban Yaro</span>
</div> </div>
<div id="header-actions"></div> <div id="header-actions"></div>
<button id="header-user-btn" aria-label="Profil"
style="width:36px;height:36px;border-radius:50%;border:2px solid var(--c-border);
background:var(--c-surface-2);cursor:pointer;flex-shrink:0;
display:flex;align-items:center;justify-content:center;overflow:hidden;
padding:0;position:relative">
<svg id="header-user-icon" class="ph-icon" aria-hidden="true"
style="width:18px;height:18px;color:var(--c-text-muted)">
<use href="/icons/phosphor.svg#user"></use>
</svg>
</button>
<button class="header-menu-btn" id="header-menu-btn" aria-label="Menü"><svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#list"></use></svg></button> <button class="header-menu-btn" id="header-menu-btn" aria-label="Menü"><svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#list"></use></svg></button>
</header> </header>

View file

@ -225,8 +225,13 @@ const API = (() => {
get(id) { return get(`/routes/${id}`); }, get(id) { return get(`/routes/${id}`); },
create(data) { return post('/routes', data); }, create(data) { return post('/routes', data); },
update(id, data) { return patch(`/routes/${id}`, data); }, update(id, data) { return patch(`/routes/${id}`, data); },
delete(id) { return del(`/routes/${id}`); }, trim(id, gps_track) { return patch(`/routes/${id}/trim`, { gps_track }); },
rate(id, wertung) { return post(`/routes/${id}/rate`, { wertung }); }, 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) { addPhoto(id, file) {
const fd = new FormData(); const fd = new FormData();
fd.append('file', file); 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 // GASSI-TREFFEN
// ---------------------------------------------------------- // ----------------------------------------------------------
@ -556,7 +572,7 @@ const API = (() => {
get, post, put, patch, del, upload, get, post, put, patch, del, upload,
auth, dogs, diary, health, tieraerzte, poison, auth, dogs, diary, health, tieraerzte, poison,
places, routes, walks, events, sitting, forum, lost, knigge, weather, push, 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, subscribeToPush, getLocation,
APIError, APIError,
}; };

View file

@ -3,7 +3,7 @@
Router, State-Management, Navigation, Initialisierung. 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 = (() => { const App = (() => {
@ -268,6 +268,12 @@ const App = (() => {
return; return;
} }
// Header-User-Button → Settings
if (e.target.closest('#header-user-btn')) {
navigate('settings');
return;
}
// Sidebar-Logo → Willkommensseite (oder Hund-Profil wenn Hund aktiv) // Sidebar-Logo → Willkommensseite (oder Hund-Profil wenn Hund aktiv)
// Hinweis: dog-sw-title hat eigenen Listener; dieser Fallback greift nur // Hinweis: dog-sw-title hat eigenen Listener; dieser Fallback greift nur
// wenn kein Hund aktiv ist (statischer "Ban Yaro"-Text ohne dog-sw-title). // wenn kein Hund aktiv ist (statischer "Ban Yaro"-Text ohne dog-sw-title).
@ -410,6 +416,8 @@ const App = (() => {
async function _onLoggedIn() { async function _onLoggedIn() {
document.getElementById('sidebar-username').textContent = state.user.name; document.getElementById('sidebar-username').textContent = state.user.name;
document.getElementById('header-login-btn')?.remove();
_updateHeaderUserBtn(true);
// Admin/Moderator-Item einblenden // Admin/Moderator-Item einblenden
const adminItem = document.getElementById('sidebar-admin'); const adminItem = document.getElementById('sidebar-admin');
if (adminItem) { if (adminItem) {
@ -486,6 +494,8 @@ const App = (() => {
_renderDogSwitcher(); _renderDogSwitcher();
_updateHeaderUserBtn(false);
// Wenn aktuelle Seite geschützt ist → zu freier Seite wechseln // Wenn aktuelle Seite geschützt ist → zu freier Seite wechseln
if (pages[state.page]?.requiresAuth) { if (pages[state.page]?.requiresAuth) {
navigate('map', false); 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 = `<img src="${av}" style="width:100%;height:100%;object-fit:cover;border-radius:50%">`;
} else {
btn.innerHTML = `<svg class="ph-icon" aria-hidden="true" style="width:18px;height:18px;color:var(--c-primary)">
<use href="/icons/phosphor.svg#user"></use></svg>`;
}
btn.style.borderColor = 'var(--c-primary)';
btn.title = 'Mein Profil';
} else {
btn.innerHTML = `<svg class="ph-icon" aria-hidden="true" style="width:18px;height:18px;color:var(--c-text-muted)">
<use href="/icons/phosphor.svg#user"></use></svg>
<div style="position:absolute;bottom:0;right:0;width:10px;height:10px;border-radius:50%;
background:var(--c-danger);border:2px solid var(--c-surface)"></div>`;
btn.style.borderColor = 'var(--c-border)';
btn.title = 'Anmelden';
}
}
async function _loadDogs() { async function _loadDogs() {
try { try {
state.dogs = await API.dogs.list(); state.dogs = await API.dogs.list();

View file

@ -225,6 +225,11 @@ window.Page_admin = (() => {
· ${u.dog_count} Hund${u.dog_count !== 1 ? 'e' : ''} · ${u.dog_count} Hund${u.dog_count !== 1 ? 'e' : ''}
· ${u.thread_count} Threads · ${u.thread_count} Threads
</div> </div>
<div style="font-size:var(--text-xs);color:var(--c-text-muted);margin-top:2px">
🗺 ${u.route_count} Routen · ${u.total_km} km
· 📍 ${u.poi_count} POIs
${u.last_route ? '· zuletzt ' + new Date(u.last_route).toLocaleDateString('de-DE') : ''}
</div>
</div> </div>
<!-- Aktionen --> <!-- Aktionen -->

View file

@ -34,25 +34,68 @@ window.Page_chat = (() => {
// ---------------------------------------------------------- // ----------------------------------------------------------
// Conversation list // Conversation list
// ---------------------------------------------------------- // ----------------------------------------------------------
const _isDesktop = () => window.innerWidth >= 768;
function _listPaneHTML() {
return `
<div style="display:flex;align-items:center;justify-content:space-between;
padding:0 var(--space-4);height:56px;box-sizing:border-box;
flex-shrink:0;border-bottom:1px solid var(--c-border);
background:var(--c-surface)">
<h2 style="font-size:var(--text-base);font-weight:var(--weight-bold);margin:0">Nachrichten</h2>
<button class="btn btn-primary btn-sm" id="chat-new-btn">
${UI.icon('pencil-simple')} Neue Nachricht
</button>
</div>
<div id="chat-list-body" style="overflow-y:auto;flex:1"></div>`;
}
async function _showList() { async function _showList() {
_view = 'list'; _view = 'list';
_stopPolling(); _stopPolling();
_convId = null; _convId = null;
_container.innerHTML = ` if (_isDesktop()) {
<div style="background:var(--c-surface)"> // Split-Pane: linke Spalte bleibt, rechte zeigt Placeholder
<div style="display:flex;align-items:center;justify-content:space-between; if (!document.getElementById('chat-split')) {
padding:var(--space-4) var(--space-4) var(--space-2)"> _container.innerHTML = `
<h2 style="font-size:var(--text-xl);font-weight:var(--weight-bold);margin:0">Nachrichten</h2> <div id="chat-split" style="display:flex;flex:1;min-height:0;overflow:hidden;
<button class="btn btn-primary btn-sm" id="chat-new-btn"> position:absolute;inset:0">
${UI.icon('pencil-simple')} Neue Nachricht <div id="chat-list-pane" style="width:320px;flex-shrink:0;display:flex;
</button> flex-direction:column;border-right:1px solid var(--c-border);
</div> background:var(--c-surface);min-height:0">
<div id="chat-list-body"></div> ${_listPaneHTML()}
</div> </div>
`; <div id="chat-thread-pane" style="flex:1;min-width:0;display:flex;
align-items:center;justify-content:center;
background:var(--c-bg);color:var(--c-text-muted);font-size:var(--text-sm)">
${UI.icon('chat-circle-dots')} Gespräch auswählen
</div>
</div>`;
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 = `
<div style="background:var(--c-surface)">
<div style="display:flex;align-items:center;justify-content:space-between;
padding:var(--space-4) var(--space-4) var(--space-2)">
<h2 style="font-size:var(--text-xl);font-weight:var(--weight-bold);margin:0">Nachrichten</h2>
<button class="btn btn-primary btn-sm" id="chat-new-btn">
${UI.icon('pencil-simple')} Neue Nachricht
</button>
</div>
<div id="chat-list-body"></div>
</div>`;
document.getElementById('chat-new-btn')?.addEventListener('click', _showNewMessagePicker);
}
document.getElementById('chat-new-btn')?.addEventListener('click', _showNewMessagePicker);
await _loadList(); await _loadList();
await _updateChatBadge(); await _updateChatBadge();
} }
@ -122,12 +165,18 @@ window.Page_chat = (() => {
_view = 'thread'; _view = 'thread';
_stopPolling(); _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 = `
<div class="chat-thread" id="chat-thread"> <div class="chat-thread" id="chat-thread">
<div class="chat-thread-header"> <div class="chat-thread-header">
${_isDesktop() ? '' : `
<button class="btn btn-ghost btn-sm" onclick="Page_chat._showList()" style="padding:var(--space-1)"> <button class="btn btn-ghost btn-sm" onclick="Page_chat._showList()" style="padding:var(--space-1)">
<svg class="ph-icon"><use href="/icons/phosphor.svg#arrow-left"></use></svg> <svg class="ph-icon"><use href="/icons/phosphor.svg#arrow-left"></use></svg>
</button> </button>`}
<div style="position:relative;flex-shrink:0"> <div style="position:relative;flex-shrink:0">
<div class="chat-conv-avatar" id="chat-partner-av" style="width:32px;height:32px;font-size:var(--text-sm)">?</div> <div class="chat-conv-avatar" id="chat-partner-av" style="width:32px;height:32px;font-size:var(--text-sm)">?</div>
<span class="online-dot chat-avatar-dot" id="chat-partner-dot" style="display:none"></span> <span class="online-dot chat-avatar-dot" id="chat-partner-dot" style="display:none"></span>
@ -154,6 +203,14 @@ window.Page_chat = (() => {
</div> </div>
`; `;
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 // Auto-resize textarea
const input = document.getElementById('chat-input'); const input = document.getElementById('chat-input');
input.addEventListener('input', () => { input.addEventListener('input', () => {

View file

@ -371,17 +371,53 @@ window.Page_diary = (() => {
// ---------------------------------------------------------- // ----------------------------------------------------------
// LIGHTBOX // 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'); 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.id = 'diary-lightbox';
lb.innerHTML = `<img src="${UI.escape(src)}" style="max-width:100%;max-height:100%;object-fit:contain;touch-action:pinch-zoom"> lb.style.cssText = 'position:fixed;inset:0;z-index:1100;background:#000;display:flex;flex-direction:column';
<button style="position:absolute;top:16px;right:16px;background:rgba(255,255,255,.2);border:none;border-radius:50%;width:40px;height:40px;color:#fff;font-size:22px;cursor:pointer;display:flex;align-items:center;justify-content:center"></button>`;
lb.addEventListener('click', () => lb.remove()); const render = () => {
lb.innerHTML = `
<div style="display:flex;align-items:center;justify-content:space-between;
padding:calc(env(safe-area-inset-top,0px)+10px) 16px 10px;flex-shrink:0">
<button id="lb-close" style="background:rgba(255,255,255,.15);border:none;border-radius:50%;
width:40px;height:40px;color:#fff;font-size:22px;cursor:pointer;
display:flex;align-items:center;justify-content:center"></button>
${photos.length > 1
? `<span style="color:rgba(255,255,255,.7);font-size:13px">${idx+1} / ${photos.length}</span>`
: ''}
<div style="width:40px"></div>
</div>
<div style="flex:1;display:flex;align-items:center;justify-content:center;overflow:hidden;position:relative">
<img src="${UI.escape(photos[idx])}" style="max-width:100%;max-height:100%;object-fit:contain;touch-action:pinch-zoom;display:block">
${photos.length > 1 ? `
<button id="lb-prev" style="position:absolute;left:8px;top:50%;transform:translateY(-50%);
background:rgba(255,255,255,.15);border:none;border-radius:50%;width:44px;height:44px;
color:#fff;font-size:24px;cursor:pointer;display:flex;align-items:center;justify-content:center
${idx === 0 ? ';opacity:.3;pointer-events:none' : ''}"></button>
<button id="lb-next" style="position:absolute;right:8px;top:50%;transform:translateY(-50%);
background:rgba(255,255,255,.15);border:none;border-radius:50%;width:44px;height:44px;
color:#fff;font-size:24px;cursor:pointer;display:flex;align-items:center;justify-content:center
${idx === photos.length-1 ? ';opacity:.3;pointer-events:none' : ''}"></button>
` : ''}
</div>
`;
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); document.body.appendChild(lb);
} }
// ---------------------------------------------------------- // ----------------------------------------------------------
// DETAIL-ANSICHT // DETAIL-ANSICHT — Fullscreen (DayOne-Stil)
// ---------------------------------------------------------- // ----------------------------------------------------------
function _openDetail(entryId) { function _openDetail(entryId) {
const entry = _entries.find(e => e.id === entryId); const entry = _entries.find(e => e.id === entryId);
@ -390,113 +426,110 @@ window.Page_diary = (() => {
const typ = TYPEN[entry.typ] || TYPEN.eintrag; const typ = TYPEN[entry.typ] || TYPEN.eintrag;
const isMile = entry.is_milestone || entry.typ === 'meilenstein'; const isMile = entry.is_milestone || entry.typ === 'meilenstein';
const tags = (entry.tags || []).filter(t => t && t.trim()); const tags = (entry.tags || []).filter(t => t && t.trim());
const allMedia = _allMedia(entry); const allMedia = _allMedia(entry);
const photo = allMedia.length > 0
? (allMedia.length === 1
? `<div style="position:relative;margin-bottom:var(--space-4)">
${_mediaHtml(allMedia[0].url)}
</div>`
: `<div class="diary-gallery" style="margin-bottom:var(--space-4)">
${allMedia.map(m => `
<div class="diary-gallery-wrap" style="position:relative">
${m.media_type === 'video'
? `<video src="${m.url}" controls playsinline class="diary-gallery-item"></video>`
: `<img src="${m.url}" alt="Foto" class="diary-gallery-item">`}
<button type="button"
class="diary-cover-btn${m.is_cover ? ' diary-cover-btn--active' : ''}"
data-media-id="${m.id}"
aria-label="${m.is_cover ? 'Cover-Bild' : 'Als Cover setzen'}"
title="${m.is_cover ? 'Cover-Bild' : 'Als Cover setzen'}"
style="background:${m.is_cover ? '#f5c518' : 'rgba(0,0,0,.45)'};color:${m.is_cover ? '#fff' : 'rgba(255,255,255,.7)'}"><svg style="width:16px;height:16px;display:block" aria-hidden="true"><use href="/icons/phosphor.svg#star"></use></svg></button>
</div>`).join('')}
</div>`)
: '';
// Hunde-Anzeige wenn mehrere beteiligt
const dogIds = entry.dog_ids || [entry.dog_id]; const dogIds = entry.dog_ids || [entry.dog_id];
const dogsHtml = dogIds.length > 1 const dogsHtml = dogIds.length > 1
? `<div class="diary-detail-dogs"> ? `<div class="diary-detail-dogs" style="margin-bottom:var(--space-3)">
${dogIds.map(did => { ${dogIds.map(did => {
const dog = _appState.dogs.find(d => d.id === did); const dog = _appState.dogs.find(d => d.id === did);
return dog ? `<div class="diary-dog-chip"> return dog ? `<div class="diary-dog-chip">
<div class="diary-dog-av"> <div class="diary-dog-av">
${dog.foto_url ? `<img src="${UI.escape(dog.foto_url)}" alt="">` : `<span>${UI.icon('dog')}</span>`} ${dog.foto_url ? `<img src="${UI.escape(dog.foto_url)}" alt="">` : `<span>${UI.icon('dog')}</span>`}
</div> </div><span>${UI.escape(dog.name)}</span></div>` : '';
<span>${UI.escape(dog.name)}</span>
</div>` : '';
}).join('')} }).join('')}
</div>` </div>` : '';
: '';
const body = ` const view = document.createElement('div');
${isMile ? `<div class="diary-detail-milestone-badge">${UI.icon('trophy')} Meilenstein</div>` : ''} view.id = 'diary-detail-view';
<div style="display:flex;gap:var(--space-2);align-items:center;margin-bottom:var(--space-3)"> view.style.cssText = 'position:fixed;inset:0;z-index:800;background:var(--c-bg);overflow-y:auto;display:flex;flex-direction:column';
<span class="badge badge-primary">${typ.icon} ${typ.label}</span>
<span style="color:var(--c-text-secondary);font-size:var(--text-sm)"> // Medien-HTML für Hero-Bereich
${entry.datum ? UI.time.format(entry.datum + 'T00:00:00') : ''} const _heroHtml = (m) => m.media_type === 'pdf'
</span> ? `<a href="${UI.escape(m.url)}" target="_blank" rel="noopener"
style="display:flex;flex-direction:column;align-items:center;justify-content:center;
gap:12px;padding:32px 16px;background:var(--c-surface-2);text-decoration:none;color:var(--c-text)">
<svg class="ph-icon" style="width:48px;height:48px;color:var(--c-danger)" aria-hidden="true"><use href="/icons/phosphor.svg#file-text"></use></svg>
<span style="font-size:14px;font-weight:600">${UI.escape(m.url.split('/').pop())}</span>
<span style="font-size:12px;color:var(--c-text-secondary)">PDF öffnen</span>
</a>`
: m.media_type === 'video'
? `<video src="${UI.escape(m.url)}" controls playsinline style="width:100%;max-height:55vh;display:block;object-fit:contain;background:#000"></video>`
: `<img src="${UI.escape(m.url)}" data-idx="${allMedia.indexOf(m)}" style="width:100%;max-height:55vh;object-fit:cover;display:block;cursor:zoom-in">`;
let mediaSection = '';
if (allMedia.length === 1) {
mediaSection = `<div style="background:#000;flex-shrink:0" id="diary-dv-hero">${_heroHtml(allMedia[0])}</div>`;
} else if (allMedia.length > 1) {
mediaSection = `
<div style="background:#000;flex-shrink:0" id="diary-dv-hero">${_heroHtml(allMedia[0])}</div>
<div style="display:flex;gap:3px;padding:3px;overflow-x:auto;background:#111;flex-shrink:0" id="diary-dv-thumbs">
${allMedia.map((m, i) => `
<div data-idx="${i}" style="flex-shrink:0;width:64px;height:64px;border-radius:4px;overflow:hidden;
cursor:pointer;border:2px solid ${i===0?'var(--c-primary)':'transparent'};box-sizing:border-box">
${m.media_type === 'pdf'
? `<div style="width:100%;height:100%;display:flex;align-items:center;justify-content:center;background:var(--c-surface-2)"><svg class="ph-icon" style="width:28px;height:28px;color:var(--c-danger)" aria-hidden="true"><use href="/icons/phosphor.svg#file-text"></use></svg></div>`
: m.media_type === 'video'
? `<video src="${UI.escape(m.url)}" style="width:100%;height:100%;object-fit:cover;pointer-events:none"></video>`
: `<img src="${UI.escape(m.url)}" style="width:100%;height:100%;object-fit:cover">`}
</div>`).join('')}
</div>`;
}
view.innerHTML = `
<div style="position:sticky;top:0;z-index:10;display:flex;align-items:center;
justify-content:space-between;
padding:calc(env(safe-area-inset-top,0px) + 8px) 16px 8px;
background:var(--c-surface);border-bottom:1px solid var(--c-border);flex-shrink:0">
<button id="diary-dv-back" style="display:flex;align-items:center;gap:6px;background:none;
border:none;color:var(--c-primary);font-size:16px;cursor:pointer;padding:4px 0">
Zurück
</button>
${!_appState?.activeDog?.is_guest
? `<button id="diary-dv-edit" style="background:none;border:none;color:var(--c-primary);
font-size:14px;cursor:pointer;padding:4px 0">Bearbeiten</button>`
: '<div></div>'}
</div>
${mediaSection}
<div style="padding:var(--space-4);flex:1">
${isMile ? `<div class="diary-detail-milestone-badge" style="margin-bottom:var(--space-3)">${UI.icon('trophy')} Meilenstein</div>` : ''}
${entry.titel ? `<h2 style="margin:0 0 var(--space-2);font-size:1.3rem;font-weight:700;color:var(--c-text)">${UI.escape(entry.titel)}</h2>` : ''}
<div style="display:flex;gap:var(--space-2);align-items:center;margin-bottom:var(--space-3);flex-wrap:wrap">
<span class="badge badge-primary">${typ.icon} ${typ.label}</span>
<span style="color:var(--c-text-secondary);font-size:var(--text-sm)">
${entry.datum ? UI.time.format(entry.datum + 'T00:00:00') : ''}
</span>
</div>
${entry.location_name ? `
<div class="diary-detail-location" style="margin-bottom:var(--space-3)">
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#map-pin"></use></svg>
${entry.gps_lat
? `<a href="https://maps.apple.com/?q=${encodeURIComponent(entry.location_name)}&ll=${entry.gps_lat},${entry.gps_lon}"
target="_blank" rel="noopener" style="color:inherit">${UI.escape(entry.location_name)}</a>`
: UI.escape(entry.location_name)}
</div>` : ''}
${dogsHtml}
${entry.text
? `<p style="white-space:pre-wrap;line-height:1.7;color:var(--c-text);margin:0 0 var(--space-4)">${UI.escape(_cleanText(entry.text))}</p>`
: ''}
${tags.length
? `<div style="display:flex;flex-wrap:wrap;gap:var(--space-1)">
${tags.map(t => `<span class="badge">${t}</span>`).join('')}
</div>`
: ''}
</div> </div>
${entry.location_name ? `
<div class="diary-detail-location" style="margin-bottom:var(--space-3)">
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#map-pin"></use></svg>
${entry.gps_lat ? `<a href="https://maps.apple.com/?q=${encodeURIComponent(entry.location_name)}&ll=${entry.gps_lat},${entry.gps_lon}" target="_blank" rel="noopener" style="color:inherit">${UI.escape(entry.location_name)}</a>` : UI.escape(entry.location_name)}
</div>` : ''}
${entry.text
? `<p style="white-space:pre-wrap;line-height:1.6;color:var(--c-text);margin-bottom:var(--space-4)">${UI.escape(_cleanText(entry.text))}</p>`
: ''}
${tags.length
? `<div style="display:flex;flex-wrap:wrap;gap:var(--space-1);margin-bottom:var(--space-4)">
${tags.map(t => `<span class="badge">${t}</span>`).join('')}
</div>`
: ''}
${dogsHtml}
${photo}
${!_appState?.activeDog?.is_guest ? `<button class="btn btn-secondary" style="width:100%;margin-top:var(--space-4)" id="detail-edit">Bearbeiten</button>` : ''}
`; `;
UI.modal.open({ title: entry.titel || typ.label, body }); document.body.appendChild(view);
// Bilder anklickbar machen (Lightbox) // Zurück
document.querySelector('#modal-container .modal-body')?.querySelectorAll('img').forEach(img => { view.querySelector('#diary-dv-back').addEventListener('click', () => view.remove());
img.style.cursor = 'zoom-in';
img.addEventListener('click', () => _showLightbox(img.src));
});
// Stern-Buttons: Cover-Bild setzen // Bearbeiten
document.querySelectorAll('.diary-cover-btn').forEach(btn => { view.querySelector('#diary-dv-edit')?.addEventListener('click', async () => {
btn.addEventListener('click', async (ev) => { view.remove();
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)
if (entry.location_name !== undefined || entry.gps_lat !== undefined) { if (entry.location_name !== undefined || entry.gps_lat !== undefined) {
_showForm(entry); _showForm(entry);
} else { } else {
@ -505,11 +538,41 @@ window.Page_diary = (() => {
const idx = _entries.findIndex(e => e.id === entry.id); const idx = _entries.findIndex(e => e.id === entry.id);
if (idx !== -1) _entries[idx] = fresh; if (idx !== -1) _entries[idx] = fresh;
_showForm(fresh); _showForm(fresh);
} catch { } catch { _showForm(entry); }
_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 = (() => {
<div id="diary-new-media-grid" class="diary-media-grid" style="display:none"></div> <div id="diary-new-media-grid" class="diary-media-grid" style="display:none"></div>
<!-- versteckter Input multiple für Mehrfachauswahl --> <!-- versteckter Input multiple für Mehrfachauswahl -->
<input type="file" id="diary-media-input" accept="image/*,video/*" multiple style="display:none"> <input type="file" id="diary-media-input" accept="image/*,video/*,application/pdf" multiple style="display:none">
<!-- Einzelner Button iOS zeigt nativen Picker (Mediathek / Kamera / Datei) --> <!-- Einzelner Button iOS zeigt nativen Picker (Mediathek / Kamera / Datei) -->
<label for="diary-media-input" class="btn btn-secondary" style="margin-top:var(--space-2);cursor:pointer;display:flex;align-items:center;gap:var(--space-2);justify-content:center"> <label for="diary-media-input" class="btn btn-secondary" style="margin-top:var(--space-2);cursor:pointer;display:flex;align-items:center;gap:var(--space-2);justify-content:center">
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#images"></use></svg> <svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#image"></use></svg>
Fotos / Videos hinzufügen Fotos / Videos hinzufügen
</label> </label>
</div> </div>
@ -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.style.cssText = 'display:grid;grid-template-columns:repeat(auto-fill,minmax(90px,1fr));gap:8px;margin-bottom:8px';
grid.innerHTML = _newFiles.map((f, i) => { grid.innerHTML = _newFiles.map((f, i) => {
const objUrl = URL.createObjectURL(f); const objUrl = URL.createObjectURL(f);
const thumb = f.type.startsWith('video/') const thumb = f.type === 'application/pdf' || f.name?.endsWith('.pdf')
? `<div style="display:flex;flex-direction:column;align-items:center;justify-content:center;
height:100%;gap:4px;padding:8px;text-align:center">
<svg class="ph-icon" style="width:32px;height:32px;color:var(--c-danger)" aria-hidden="true"><use href="/icons/phosphor.svg#file-text"></use></svg>
<div style="font-size:10px;color:var(--c-text-secondary);word-break:break-all;line-height:1.2">${f.name}</div>
</div>`
: f.type.startsWith('video/')
? `<video src="${objUrl}" class="diary-media-thumb" muted playsinline></video>` ? `<video src="${objUrl}" class="diary-media-thumb" muted playsinline></video>`
: `<img src="${objUrl}" alt="" class="diary-media-thumb">`; : `<img src="${objUrl}" alt="" class="diary-media-thumb">`;
return `<div style="position:relative;aspect-ratio:1;border-radius:8px;overflow:hidden;background:var(--c-surface-2)" data-new-idx="${i}"> return `<div style="position:relative;aspect-ratio:1;border-radius:8px;overflow:hidden;background:var(--c-surface-2)" data-new-idx="${i}">
@ -786,6 +855,7 @@ window.Page_diary = (() => {
} }
}); });
document.getElementById('diary-form-cancel')?.addEventListener('click', UI.modal.close); document.getElementById('diary-form-cancel')?.addEventListener('click', UI.modal.close);
// Milestone-Toggle // Milestone-Toggle

View file

@ -16,6 +16,7 @@ window.Page_forum = (() => {
let _mapLoaded = false; let _mapLoaded = false;
let _leafletLoaded = false; let _leafletLoaded = false;
let _map = null; let _map = null;
let _clusterGroup = null;
let _activeSection = 'list'; // 'list' | 'map' let _activeSection = 'list'; // 'list' | 'map'
const LIMIT = 30; const LIMIT = 30;
@ -238,7 +239,9 @@ window.Page_forum = (() => {
const pinBadge = t.is_pinned ? `<span class="forum-pin-badge" title="Angepinnt">${UI.icon('push-pin')}</span>` : ''; const pinBadge = t.is_pinned ? `<span class="forum-pin-badge" title="Angepinnt">${UI.icon('push-pin')}</span>` : '';
const lockBadge = t.is_locked ? `<span class="forum-lock-badge" title="Gesperrt">${UI.icon('lock')}</span>` : ''; const lockBadge = t.is_locked ? `<span class="forum-lock-badge" title="Gesperrt">${UI.icon('lock')}</span>` : '';
const fotoHtml = t.foto_preview const fotoHtml = t.foto_preview
? `<img class="forum-card-thumb" src="${_esc(t.foto_preview)}" alt="" loading="lazy">` ? /\.(mp4|mov|webm|m4v|avi)$/i.test(t.foto_preview)
? `<div class="forum-card-thumb forum-card-thumb--video" style="display:flex;align-items:center;justify-content:center;background:var(--c-surface-2)">${UI.icon('video-camera')}</div>`
: `<img class="forum-card-thumb" src="${_esc(t.foto_preview)}" alt="" loading="lazy">`
: ''; : '';
return ` return `
@ -321,10 +324,17 @@ window.Page_forum = (() => {
<button class="btn btn-ghost btn-sm forum-mod-delete-thread" style="color:var(--c-danger)">${UI.icon('trash')} Thread</button> <button class="btn btn-ghost btn-sm forum-mod-delete-thread" style="color:var(--c-danger)">${UI.icon('trash')} Thread</button>
</div>` : ''; </div>` : '';
const _forumMediaHtml = (u) => {
if (u.endsWith('.pdf'))
return `<a href="${_esc(u)}" target="_blank" rel="noopener" class="forum-pdf-card">
${UI.icon('file-text')} <span>${_esc(u.split('/').pop())}</span></a>`;
if (/\.(mp4|mov|webm|m4v|avi)$/i.test(u))
return `<video src="${_esc(u)}" controls playsinline
style="max-width:100%;max-height:320px;border-radius:var(--radius-md);display:block"></video>`;
return `<img src="${_esc(u)}" class="forum-foto-img" data-src="${_esc(u)}" alt="" loading="lazy">`;
};
const fotoGallery = (thread.foto_urls?.length) const fotoGallery = (thread.foto_urls?.length)
? `<div class="forum-foto-grid">${thread.foto_urls.map(u => ? `<div class="forum-foto-grid">${thread.foto_urls.map(_forumMediaHtml).join('')}</div>`
`<img src="${_esc(u)}" class="forum-foto-img" data-src="${_esc(u)}" alt="" loading="lazy">`
).join('')}</div>`
: ''; : '';
const likeClass = thread.user_liked ? 'forum-like-btn active' : 'forum-like-btn'; const likeClass = thread.user_liked ? 'forum-like-btn active' : 'forum-like-btn';
@ -789,10 +799,10 @@ window.Page_forum = (() => {
<div id="forum-location-picker"></div> <div id="forum-location-picker"></div>
</div> </div>
<div class="form-group"> <div class="form-group">
<label class="form-label">Fotos (max. 5)</label> <label class="form-label">Fotos / Dateien (max. 5)</label>
<div class="forum-upload-area"> <div class="forum-upload-area">
<label class="btn btn-secondary btn-sm" for="forum-thread-files">${UI.icon('camera')} Fotos auswählen</label> <label class="btn btn-secondary btn-sm" for="forum-thread-files">${UI.icon('image')} Fotos / Video / PDF</label>
<input type="file" id="forum-thread-files" accept="image/*" multiple style="display:none"> <input type="file" id="forum-thread-files" accept="image/*,video/*,application/pdf" multiple style="display:none">
</div> </div>
<div id="forum-thread-previews" class="forum-upload-previews" style="margin-top:var(--space-2)"></div> <div id="forum-thread-previews" class="forum-upload-previews" style="margin-top:var(--space-2)"></div>
</div> </div>
@ -817,17 +827,52 @@ window.Page_forum = (() => {
document.getElementById('ff-cancel')?.addEventListener('click', UI.modal.close); document.getElementById('ff-cancel')?.addEventListener('click', UI.modal.close);
document.getElementById('ff-rules-link')?.addEventListener('click', _showRules); document.getElementById('ff-rules-link')?.addEventListener('click', _showRules);
// Foto-Vorschau // Foto-Vorschau — eigenes Array damit iOS-Mehrfachauswahl akkumuliert
document.getElementById('forum-thread-files')?.addEventListener('change', e => { let _threadFiles = [];
const _renderThreadPreviews = () => {
const previews = document.getElementById('forum-thread-previews'); const previews = document.getElementById('forum-thread-previews');
if (!previews) return; if (!previews) return;
previews.innerHTML = ''; previews.innerHTML = '';
Array.from(e.target.files || []).slice(0, 5).forEach(file => { _threadFiles.forEach((file, i) => {
const img = document.createElement('img'); const wrap = document.createElement('div');
img.src = URL.createObjectURL(file); wrap.style.cssText = 'position:relative;display:inline-block';
img.className = 'forum-upload-thumb'; let thumb;
previews.appendChild(img); 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 => { document.getElementById('forum-thread-form')?.addEventListener('submit', async e => {
@ -853,8 +898,7 @@ window.Page_forum = (() => {
}); });
// Fotos hochladen // Fotos hochladen
const files = Array.from(document.getElementById('forum-thread-files')?.files || []); for (const file of _threadFiles.slice(0, 5)) {
for (const file of files.slice(0, 5)) {
try { try {
await API.forum.uploadThreadFoto(created.id, file); await API.forum.uploadThreadFoto(created.id, file);
} catch (e) { /* ignorieren */ } } catch (e) { /* ignorieren */ }
@ -899,8 +943,31 @@ window.Page_forum = (() => {
if (show) { if (show) {
try { try {
const pos = await API.getLocation(); const pos = await API.getLocation();
await API.forum.setLocation(pos.lat, pos.lon, true); // Ortsmitte via Nominatim: erst Ort (zoom=10), dann Nominatim-Suche nach Ortsname
UI.toast.success('Standort geteilt.'); 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(); _loadMembersOnMap();
} catch (err) { } catch (err) {
e.target.checked = false; e.target.checked = false;
@ -910,6 +977,7 @@ window.Page_forum = (() => {
try { try {
await API.forum.setLocation(null, null, false); await API.forum.setLocation(null, null, false);
UI.toast.success('Standort versteckt.'); UI.toast.success('Standort versteckt.');
_loadMembersOnMap();
} catch (err) { UI.toast.error(err.message); } } catch (err) { UI.toast.error(err.message); }
} }
}); });
@ -930,7 +998,25 @@ window.Page_forum = (() => {
async function _loadMembersOnMap() { async function _loadMembersOnMap() {
if (!_map) return; if (!_map) return;
try { 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(); 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 => { members.forEach(m => {
const icon = L.divIcon({ const icon = L.divIcon({
className: '', className: '',
@ -941,10 +1027,12 @@ window.Page_forum = (() => {
border:2px solid rgba(255,255,255,0.8)">${_esc((m.vorname||'?')[0].toUpperCase())}</div>`, border:2px solid rgba(255,255,255,0.8)">${_esc((m.vorname||'?')[0].toUpperCase())}</div>`,
iconSize: [32, 32], iconAnchor: [16, 16], iconSize: [32, 32], iconAnchor: [16, 16],
}); });
L.marker([m.lat, m.lon], { icon }) _clusterGroup.addLayer(
.bindPopup(`<strong>${_esc(m.vorname || '?')}</strong>`) L.marker([m.lat, m.lon], { icon })
.addTo(_map); .bindPopup(`<strong>${_esc(m.vorname || '?')}</strong>`)
);
}); });
_map.addLayer(_clusterGroup);
} catch (err) { } catch (err) {
console.error('Mitgliederkarte Fehler:', err); console.error('Mitgliederkarte Fehler:', err);
} }

File diff suppressed because it is too large Load diff

View file

@ -36,7 +36,7 @@ window.Page_settings = (() => {
// ---------------------------------------------------------- // ----------------------------------------------------------
// EINGELOGGT — Account-Übersicht // EINGELOGGT — Account-Übersicht
// ---------------------------------------------------------- // ----------------------------------------------------------
function _renderAccount() { async function _renderAccount() {
const u = _appState.user; const u = _appState.user;
// Avatar: Bild oder Buchstabe // Avatar: Bild oder Buchstabe
@ -61,7 +61,7 @@ window.Page_settings = (() => {
}; };
_container.innerHTML = ` _container.innerHTML = `
<div style="max-width:400px;margin:0 auto;padding:var(--space-4) 0"> <div style="width:100%;max-width:640px;margin:0 auto;box-sizing:border-box;overflow-x:hidden;align-self:center">
<div class="card" style="padding:var(--space-5);margin-bottom:var(--space-4)"> <div class="card" style="padding:var(--space-5);margin-bottom:var(--space-4)">
<div style="display:flex;align-items:center;gap:var(--space-4)"> <div style="display:flex;align-items:center;gap:var(--space-4)">
@ -145,6 +145,26 @@ window.Page_settings = (() => {
</div> </div>
</div> </div>
<div class="card" id="settings-stats-card" style="margin-bottom:var(--space-4)">
<div style="padding:var(--space-3) var(--space-4);font-size:var(--text-xs);font-weight:600;
color:var(--c-text-secondary);text-transform:uppercase;letter-spacing:0.05em;
border-bottom:1px solid var(--c-border)">Aktivität</div>
<div id="settings-stats-body" style="padding:var(--space-4);display:flex;justify-content:space-around">
<div style="color:var(--c-text-muted);font-size:var(--text-sm)">Lädt</div>
</div>
<div id="settings-streak" style="display:flex;align-items:center;gap:8px;
padding:0 var(--space-4) var(--space-3);flex-wrap:wrap"></div>
</div>
<div class="card" style="margin-bottom:var(--space-4)">
<div style="padding:var(--space-3) var(--space-4);font-size:var(--text-xs);font-weight:600;
color:var(--c-text-secondary);text-transform:uppercase;letter-spacing:0.05em;
border-bottom:1px solid var(--c-border)">Trophäen</div>
<div id="settings-badges-body" style="padding:var(--space-4)">
<div style="color:var(--c-text-muted);font-size:var(--text-sm)">Lädt</div>
</div>
</div>
<div class="card" style="margin-bottom:var(--space-4)"> <div class="card" style="margin-bottom:var(--space-4)">
<div class="card-body" style="padding:0"> <div class="card-body" style="padding:0">
<div class="sidebar-item" data-page="dog-profile" <div class="sidebar-item" data-page="dog-profile"
@ -208,22 +228,6 @@ window.Page_settings = (() => {
</select> </select>
</div> </div>
<div style="display:flex;align-items:center;gap:var(--space-3);
padding:var(--space-4);border-bottom:1px solid var(--c-border)">
<svg class="ph-icon" aria-hidden="true" style="width:1.25rem;height:1.25rem"><use href="/icons/phosphor.svg#eye-slash"></use></svg>
<div style="flex:1">
<div style="font-weight:500">Pocket-Modus beim Aufzeichnen</div>
<div style="font-size:var(--text-xs);color:var(--c-text-secondary);margin-top:2px">
Schwarzes Overlay hält den Bildschirm aktiv (GPS läuft) ideal für die Hosentasche.
Helligkeit auf Minimum reduzieren für optimalen Akku-Schutz.
</div>
</div>
<label class="toggle" style="flex-shrink:0">
<input type="checkbox" id="toggle-pocket-mode"
${localStorage.getItem('by_pocket_mode') === 'true' ? 'checked' : ''}>
<span class="toggle-slider"></span>
</label>
</div>
</div> </div>
</div> </div>
@ -265,6 +269,109 @@ window.Page_settings = (() => {
</div> </div>
`; `;
// Achievements laden (Streak + Stats + Badges)
API.get('/achievements/me').then(a => {
const statsEl = document.getElementById('settings-stats-body');
const badgesEl = document.getElementById('settings-badges-body');
if (!statsEl) return;
const s = a.stats || {}, streak = a.streak || {};
const stat = (val, label) => `
<div style="text-align:center">
<div style="font-size:1.3rem;font-weight:700;color:var(--c-text)">${val}</div>
<div style="font-size:10px;color:var(--c-text-secondary);text-transform:uppercase;letter-spacing:.05em;margin-top:2px">${label}</div>
</div>`;
statsEl.innerHTML =
stat((s.total_km ?? 0) + ' km', 'gelaufen') +
stat(s.routen ?? 0, 'Routen') +
stat(s.pois ?? 0, 'POIs') +
stat('#' + (a.rang ?? ''), 'Rang');
const streakEl = document.getElementById('settings-streak');
if (streakEl) {
const cur = streak.current || 0, mx = streak.max || 0;
streakEl.innerHTML = cur > 0
? `<span style="font-size:1.3rem">🔥</span>
<span style="font-weight:700;font-size:1.05rem">${cur} Tage Streak</span>
${mx > cur ? `<span style="color:var(--c-text-muted);font-size:11px;margin-left:auto">Best: ${mx}</span>` : ''}`
: `<span style="color:var(--c-text-muted);font-size:var(--text-sm)">🔥 Noch kein Streak — heute aktiv werden!</span>`;
}
if (badgesEl && a.categories) {
// SVG-Schild für jede Kategorie
const shield = (color, dark, emoji, opacity = 1) => `
<svg viewBox="0 0 60 72" xmlns="http://www.w3.org/2000/svg"
style="width:56px;height:56px;filter:drop-shadow(0 2px 6px ${color}66)">
<defs>
<linearGradient id="g_${color.replace('#','')}" x1="0" y1="0" x2="1" y2="1">
<stop offset="0%" stop-color="${color}"/>
<stop offset="100%" stop-color="${dark}"/>
</linearGradient>
</defs>
<path d="M30 3 L57 15 L57 38 Q57 60 30 70 Q3 60 3 38 L3 15 Z"
fill="url(#g_${color.replace('#','')})" opacity="${opacity}"/>
<path d="M30 3 L57 15 L57 38 Q57 60 30 70 Q3 60 3 38 L3 15 Z"
fill="none" stroke="rgba(255,255,255,.25)" stroke-width="1.5"/>
<text x="30" y="43" text-anchor="middle" dominant-baseline="middle"
font-size="22" style="user-select:none">${emoji}</text>
</svg>`;
badgesEl.innerHTML = (a.categories || []).map(cat => {
const cur = cat.current_tier;
const nxt = cat.next_tier;
const val = cat.current_value;
// Alle Stufen als kleine Punkte
const dots = (cat.alle_stufen || []).map(s =>
`<div title="${_esc(s.name)}" style="width:8px;height:8px;border-radius:50%;
background:${s.earned ? s.color : 'var(--c-border)'}"></div>`
).join('');
// Aktuelles Schild
const shieldSvg = cur
? shield(cur.color, cur.dark, cat.emoji)
: shield('#9ca3af', '#6b7280', cat.emoji, 0.5);
// Fortschrittsbalken
const progressBar = nxt ? `
<div style="font-size:10px;color:var(--c-text-muted);margin-top:4px">
${val}${cat.einheit} / ${nxt.schwelle}${cat.einheit} ${_esc(nxt.name)}
</div>
<div style="height:4px;background:var(--c-border);border-radius:2px;margin-top:4px;overflow:hidden">
<div style="height:100%;width:${cat.progress}%;background:${nxt.color};border-radius:2px;transition:width .4s"></div>
</div>` : `
<div style="font-size:10px;color:var(--c-primary);font-weight:600;margin-top:4px">
Höchste Stufe erreicht! 🎉
</div>`;
return `
<div style="display:flex;gap:14px;align-items:flex-start;padding:12px 0;
border-bottom:1px solid var(--c-border)">
${shieldSvg}
<div style="flex:1;min-width:0">
<div style="display:flex;align-items:center;gap:6px;margin-bottom:2px">
<span style="font-weight:700;font-size:var(--text-sm)">${_esc(cat.name)}</span>
${cur ? `<span style="font-size:10px;font-weight:600;padding:1px 6px;border-radius:999px;
background:${cur.color};color:${cur.text}">${_esc(cur.name)}</span>` : ''}
</div>
<div style="display:flex;gap:4px;margin-bottom:6px">${dots}</div>
${progressBar}
</div>
</div>`;
}).join('');
}
// Neue Badges als Toast
if (a.new_badges?.length) {
a.new_badges.forEach(b => {
UI.toast.success(`${b.emoji} ${b.name}${b.tier} freigeschaltet!`);
});
}
}).catch(() => {
const el = document.getElementById('settings-stats-body');
if (el) el.innerHTML = '<div style="color:var(--c-text-muted);font-size:var(--text-sm)"></div>';
});
// Avatar-Hover-Overlay // Avatar-Hover-Overlay
const avatarBtn = document.getElementById('settings-avatar-btn'); const avatarBtn = document.getElementById('settings-avatar-btn');
const avatarOverlay = avatarBtn?.querySelector('.avatar-overlay'); const avatarOverlay = avatarBtn?.querySelector('.avatar-overlay');
@ -495,7 +602,7 @@ window.Page_settings = (() => {
const r = await API.auth.referral(); const r = await API.auth.referral();
el.innerHTML = ` el.innerHTML = `
<div style="display:flex;align-items:center;gap:var(--space-3);margin-bottom:var(--space-3)"> <div style="display:flex;align-items:center;gap:var(--space-3);margin-bottom:var(--space-3)">
<div style="flex:1;background:var(--c-surface-2);border-radius:var(--radius-md); <div style="flex:1;min-width:0;background:var(--c-surface-2);border-radius:var(--radius-md);
padding:var(--space-2) var(--space-3);font-family:monospace;font-size:var(--text-sm); padding:var(--space-2) var(--space-3);font-family:monospace;font-size:var(--text-sm);
overflow:hidden;text-overflow:ellipsis;white-space:nowrap">${r.link}</div> overflow:hidden;text-overflow:ellipsis;white-space:nowrap">${r.link}</div>
<button class="btn btn-primary btn-sm" id="ref-share-btn">${UI.icon('arrow-square-out')} Teilen</button> <button class="btn btn-primary btn-sm" id="ref-share-btn">${UI.icon('arrow-square-out')} Teilen</button>
@ -518,7 +625,7 @@ window.Page_settings = (() => {
UI.toast.success('Link kopiert!'); UI.toast.success('Link kopiert!');
} }
}); });
} catch { el.innerHTML = '<p style="color:var(--c-text-muted)">Nicht verfügbar.</p>'; } } catch { el.innerHTML = ''; }
} }
// ---------------------------------------------------------- // ----------------------------------------------------------
@ -677,6 +784,7 @@ window.Page_settings = (() => {
_appState.activeDog = _appState.dogs[0] || null; _appState.activeDog = _appState.dogs[0] || null;
} catch { /* keine Hunde = okay */ } } catch { /* keine Hunde = okay */ }
document.getElementById('header-login-btn')?.remove();
UI.toast.success(`Willkommen zurück, ${_appState.user.name}!`); UI.toast.success(`Willkommen zurück, ${_appState.user.name}!`);
// Push-Benachrichtigungen anbieten wenn noch nicht entschieden // Push-Benachrichtigungen anbieten wenn noch nicht entschieden
@ -721,8 +829,8 @@ window.Page_settings = (() => {
_appState.dogs = []; _appState.dogs = [];
_appState.activeDog = null; _appState.activeDog = null;
document.getElementById('header-login-btn')?.remove();
UI.toast.success(`Willkommen bei Ban Yaro, ${_appState.user.name}!`); UI.toast.success(`Willkommen bei Ban Yaro, ${_appState.user.name}!`);
// Onboarding-Modal direkt zeigen (SPA — kein Reload)
App.showOnboarding(); App.showOnboarding();
}); });
}); });

View file

@ -36,16 +36,26 @@ window.Page_uebungen = (() => {
return `ub_status_${tab}_${name.replace(/\s+/g, '_')}`; return `ub_status_${tab}_${name.replace(/\s+/g, '_')}`;
} }
// In-memory cache (loaded from API on init)
let _progressCache = {}; // key → statusId
function _progressKey(tab, name) {
return `${tab}_${name.replace(/[\s/]+/g, '_')}`;
}
function _getStatus(tab, name) { function _getStatus(tab, name) {
return localStorage.getItem(_statusKey(tab, name)) || null; const k = _progressKey(tab, name);
// Fallback to localStorage while API loads
return _progressCache[k] !== undefined
? _progressCache[k]
: localStorage.getItem(_statusKey(tab, name)) || null;
} }
function _setStatus(tab, name, statusId) { function _setStatus(tab, name, statusId) {
if (statusId === null) { const k = _progressKey(tab, name);
localStorage.removeItem(_statusKey(tab, name)); _progressCache[k] = statusId;
} else { localStorage.setItem(_statusKey(tab, name), statusId || ''); // keep localStorage in sync
localStorage.setItem(_statusKey(tab, name), statusId); API.training.setProgress(k, statusId).catch(() => {});
}
} }
function _nextStatus(currentId) { function _nextStatus(currentId) {
@ -352,6 +362,31 @@ window.Page_uebungen = (() => {
_container = container; _container = container;
_appState = appState; _appState = appState;
_render(); _render();
// Progress vom Server laden
API.training.getProgress().then(rows => {
rows.forEach(r => { _progressCache[r.exercise_id] = r.status; });
// localStorage-Daten migrieren falls noch nicht im Backend
Object.keys(localStorage).filter(k => k.startsWith('ub_status_')).forEach(lsKey => {
const parts = lsKey.replace('ub_status_', '').split('_');
const tab = parts[0];
const name = parts.slice(1).join('_');
const apiKey = `${tab}_${name}`;
if (_progressCache[apiKey] === undefined) {
const val = localStorage.getItem(lsKey);
if (val) {
_progressCache[apiKey] = val;
API.training.setProgress(apiKey, val).catch(() => {});
}
}
});
_renderContent(); // Re-render with loaded progress
}).catch(() => {});
// Empfehlungen laden
API.training.getSuggestions().then(suggestions => {
if (suggestions.length) _showSuggestions(suggestions);
}).catch(() => {});
} }
function refresh() {} function refresh() {}
@ -364,6 +399,7 @@ window.Page_uebungen = (() => {
_container.innerHTML = ` _container.innerHTML = `
<div id="ueb-wrap"> <div id="ueb-wrap">
${_renderTabs()} ${_renderTabs()}
<div id="ueb-suggestions" style="padding:0 var(--space-4);display:flex;flex-direction:column;gap:var(--space-2);margin-bottom:var(--space-2)"></div>
<div id="ueb-content"></div> <div id="ueb-content"></div>
</div> </div>
`; `;
@ -384,6 +420,54 @@ window.Page_uebungen = (() => {
`; `;
} }
function _showSuggestions(suggestions) {
const el = _container.querySelector('#ueb-suggestions');
if (!el || !suggestions.length) return;
const COLORS = {
help: { bg: '#fef2f2', border: '#fca5a5', text: '#dc2626' },
boost: { bg: '#fff7ed', border: '#fdba74', text: '#ea580c' },
next: { bg: '#f0fdf4', border: '#86efac', text: '#16a34a' },
start: { bg: 'var(--c-primary-subtle)', border: 'var(--c-primary-light)', text: 'var(--c-primary)' },
};
el.innerHTML = suggestions.map(s => {
const c = COLORS[s.type] || COLORS.start;
return `
<div style="background:${c.bg};border:1px solid ${c.border};border-radius:var(--radius-md);
padding:var(--space-3) var(--space-4);display:flex;gap:var(--space-3);align-items:flex-start;cursor:pointer"
data-action-tab="${_esc(s.action_tab || '')}"
data-action-name="${_esc(s.action_name || '')}"
class="ueb-suggestion-card">
<svg class="ph-icon" style="width:20px;height:20px;flex-shrink:0;color:${c.text};margin-top:2px" aria-hidden="true">
<use href="/icons/phosphor.svg#${_esc(s.icon)}"></use>
</svg>
<div style="min-width:0">
<div style="font-size:var(--text-sm);font-weight:var(--weight-semibold);color:${c.text};margin-bottom:2px">
${_esc(s.title)}
</div>
<div style="font-size:var(--text-xs);color:var(--c-text-secondary);line-height:1.5">
${_esc(s.text)}
</div>
</div>
</div>
`;
}).join('');
el.querySelectorAll('.ueb-suggestion-card').forEach(card => {
card.addEventListener('click', () => {
const tab = card.dataset.actionTab;
if (tab && tab !== _activeTab) {
_activeTab = tab;
_container.querySelectorAll('#ueb-tabs .by-tab').forEach(b =>
b.classList.toggle('active', b.dataset.tab === tab)
);
_renderContent();
}
});
});
}
function _bindTabs() { function _bindTabs() {
_container.querySelectorAll('#ueb-tabs .by-tab').forEach(btn => { _container.querySelectorAll('#ueb-tabs .by-tab').forEach(btn => {
btn.addEventListener('click', () => { btn.addEventListener('click', () => {
@ -551,6 +635,7 @@ window.Page_uebungen = (() => {
const cur = _getStatus(tab, name); const cur = _getStatus(tab, name);
const next = _nextStatus(cur); const next = _nextStatus(cur);
_setStatus(tab, name, next); _setStatus(tab, name, next);
if (next === 'sitzt') UI.toast.success(`🏆 „${name}" sitzt! Gut gemacht!`);
// Update button in place (no full re-render) // Update button in place (no full re-render)
const sm = _statusMeta(next); const sm = _statusMeta(next);

View file

@ -34,7 +34,7 @@ window.Page_welcome = (() => {
<div style="text-align:center;margin-bottom:var(--space-8)"> <div style="text-align:center;margin-bottom:var(--space-8)">
<img src="/icons/icon-180.png" alt="Ban Yaro" <img src="/icons/icon-180.png" alt="Ban Yaro"
style="width:88px;height:88px;border-radius:var(--radius-xl); style="width:88px;height:88px;border-radius:var(--radius-xl);
box-shadow:var(--shadow-md);margin-bottom:var(--space-4)"> box-shadow:var(--shadow-md);margin:0 auto var(--space-4);display:block">
<h1 style="font-size:var(--text-2xl);font-weight:var(--weight-bold); <h1 style="font-size:var(--text-2xl);font-weight:var(--weight-bold);
color:var(--c-text);margin:0 0 var(--space-2)">Ban Yaro</h1> color:var(--c-text);margin:0 0 var(--space-2)">Ban Yaro</h1>
<p style="font-size:var(--text-base);color:var(--c-text-secondary); <p style="font-size:var(--text-base);color:var(--c-text-secondary);

View file

@ -3,7 +3,7 @@
Offline-Cache + Push Notifications + Tile-Cache Offline-Cache + Push Notifications + Tile-Cache
============================================================ */ ============================================================ */
const CACHE_VERSION = 'by-v244'; const CACHE_VERSION = 'by-v271';
const CACHE_STATIC = `${CACHE_VERSION}-static`; const CACHE_STATIC = `${CACHE_VERSION}-static`;
const CACHE_TILES = 'ban-yaro-tiles-v1'; // bleibt über SW-Updates erhalten const CACHE_TILES = 'ban-yaro-tiles-v1'; // bleibt über SW-Updates erhalten