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,
created_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE TABLE IF NOT EXISTS route_walks (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
route_id INTEGER REFERENCES routes(id) ON DELETE SET NULL,
walked_km REAL NOT NULL,
walked_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE INDEX IF NOT EXISTS idx_route_walks_user ON route_walks(user_id);
CREATE TABLE IF NOT EXISTS exercise_progress (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
exercise_id TEXT NOT NULL,
status TEXT,
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
UNIQUE(user_id, exercise_id)
);
CREATE INDEX IF NOT EXISTS idx_exercise_progress_user ON exercise_progress(user_id);
CREATE TABLE IF NOT EXISTS training_plan_progress (
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
item_key TEXT NOT NULL,
checked INTEGER NOT NULL DEFAULT 1,
checked_at TEXT NOT NULL DEFAULT (datetime('now')),
PRIMARY KEY (user_id, item_key)
);
-- GASSI-TREFFEN
CREATE TABLE IF NOT EXISTS walks (
@ -486,6 +512,16 @@ def _migrate(conn_factory):
("forum_threads", "thread_lat", "REAL"),
("forum_threads", "thread_lon", "REAL"),
("forum_threads", "thread_ort", "TEXT"),
# Referral (idempotent, falls try/except-Block oben fehlgeschlagen ist)
("users", "referral_code", "TEXT"),
("users", "referred_by", "INTEGER"),
# Routen: Original-Werte für Gamification (bleiben nach Kürzen erhalten)
("routes", "original_km", "REAL"),
("routes", "original_dauer_min","INTEGER"),
# Gamification: Streaks
("users", "current_streak", "INTEGER NOT NULL DEFAULT 0"),
("users", "max_streak", "INTEGER NOT NULL DEFAULT 0"),
("users", "last_activity_date","TEXT"),
]
with conn_factory() as conn:
for table, column, col_type in migrations:
@ -848,3 +884,16 @@ def _migrate(conn_factory):
logger.info("Migration: referral_code + referred_by zu users hinzugefügt.")
except Exception:
pass
# Gamification: Badge-Tabelle
conn.executescript("""
CREATE TABLE IF NOT EXISTS user_badges (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
badge_id TEXT NOT NULL,
earned_at TEXT NOT NULL DEFAULT (datetime('now')),
UNIQUE(user_id, badge_id)
);
CREATE INDEX IF NOT EXISTS idx_user_badges_user ON user_badges(user_id);
""")
logger.info("Migration: user_badges Tabelle bereit.")

View file

@ -96,6 +96,9 @@ from routes.notifications import router as notifications_router
from routes.services import router as services_router
from routes.ratings import router as ratings_router
from routes.sitting_access import router as sitting_access_router
from routes.stats import router as stats_router
from routes.achievements import router as achievements_router
from routes.training import router as training_router
app.include_router(auth_router, prefix="/api/auth", tags=["Auth"])
app.include_router(dogs_router, prefix="/api/dogs", tags=["Hunde"])
@ -129,6 +132,9 @@ app.include_router(notifications_router, prefix="/api/notifications", tags=["N
app.include_router(services_router, prefix="/api/services", tags=["Services"])
app.include_router(ratings_router, prefix="/api/ratings", tags=["Ratings"])
app.include_router(sitting_access_router, prefix="/api/sitting-access", tags=["SittingAccess"])
app.include_router(stats_router, prefix="/api/stats", tags=["Stats"])
app.include_router(achievements_router, prefix="/api/achievements", tags=["Achievements"])
app.include_router(training_router, prefix="/api/training", tags=["Training"])
# ------------------------------------------------------------------

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.created_at, u.last_login,
(SELECT COUNT(*) FROM dogs d WHERE d.user_id=u.id) AS dog_count,
(SELECT COUNT(*) FROM forum_threads t WHERE t.user_id=u.id AND t.is_deleted=0) AS thread_count
(SELECT COUNT(*) FROM forum_threads t WHERE t.user_id=u.id AND t.is_deleted=0) AS thread_count,
ROUND(COALESCE((SELECT SUM(r.distanz_km) FROM routes r WHERE r.user_id=u.id), 0), 1) AS total_km,
(SELECT COUNT(*) FROM routes r WHERE r.user_id=u.id) AS route_count,
(SELECT COUNT(*) FROM user_map_pois p WHERE p.user_id=u.id) AS poi_count,
(SELECT MAX(r.created_at) FROM routes r WHERE r.user_id=u.id) AS last_route
FROM users u
{where}
ORDER BY u.created_at DESC

View file

@ -116,14 +116,20 @@ async def logout(response: Response):
async def get_referral_info(user=Depends(get_current_user)):
with db() as conn:
row = conn.execute(
"SELECT referral_code, (SELECT COUNT(*) FROM users WHERE referred_by=?) AS count FROM users WHERE id=?",
"""SELECT referral_code,
COALESCE((SELECT COUNT(*) FROM users WHERE referred_by=?), 0) AS count
FROM users WHERE id=?""",
(user['id'], user['id'])
).fetchone()
code = row["referral_code"] if row else None
if not code:
code = _gen_referral_code()
conn.execute("UPDATE users SET referral_code=? WHERE id=?", (code, user['id']))
base = os.getenv("APP_URL", "https://banyaro.app")
return {
"code": row["referral_code"],
"count": row["count"],
"link": f"{base}/?ref={row['referral_code']}",
"code": code,
"count": row["count"] if row else 0,
"link": f"{base}/?ref={code}",
}

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

View file

@ -68,9 +68,14 @@ class ResolveReport(BaseModel):
# ------------------------------------------------------------------
# Helpers
# ------------------------------------------------------------------
_FORUM_ALLOWED_EXT = {".jpg",".jpeg",".png",".gif",".webp",".heic",".heif",
".mp4",".mov",".webm",".m4v",".pdf",".avi"}
def _save_upload(file: UploadFile, data: bytes) -> str:
os.makedirs(FORUM_DIR, exist_ok=True)
ext = os.path.splitext(file.filename or "")[1] or ".jpg"
ext = os.path.splitext(file.filename or "")[1].lower() or ".jpg"
if ext not in _FORUM_ALLOWED_EXT:
raise HTTPException(415, "Dateityp nicht erlaubt.")
filename = f"{uuid.uuid4().hex}{ext}"
path = os.path.join(FORUM_DIR, filename)
with open(path, "wb") as f:
@ -573,7 +578,7 @@ async def members_map():
AND forum_lat IS NOT NULL
AND forum_lon IS NOT NULL"""
).fetchall()
return [{'vorname': r['vorname'] or '?', 'lat': round(r['lat'], 2), 'lon': round(r['lon'], 2)}
return [{'vorname': r['vorname'] or '?', 'lat': round(r['lat'], 3), 'lon': round(r['lon'], 3)}
for r in rows]
@ -583,16 +588,13 @@ async def members_map():
@router.patch("/members/location")
async def set_member_location(data: LocationBody, user=Depends(get_current_user)):
if data.show and data.lat is not None and data.lon is not None:
# Snap to ~1km grid (2 decimal places ≈ 1.1km)
snapped_lat = round(data.lat, 2)
snapped_lon = round(data.lon, 2)
with db() as conn:
conn.execute(
"""UPDATE users SET forum_lat=?, forum_lon=?, forum_show_location=1
WHERE id=?""",
(snapped_lat, snapped_lon, user['id'])
(round(data.lat, 4), round(data.lon, 4), user['id'])
)
return {"ok": True, "lat": snapped_lat, "lon": snapped_lon}
return {"ok": True, "lat": data.lat, "lon": data.lon}
else:
with db() as conn:
conn.execute(

View file

@ -1,11 +1,14 @@
"""BAN YARO — Gassi-Routen"""
import json, math, os, uuid
import httpx
from fastapi import APIRouter, Depends, HTTPException, UploadFile, File
from pydantic import BaseModel
from typing import Optional, List
from database import db
from auth import get_current_user, get_current_user_optional
from routes.achievements import update_streak, check_and_award
from routes.push import send_push_to_user
router = APIRouter()
@ -26,6 +29,7 @@ def _haversine(lat1: float, lon1: float, lat2: float, lon2: float) -> float:
class GPSPoint(BaseModel):
lat: float
lon: float
alt: Optional[float] = None
class RouteCreate(BaseModel):
name: str
@ -147,6 +151,8 @@ async def create_route(data: RouteCreate, user=Depends(get_current_user)):
data.hunde_tauglichkeit,
))
row = conn.execute("SELECT * FROM routes WHERE id = ?", (cur.lastrowid,)).fetchone()
update_streak(user['id'], conn)
check_and_award(user['id'], conn)
return _parse(row)
@ -189,6 +195,59 @@ async def update_route(route_id: int, data: RouteUpdate, user=Depends(get_curren
return _parse(row)
# ------------------------------------------------------------------
# PATCH /api/routes/{id}/trim — Route kürzen (Datenschutz)
# ------------------------------------------------------------------
class RouteTrim(BaseModel):
gps_track: List[GPSPoint]
@router.patch("/{route_id}/trim")
async def trim_route(route_id: int, data: RouteTrim, user=Depends(get_current_user)):
if len(data.gps_track) < 2:
raise HTTPException(400, "Mindestens 2 GPS-Punkte erforderlich.")
with db() as conn:
row = conn.execute("SELECT * FROM routes WHERE id=?", (route_id,)).fetchone()
if not row:
raise HTTPException(404, "Route nicht gefunden.")
if row['user_id'] != user['id']:
raise HTTPException(403, "Nicht berechtigt.")
# Original-Werte beim ersten Kürzen einmalig sichern
if row['original_km'] is None:
conn.execute(
"UPDATE routes SET original_km=?, original_dauer_min=? WHERE id=?",
(row['distanz_km'], row['dauer_min'], route_id)
)
orig_km = row['distanz_km'] or 0
orig_min = row['dauer_min'] or 0
else:
orig_km = row['original_km']
orig_min = row['original_dauer_min'] or 0
# Neue Distanz berechnen
new_track = [p.model_dump() for p in data.gps_track]
new_km = 0.0
for i in range(1, len(new_track)):
p1, p2 = new_track[i-1], new_track[i]
dlat = math.radians(p2['lat'] - p1['lat'])
dlon = math.radians(p2['lon'] - p1['lon'])
a = math.sin(dlat/2)**2 + math.cos(math.radians(p1['lat'])) * math.cos(math.radians(p2['lat'])) * math.sin(dlon/2)**2
new_km += 6371 * 2 * math.atan2(math.sqrt(a), math.sqrt(1-a))
new_km = round(new_km, 2)
# Dauer proportional schätzen (Original-Pace)
pace = orig_min / orig_km if orig_km > 0 else 10
new_min = max(1, round(new_km * pace))
conn.execute(
"UPDATE routes SET gps_track=?, distanz_km=?, dauer_min=? WHERE id=?",
(json.dumps(new_track), new_km, new_min, route_id)
)
row = conn.execute("SELECT * FROM routes WHERE id=?", (route_id,)).fetchone()
return _parse(row)
# ------------------------------------------------------------------
# DELETE /api/routes/{id}
# ------------------------------------------------------------------
@ -227,6 +286,46 @@ async def rate_route(route_id: int, data: RouteRate, user=Depends(get_current_us
return {'bewertung': round(avg, 2), 'anz_bewertungen': n}
# ------------------------------------------------------------------
# POST /api/routes/{id}/walked — Gelaufene km ins Profil eintragen
# ------------------------------------------------------------------
class WalkRecord(BaseModel):
walked_km: float
progress_pct: int
@router.post("/{route_id}/walked", status_code=201)
async def record_walk(route_id: int, body: WalkRecord, user=Depends(get_current_user)):
if body.progress_pct < 50:
raise HTTPException(400, "Mindestens 50 % der Route müssen absolviert sein.")
uid = user["id"]
with db() as conn:
conn.execute(
"INSERT INTO route_walks (user_id, route_id, walked_km) VALUES (?,?,?)",
(uid, route_id, round(max(0.01, body.walked_km), 2))
)
update_streak(uid, conn)
new_badges = check_and_award(uid, conn)
return {"ok": True, "new_badges": new_badges}
# ------------------------------------------------------------------
# POST /api/routes/{id}/reverse — GPS-Track umkehren
# ------------------------------------------------------------------
@router.post("/{route_id}/reverse", status_code=200)
async def reverse_route(route_id: int, user=Depends(get_current_user)):
uid = user["id"]
with db() as conn:
row = conn.execute("SELECT user_id, gps_track FROM routes WHERE id=?", (route_id,)).fetchone()
if not row:
raise HTTPException(404, "Route nicht gefunden.")
if row["user_id"] != uid:
raise HTTPException(403, "Nur der Ersteller kann die Route umkehren.")
track = json.loads(row["gps_track"])
track.reverse()
conn.execute("UPDATE routes SET gps_track=? WHERE id=?", (json.dumps(track), route_id))
return {"ok": True}
# ------------------------------------------------------------------
# POST /api/routes/{id}/photo — Foto hochladen
# ------------------------------------------------------------------
@ -259,3 +358,70 @@ async def add_route_photo(
urls.append(foto_url)
conn.execute("UPDATE routes SET foto_urls=? WHERE id=?", (json.dumps(urls), route_id))
return {'foto_url': foto_url, 'foto_urls': urls}
# ------------------------------------------------------------------
# POST /api/routes/{id}/feedback — Feedback an Route-Ersteller
# ------------------------------------------------------------------
class RouteFeedback(BaseModel):
text: str
@router.post("/{route_id}/feedback", status_code=201)
async def route_feedback(route_id: int, data: RouteFeedback, user=Depends(get_current_user)):
if len(data.text.strip()) < 5:
raise HTTPException(400, "Feedback zu kurz.")
with db() as conn:
row = conn.execute(
"SELECT user_id, name FROM routes WHERE id=?", (route_id,)
).fetchone()
if not row:
raise HTTPException(404, "Route nicht gefunden.")
if row["user_id"] == user["id"]:
raise HTTPException(400, "Eigene Route kann nicht bewertet werden.")
send_push_to_user(row["user_id"], {
"type": "route_feedback",
"title": "📍 Feedback zu \u201e" + row['name'] + "\u201c",
"body": data.text.strip()[:120],
"route_id": route_id,
})
return {"ok": True}
# ------------------------------------------------------------------
# GET /api/routes/{id}/elevation — Höhenprofil via OpenTopoData
# ------------------------------------------------------------------
@router.get("/{route_id}/elevation")
async def route_elevation(route_id: int, _user=Depends(get_current_user_optional)):
with db() as conn:
row = conn.execute("SELECT gps_track FROM routes WHERE id=?", (route_id,)).fetchone()
if not row:
raise HTTPException(404)
track = json.loads(row["gps_track"] or "[]")
if not track:
return {"elevations": []}
# Bereits mit Höhe gespeichert?
if all(p.get("alt") is not None for p in track):
return {"elevations": [{"lat": p["lat"], "lon": p["lon"], "alt": p["alt"]} for p in track]}
# Auf max. 60 Punkte reduzieren
step = max(1, len(track) // 60)
sample = track[::step]
if track[-1] not in sample:
sample.append(track[-1])
locations = "|".join(f"{p['lat']},{p['lon']}" for p in sample)
try:
async with httpx.AsyncClient(timeout=8) as client:
r = await client.get(
f"https://api.opentopodata.org/v1/srtm90m?locations={locations}"
)
results = r.json().get("results", [])
return {"elevations": [
{"lat": res["location"]["lat"], "lon": res["location"]["lng"], "alt": res.get("elevation", 0)}
for res in results
]}
except Exception:
return {"elevations": []}

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);
padding: var(--space-3) var(--space-4);
flex-shrink: 0;
position: relative;
z-index: 10;
}
.rk-search-row {
display: flex;
@ -3824,6 +3826,22 @@ html.modal-open {
}
.forum-foto-img:hover { opacity: 0.85; }
.forum-pdf-card {
display: inline-flex;
align-items: center;
gap: var(--space-2);
padding: var(--space-2) var(--space-3);
border-radius: var(--radius-md);
background: var(--c-surface-2);
border: 1px solid var(--c-border);
color: var(--c-text);
text-decoration: none;
font-size: var(--text-sm);
transition: background var(--transition-fast);
}
.forum-pdf-card:hover { background: var(--c-surface); }
.forum-pdf-card .ph-icon { color: var(--c-danger); flex-shrink: 0; }
/* Upload */
.forum-upload-area { display: flex; gap: var(--space-2); align-items: center; }
.forum-upload-previews {
@ -4984,6 +5002,7 @@ html.modal-open {
}
.chat-conv-item:hover,
.chat-conv-item:active { background: var(--c-surface-2); }
.chat-conv-item.active { background: var(--c-primary-subtle); border-left: 3px solid var(--c-primary); }
.chat-conv-avatar {
width: 44px;
@ -5055,10 +5074,12 @@ html.modal-open {
display: flex;
align-items: center;
gap: var(--space-3);
padding: var(--space-3) var(--space-4);
padding: 0 var(--space-4);
height: 56px;
background: var(--c-surface);
border-bottom: 1px solid var(--c-border);
flex-shrink: 0;
box-sizing: border-box;
}
.chat-thread-partner {

View file

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

View file

@ -1,5 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<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-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>

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="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-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">
<title>Ban Yaro</title>
@ -159,6 +159,16 @@
<span class="header-title" id="header-title">Ban Yaro</span>
</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>
</header>

View file

@ -225,8 +225,13 @@ const API = (() => {
get(id) { return get(`/routes/${id}`); },
create(data) { return post('/routes', data); },
update(id, data) { return patch(`/routes/${id}`, data); },
delete(id) { return del(`/routes/${id}`); },
rate(id, wertung) { return post(`/routes/${id}/rate`, { wertung }); },
trim(id, gps_track) { return patch(`/routes/${id}/trim`, { gps_track }); },
feedback(id, text) { return post(`/routes/${id}/feedback`, { text }); },
elevation(id) { return get(`/routes/${id}/elevation`); },
delete(id) { return del(`/routes/${id}`); },
rate(id, wertung) { return post(`/routes/${id}/rate`, { wertung }); },
walked(id, walked_km, progress_pct) { return post(`/routes/${id}/walked`, { walked_km, progress_pct }); },
reverse(id) { return post(`/routes/${id}/reverse`, {}); },
addPhoto(id, file) {
const fd = new FormData();
fd.append('file', file);
@ -236,6 +241,17 @@ const API = (() => {
},
};
// ----------------------------------------------------------
// TRAINING & ÜBUNGSFORTSCHRITT
// ----------------------------------------------------------
const training = {
getProgress() { return get('/training/progress'); },
setProgress(id, status) { return post('/training/progress', { exercise_id: id, status }); },
getSuggestions() { return get('/training/suggestions'); },
getPlanProgress() { return get('/training/plan-progress'); },
setPlanProgress(key, checked) { return post('/training/plan-progress', { item_key: key, checked }); },
};
// ----------------------------------------------------------
// GASSI-TREFFEN
// ----------------------------------------------------------
@ -556,7 +572,7 @@ const API = (() => {
get, post, put, patch, del, upload,
auth, dogs, diary, health, tieraerzte, poison,
places, routes, walks, events, sitting, forum, lost, knigge, weather, push,
friends, chat, webcal, importData, sharing, widget, notifications, services, ratings, sittingAccess,
friends, chat, webcal, importData, sharing, widget, notifications, services, ratings, sittingAccess, training,
subscribeToPush, getLocation,
APIError,
};

View file

@ -3,7 +3,7 @@
Router, State-Management, Navigation, Initialisierung.
============================================================ */
const APP_VER = '237'; // ← bei jedem Deploy mit Frontend-Änderungen erhöhen
const APP_VER = '261'; // ← bei jedem Deploy mit Frontend-Änderungen erhöhen
const App = (() => {
@ -268,6 +268,12 @@ const App = (() => {
return;
}
// Header-User-Button → Settings
if (e.target.closest('#header-user-btn')) {
navigate('settings');
return;
}
// Sidebar-Logo → Willkommensseite (oder Hund-Profil wenn Hund aktiv)
// Hinweis: dog-sw-title hat eigenen Listener; dieser Fallback greift nur
// wenn kein Hund aktiv ist (statischer "Ban Yaro"-Text ohne dog-sw-title).
@ -410,6 +416,8 @@ const App = (() => {
async function _onLoggedIn() {
document.getElementById('sidebar-username').textContent = state.user.name;
document.getElementById('header-login-btn')?.remove();
_updateHeaderUserBtn(true);
// Admin/Moderator-Item einblenden
const adminItem = document.getElementById('sidebar-admin');
if (adminItem) {
@ -486,6 +494,8 @@ const App = (() => {
_renderDogSwitcher();
_updateHeaderUserBtn(false);
// Wenn aktuelle Seite geschützt ist → zu freier Seite wechseln
if (pages[state.page]?.requiresAuth) {
navigate('map', false);
@ -495,6 +505,30 @@ const App = (() => {
}
}
function _updateHeaderUserBtn(loggedIn) {
const btn = document.getElementById('header-user-btn');
const icon = document.getElementById('header-user-icon');
if (!btn) return;
if (loggedIn) {
const av = state.user?.avatar_url;
if (av) {
btn.innerHTML = `<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() {
try {
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.thread_count} Threads
</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>
<!-- Aktionen -->

View file

@ -34,25 +34,68 @@ window.Page_chat = (() => {
// ----------------------------------------------------------
// 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() {
_view = 'list';
_stopPolling();
_convId = null;
_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>
`;
if (_isDesktop()) {
// Split-Pane: linke Spalte bleibt, rechte zeigt Placeholder
if (!document.getElementById('chat-split')) {
_container.innerHTML = `
<div id="chat-split" style="display:flex;flex:1;min-height:0;overflow:hidden;
position:absolute;inset:0">
<div id="chat-list-pane" style="width:320px;flex-shrink:0;display:flex;
flex-direction:column;border-right:1px solid var(--c-border);
background:var(--c-surface);min-height:0">
${_listPaneHTML()}
</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 _updateChatBadge();
}
@ -122,12 +165,18 @@ window.Page_chat = (() => {
_view = 'thread';
_stopPolling();
_container.innerHTML = `
// Aktive Markierung in der Liste
document.querySelectorAll('.chat-conv-item').forEach(el =>
el.classList.toggle('active', el.getAttribute('onclick')?.includes(String(convId)))
);
const threadHTML = `
<div class="chat-thread" id="chat-thread">
<div class="chat-thread-header">
${_isDesktop() ? '' : `
<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>
</button>
</button>`}
<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>
<span class="online-dot chat-avatar-dot" id="chat-partner-dot" style="display:none"></span>
@ -154,6 +203,14 @@ window.Page_chat = (() => {
</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
const input = document.getElementById('chat-input');
input.addEventListener('input', () => {

View file

@ -371,17 +371,53 @@ window.Page_diary = (() => {
// ----------------------------------------------------------
// LIGHTBOX
// ----------------------------------------------------------
function _showLightbox(src) {
// ----------------------------------------------------------
// LIGHTBOX — Fotos mit Vor/Zurück-Navigation
// ----------------------------------------------------------
function _showLightbox(urls, startIdx = 0) {
const photos = Array.isArray(urls) ? urls : [urls];
let idx = startIdx;
const lb = document.createElement('div');
lb.style.cssText = 'position:fixed;inset:0;background:rgba(0,0,0,.92);z-index:9999;display:flex;align-items:center;justify-content:center;cursor:zoom-out';
lb.innerHTML = `<img src="${UI.escape(src)}" style="max-width:100%;max-height:100%;object-fit:contain;touch-action:pinch-zoom">
<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());
lb.id = 'diary-lightbox';
lb.style.cssText = 'position:fixed;inset:0;z-index:1100;background:#000;display:flex;flex-direction:column';
const render = () => {
lb.innerHTML = `
<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);
}
// ----------------------------------------------------------
// DETAIL-ANSICHT
// DETAIL-ANSICHT — Fullscreen (DayOne-Stil)
// ----------------------------------------------------------
function _openDetail(entryId) {
const entry = _entries.find(e => e.id === entryId);
@ -390,113 +426,110 @@ window.Page_diary = (() => {
const typ = TYPEN[entry.typ] || TYPEN.eintrag;
const isMile = entry.is_milestone || entry.typ === 'meilenstein';
const tags = (entry.tags || []).filter(t => t && t.trim());
const allMedia = _allMedia(entry);
const photo = allMedia.length > 0
? (allMedia.length === 1
? `<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 dogsHtml = dogIds.length > 1
? `<div class="diary-detail-dogs">
? `<div class="diary-detail-dogs" style="margin-bottom:var(--space-3)">
${dogIds.map(did => {
const dog = _appState.dogs.find(d => d.id === did);
return dog ? `<div class="diary-dog-chip">
<div class="diary-dog-av">
${dog.foto_url ? `<img src="${UI.escape(dog.foto_url)}" alt="">` : `<span>${UI.icon('dog')}</span>`}
</div>
<span>${UI.escape(dog.name)}</span>
</div>` : '';
</div><span>${UI.escape(dog.name)}</span></div>` : '';
}).join('')}
</div>`
: '';
</div>` : '';
const body = `
${isMile ? `<div class="diary-detail-milestone-badge">${UI.icon('trophy')} Meilenstein</div>` : ''}
<div style="display:flex;gap:var(--space-2);align-items:center;margin-bottom:var(--space-3)">
<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>
const view = document.createElement('div');
view.id = 'diary-detail-view';
view.style.cssText = 'position:fixed;inset:0;z-index:800;background:var(--c-bg);overflow-y:auto;display:flex;flex-direction:column';
// Medien-HTML für Hero-Bereich
const _heroHtml = (m) => m.media_type === 'pdf'
? `<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>
${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)
document.querySelector('#modal-container .modal-body')?.querySelectorAll('img').forEach(img => {
img.style.cursor = 'zoom-in';
img.addEventListener('click', () => _showLightbox(img.src));
});
// Zurück
view.querySelector('#diary-dv-back').addEventListener('click', () => view.remove());
// Stern-Buttons: Cover-Bild setzen
document.querySelectorAll('.diary-cover-btn').forEach(btn => {
btn.addEventListener('click', async (ev) => {
ev.stopPropagation();
const mediaId = parseInt(btn.dataset.mediaId);
try {
await API.diary.setCover(_appState.activeDog.id, entry.id, mediaId);
// Lokalen State aktualisieren
if (entry.media_items) {
entry.media_items.forEach(m => { m.is_cover = m.id === mediaId ? 1 : 0; });
}
entry.cover_url = (entry.media_items || []).find(m => m.id === mediaId)?.url || null;
_updateEntryInList(entry);
// Alle Sterne im Modal aktualisieren
document.querySelectorAll('.diary-cover-btn').forEach(b => {
const active = parseInt(b.dataset.mediaId) === mediaId;
b.classList.toggle('diary-cover-btn--active', active);
b.style.background = active ? '#f5c518' : 'rgba(0,0,0,.45)';
b.style.color = active ? '#fff' : 'rgba(255,255,255,.7)';
b.setAttribute('aria-label', active ? 'Cover-Bild' : 'Als Cover setzen');
b.setAttribute('title', active ? 'Cover-Bild' : 'Als Cover setzen');
const use = b.querySelector('use');
if (use) use.setAttribute('href', `/icons/phosphor.svg#${active ? 'star-fill' : 'star'}`);
});
UI.toast.success('Cover-Bild gesetzt.');
} catch {
UI.toast.error('Cover konnte nicht gesetzt werden.');
}
});
});
document.getElementById('detail-edit')?.addEventListener('click', async () => {
UI.modal.close();
// Nur nachladen wenn location_name/gps_lat fehlen (älterer In-Memory-Eintrag)
// Bearbeiten
view.querySelector('#diary-dv-edit')?.addEventListener('click', async () => {
view.remove();
if (entry.location_name !== undefined || entry.gps_lat !== undefined) {
_showForm(entry);
} else {
@ -505,11 +538,41 @@ window.Page_diary = (() => {
const idx = _entries.findIndex(e => e.id === entry.id);
if (idx !== -1) _entries[idx] = fresh;
_showForm(fresh);
} catch {
_showForm(entry);
}
} catch { _showForm(entry); }
}
});
// Foto in Hero → Lightbox
const photoUrls = allMedia.filter(m => m.media_type !== 'video').map(m => m.url);
view.querySelector('#diary-dv-hero')?.querySelector('img')?.addEventListener('click', e => {
const clickedIdx = parseInt(e.target.dataset.idx ?? 0);
const photoIdx = allMedia.slice(0, clickedIdx+1).filter(m => m.media_type !== 'video').length - 1;
_showLightbox(photoUrls, Math.max(0, photoIdx));
});
// Thumbnail-Strip → Hero wechseln
view.querySelector('#diary-dv-thumbs')?.addEventListener('click', e => {
const thumb = e.target.closest('[data-idx]');
if (!thumb) return;
const i = parseInt(thumb.dataset.idx);
const hero = view.querySelector('#diary-dv-hero');
if (hero) hero.innerHTML = _heroHtml(allMedia[i]);
// Foto in neuem Hero → Lightbox
hero?.querySelector('img')?.addEventListener('click', ev => {
const clickedIdx = parseInt(ev.target.dataset.idx ?? i);
const photoIdx = allMedia.slice(0, clickedIdx+1).filter(m => m.media_type !== 'video').length - 1;
_showLightbox(photoUrls, Math.max(0, photoIdx));
});
// Aktive Markierung
view.querySelectorAll('#diary-dv-thumbs [data-idx]').forEach((t, j) => {
t.style.borderColor = j === i ? 'var(--c-primary)' : 'transparent';
});
});
// Cover-Button: Stern-Icon auf aktiven Medien (optional, nur für eingeloggte)
if (!_appState?.activeDog?.is_guest && allMedia.some(m => m.id)) {
// Cover-Verwaltung über Edit-Dialog
}
}
// ----------------------------------------------------------
@ -618,11 +681,11 @@ window.Page_diary = (() => {
<div id="diary-new-media-grid" class="diary-media-grid" style="display:none"></div>
<!-- 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) -->
<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
</label>
</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.innerHTML = _newFiles.map((f, i) => {
const objUrl = URL.createObjectURL(f);
const thumb = f.type.startsWith('video/')
const thumb = f.type === 'application/pdf' || f.name?.endsWith('.pdf')
? `<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>`
: `<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}">
@ -786,6 +855,7 @@ window.Page_diary = (() => {
}
});
document.getElementById('diary-form-cancel')?.addEventListener('click', UI.modal.close);
// Milestone-Toggle

View file

@ -16,6 +16,7 @@ window.Page_forum = (() => {
let _mapLoaded = false;
let _leafletLoaded = false;
let _map = null;
let _clusterGroup = null;
let _activeSection = 'list'; // 'list' | 'map'
const LIMIT = 30;
@ -238,7 +239,9 @@ window.Page_forum = (() => {
const pinBadge = t.is_pinned ? `<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 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 `
@ -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>
</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)
? `<div class="forum-foto-grid">${thread.foto_urls.map(u =>
`<img src="${_esc(u)}" class="forum-foto-img" data-src="${_esc(u)}" alt="" loading="lazy">`
).join('')}</div>`
? `<div class="forum-foto-grid">${thread.foto_urls.map(_forumMediaHtml).join('')}</div>`
: '';
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>
<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">
<label class="btn btn-secondary btn-sm" for="forum-thread-files">${UI.icon('camera')} Fotos auswählen</label>
<input type="file" id="forum-thread-files" accept="image/*" multiple style="display:none">
<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/*,video/*,application/pdf" multiple style="display:none">
</div>
<div id="forum-thread-previews" class="forum-upload-previews" style="margin-top:var(--space-2)"></div>
</div>
@ -817,17 +827,52 @@ window.Page_forum = (() => {
document.getElementById('ff-cancel')?.addEventListener('click', UI.modal.close);
document.getElementById('ff-rules-link')?.addEventListener('click', _showRules);
// Foto-Vorschau
document.getElementById('forum-thread-files')?.addEventListener('change', e => {
// Foto-Vorschau — eigenes Array damit iOS-Mehrfachauswahl akkumuliert
let _threadFiles = [];
const _renderThreadPreviews = () => {
const previews = document.getElementById('forum-thread-previews');
if (!previews) return;
previews.innerHTML = '';
Array.from(e.target.files || []).slice(0, 5).forEach(file => {
const img = document.createElement('img');
img.src = URL.createObjectURL(file);
img.className = 'forum-upload-thumb';
previews.appendChild(img);
_threadFiles.forEach((file, i) => {
const wrap = document.createElement('div');
wrap.style.cssText = 'position:relative;display:inline-block';
let thumb;
if (file.type === 'application/pdf' || file.name.endsWith('.pdf')) {
thumb = document.createElement('div');
thumb.className = 'forum-upload-thumb';
thumb.style.cssText = 'display:flex;align-items:center;justify-content:center;background:var(--c-surface-2);font-size:11px;color:var(--c-text-secondary);text-align:center;padding:4px';
thumb.textContent = '📄 PDF';
} else if (file.type.startsWith('video/')) {
thumb = document.createElement('video');
thumb.src = URL.createObjectURL(file);
thumb.className = 'forum-upload-thumb';
thumb.muted = true;
} else {
thumb = document.createElement('img');
thumb.src = URL.createObjectURL(file);
thumb.className = 'forum-upload-thumb';
}
const del = document.createElement('button');
del.type = 'button';
del.textContent = '×';
del.style.cssText = 'position:absolute;top:-4px;right:-4px;width:18px;height:18px;border-radius:50%;' +
'background:var(--c-danger);color:#fff;border:none;font-size:12px;line-height:1;cursor:pointer;' +
'display:flex;align-items:center;justify-content:center;padding:0';
del.addEventListener('click', () => { _threadFiles.splice(i, 1); _renderThreadPreviews(); });
wrap.appendChild(thumb);
wrap.appendChild(del);
previews.appendChild(wrap);
});
};
document.getElementById('forum-thread-files')?.addEventListener('change', e => {
const neu = Array.from(e.target.files || []);
neu.forEach(f => {
if (_threadFiles.length < 5) _threadFiles.push(f);
});
e.target.value = ''; // reset damit dieselbe Datei nochmal wählbar ist
_renderThreadPreviews();
});
document.getElementById('forum-thread-form')?.addEventListener('submit', async e => {
@ -853,8 +898,7 @@ window.Page_forum = (() => {
});
// Fotos hochladen
const files = Array.from(document.getElementById('forum-thread-files')?.files || []);
for (const file of files.slice(0, 5)) {
for (const file of _threadFiles.slice(0, 5)) {
try {
await API.forum.uploadThreadFoto(created.id, file);
} catch (e) { /* ignorieren */ }
@ -899,8 +943,31 @@ window.Page_forum = (() => {
if (show) {
try {
const pos = await API.getLocation();
await API.forum.setLocation(pos.lat, pos.lon, true);
UI.toast.success('Standort geteilt.');
// Ortsmitte via Nominatim: erst Ort (zoom=10), dann Nominatim-Suche nach Ortsname
let lat = pos.lat, lon = pos.lon;
try {
const rev = await fetch(
`https://nominatim.openstreetmap.org/reverse?lat=${lat}&lon=${lon}&format=json&zoom=10&addressdetails=1&accept-language=de`,
{ cache: 'no-store' }
);
const d = await rev.json();
const a = d.address || {};
const ort = a.city || a.town || a.village || a.municipality || '';
if (ort) {
// Ortsname vorwärts-geocodieren um echte Ortsmitte zu bekommen
const fwd = await fetch(
`https://nominatim.openstreetmap.org/search?q=${encodeURIComponent(ort)}&format=json&limit=1&accept-language=de`,
{ cache: 'no-store' }
);
const results = await fwd.json();
if (results[0]?.lat && results[0]?.lon) {
lat = parseFloat(results[0].lat);
lon = parseFloat(results[0].lon);
}
}
} catch {}
await API.forum.setLocation(lat, lon, true);
UI.toast.success('Ortsmitte geteilt — dein genauer Standort bleibt privat.');
_loadMembersOnMap();
} catch (err) {
e.target.checked = false;
@ -910,6 +977,7 @@ window.Page_forum = (() => {
try {
await API.forum.setLocation(null, null, false);
UI.toast.success('Standort versteckt.');
_loadMembersOnMap();
} catch (err) { UI.toast.error(err.message); }
}
});
@ -930,7 +998,25 @@ window.Page_forum = (() => {
async function _loadMembersOnMap() {
if (!_map) return;
try {
// MarkerCluster laden falls nicht vorhanden
if (!window.L.markerClusterGroup) {
await Promise.all([
new Promise((res, rej) => {
if (document.querySelector('link[href*="MarkerCluster"]')) { res(); return; }
const l1 = document.createElement('link'); l1.rel='stylesheet'; l1.href='/css/MarkerCluster.css'; l1.onload=res; l1.onerror=rej; document.head.appendChild(l1);
}),
new Promise((res, rej) => {
const s = document.createElement('script'); s.src='/js/leaflet.markercluster.js'; s.onload=res; s.onerror=rej; document.head.appendChild(s);
}),
]);
}
const members = await API.forum.membersMap();
// Alte Cluster-Gruppe sauber entfernen
if (_clusterGroup) { _map.removeLayer(_clusterGroup); _clusterGroup = null; }
_clusterGroup = L.markerClusterGroup({ maxClusterRadius: 60 });
members.forEach(m => {
const icon = L.divIcon({
className: '',
@ -941,10 +1027,12 @@ window.Page_forum = (() => {
border:2px solid rgba(255,255,255,0.8)">${_esc((m.vorname||'?')[0].toUpperCase())}</div>`,
iconSize: [32, 32], iconAnchor: [16, 16],
});
L.marker([m.lat, m.lon], { icon })
.bindPopup(`<strong>${_esc(m.vorname || '?')}</strong>`)
.addTo(_map);
_clusterGroup.addLayer(
L.marker([m.lat, m.lon], { icon })
.bindPopup(`<strong>${_esc(m.vorname || '?')}</strong>`)
);
});
_map.addLayer(_clusterGroup);
} catch (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
// ----------------------------------------------------------
function _renderAccount() {
async function _renderAccount() {
const u = _appState.user;
// Avatar: Bild oder Buchstabe
@ -61,7 +61,7 @@ window.Page_settings = (() => {
};
_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 style="display:flex;align-items:center;gap:var(--space-4)">
@ -145,6 +145,26 @@ window.Page_settings = (() => {
</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-body" style="padding:0">
<div class="sidebar-item" data-page="dog-profile"
@ -208,22 +228,6 @@ window.Page_settings = (() => {
</select>
</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>
@ -265,6 +269,109 @@ window.Page_settings = (() => {
</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
const avatarBtn = document.getElementById('settings-avatar-btn');
const avatarOverlay = avatarBtn?.querySelector('.avatar-overlay');
@ -495,7 +602,7 @@ window.Page_settings = (() => {
const r = await API.auth.referral();
el.innerHTML = `
<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);
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>
@ -518,7 +625,7 @@ window.Page_settings = (() => {
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;
} catch { /* keine Hunde = okay */ }
document.getElementById('header-login-btn')?.remove();
UI.toast.success(`Willkommen zurück, ${_appState.user.name}!`);
// Push-Benachrichtigungen anbieten wenn noch nicht entschieden
@ -721,8 +829,8 @@ window.Page_settings = (() => {
_appState.dogs = [];
_appState.activeDog = null;
document.getElementById('header-login-btn')?.remove();
UI.toast.success(`Willkommen bei Ban Yaro, ${_appState.user.name}!`);
// Onboarding-Modal direkt zeigen (SPA — kein Reload)
App.showOnboarding();
});
});

View file

@ -36,16 +36,26 @@ window.Page_uebungen = (() => {
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) {
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) {
if (statusId === null) {
localStorage.removeItem(_statusKey(tab, name));
} else {
localStorage.setItem(_statusKey(tab, name), statusId);
}
const k = _progressKey(tab, name);
_progressCache[k] = statusId;
localStorage.setItem(_statusKey(tab, name), statusId || ''); // keep localStorage in sync
API.training.setProgress(k, statusId).catch(() => {});
}
function _nextStatus(currentId) {
@ -352,6 +362,31 @@ window.Page_uebungen = (() => {
_container = container;
_appState = appState;
_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() {}
@ -364,6 +399,7 @@ window.Page_uebungen = (() => {
_container.innerHTML = `
<div id="ueb-wrap">
${_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>
`;
@ -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() {
_container.querySelectorAll('#ueb-tabs .by-tab').forEach(btn => {
btn.addEventListener('click', () => {
@ -551,6 +635,7 @@ window.Page_uebungen = (() => {
const cur = _getStatus(tab, name);
const next = _nextStatus(cur);
_setStatus(tab, name, next);
if (next === 'sitzt') UI.toast.success(`🏆 „${name}" sitzt! Gut gemacht!`);
// Update button in place (no full re-render)
const sm = _statusMeta(next);

View file

@ -34,7 +34,7 @@ window.Page_welcome = (() => {
<div style="text-align:center;margin-bottom:var(--space-8)">
<img src="/icons/icon-180.png" alt="Ban Yaro"
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);
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);

View file

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