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:
parent
390176383f
commit
9a78121a3e
25 changed files with 2487 additions and 248 deletions
|
|
@ -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.")
|
||||
|
|
|
|||
|
|
@ -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"])
|
||||
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
|
|
|
|||
279
backend/routes/achievements.py
Normal file
279
backend/routes/achievements.py
Normal 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,
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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}",
|
||||
}
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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}"
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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
44
backend/routes/stats.py
Normal 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
156
backend/routes/training.py
Normal 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 3–5 Minuten — dann sitzt es bald.",
|
||||
"action_tab": "grundkommandos",
|
||||
"action_name": almost[0],
|
||||
})
|
||||
|
||||
# Nächste ungestartete Grundkommando
|
||||
grundk_mastered = [n for n in GRUNDKOMMANDOS_ORDER if progress.get(key(n)) == 'sitzt']
|
||||
next_up = next((n for n in GRUNDKOMMANDOS_ORDER
|
||||
if progress.get(key(n)) in (None, 'noch-nicht') and n not in stuck), None)
|
||||
|
||||
if len(grundk_mastered) == len(GRUNDKOMMANDOS_ORDER):
|
||||
# Alle Grundkommandos gemeistert → Tricks empfehlen
|
||||
next_trick = next((n for n in TRICKS_FIRST if progress.get(tkey(n)) is None), None)
|
||||
if next_trick:
|
||||
suggestions.append({
|
||||
"type": "next",
|
||||
"icon": "star",
|
||||
"title": "Alle Grundkommandos sitzen! 🎉",
|
||||
"text": f'Zeit für Tricks! Starte mit \u201e{next_trick}\u201c \u2014 Spaß garantiert.',
|
||||
"action_tab": "tricks",
|
||||
"action_name": next_trick,
|
||||
})
|
||||
elif next_up and not almost:
|
||||
suggestions.append({
|
||||
"type": "next",
|
||||
"icon": "arrow-right",
|
||||
"title": f"Bereit für den nächsten Schritt?",
|
||||
"text": f'Starte jetzt mit \u201e{next_up}\u201c. {"Du hast bereits " + str(len(grundk_mastered)) + " Grundkommandos gemeistert!" if grundk_mastered else "Es ist die perfekte Basis für alles Weitere."}',
|
||||
"action_tab": "grundkommandos",
|
||||
"action_name": next_up,
|
||||
})
|
||||
elif not progress:
|
||||
# Noch gar kein Fortschritt
|
||||
suggestions.append({
|
||||
"type": "start",
|
||||
"icon": "flag",
|
||||
"title": "Womit fangen wir an?",
|
||||
"text": "\u201eSitz\u201c ist das erste Kommando für jeden Hund \u2014 einfach, schnell erlernt und die Basis für alles.",
|
||||
"action_tab": "grundkommandos",
|
||||
"action_name": "Sitz",
|
||||
})
|
||||
|
||||
return suggestions[:2] # max 2 Empfehlungen
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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 |
|
|
@ -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>
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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 -->
|
||||
|
|
|
|||
|
|
@ -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', () => {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue