diff --git a/backend/auth.py b/backend/auth.py index 449584b..fad4d47 100644 --- a/backend/auth.py +++ b/backend/auth.py @@ -81,14 +81,19 @@ def get_current_user( user_id = int(payload["sub"]) with db() as conn: row = conn.execute( - "SELECT id, email, name, rolle, is_premium, is_moderator FROM users WHERE id=?", + "SELECT id, email, name, rolle, is_premium, is_moderator, is_banned, ban_reason FROM users WHERE id=?", (user_id,) ).fetchone() if not row: raise HTTPException(status.HTTP_401_UNAUTHORIZED, "User nicht gefunden.") - return dict(row) + user = dict(row) + if user.get("is_banned"): + reason = user.get("ban_reason") or "Kein Grund angegeben." + raise HTTPException(status.HTTP_403_FORBIDDEN, f"Account gesperrt: {reason}") + + return user def get_current_user_optional( diff --git a/backend/database.py b/backend/database.py index 7572c35..93d655a 100644 --- a/backend/database.py +++ b/backend/database.py @@ -439,6 +439,9 @@ def _migrate(conn_factory): # Events: Quelle + externe ID für gescrapte Events ("events", "quelle", "TEXT NOT NULL DEFAULT 'nutzer'"), ("events", "external_id", "TEXT"), + # Admin: User-Sperre + ("users", "is_banned", "INTEGER NOT NULL DEFAULT 0"), + ("users", "ban_reason", "TEXT"), ] with conn_factory() as conn: for table, column, col_type in migrations: @@ -525,6 +528,12 @@ def _migrate(conn_factory): ON events(external_id) WHERE external_id IS NOT NULL; """) + # Users: Eindeutiger Benutzername (case-insensitive via COLLATE NOCASE) + conn.executescript(""" + CREATE UNIQUE INDEX IF NOT EXISTS idx_users_name_unique + ON users(name COLLATE NOCASE); + """) + # Wiki: User-Foto-Einreichungen conn.executescript(""" CREATE TABLE IF NOT EXISTS wiki_foto_submissions ( diff --git a/backend/main.py b/backend/main.py index 6af39f8..1e309dd 100644 --- a/backend/main.py +++ b/backend/main.py @@ -70,6 +70,7 @@ from routes.wiki import router as wiki_router from routes.movies import router as movies_router from routes.friends import router as friends_router from routes.chat import router as chat_router +from routes.admin import router as admin_router app.include_router(auth_router, prefix="/api/auth", tags=["Auth"]) app.include_router(dogs_router, prefix="/api/dogs", tags=["Hunde"]) @@ -92,6 +93,7 @@ app.include_router(wiki_router, prefix="/api/wiki", tags=["Wiki"]) app.include_router(movies_router, prefix="/api/movies", tags=["Filme"]) app.include_router(friends_router, prefix="/api/friends", tags=["Freunde"]) app.include_router(chat_router, prefix="/api/chat", tags=["Chat"]) +app.include_router(admin_router, prefix="/api/admin", tags=["Admin"]) # ------------------------------------------------------------------ diff --git a/backend/routes/admin.py b/backend/routes/admin.py new file mode 100644 index 0000000..fd15441 --- /dev/null +++ b/backend/routes/admin.py @@ -0,0 +1,284 @@ +"""BAN YARO — Admin / Moderator Backend""" +from fastapi import APIRouter, Depends, HTTPException +from pydantic import BaseModel +from typing import Optional +from database import db +from auth import get_current_user + +router = APIRouter() + + +# ------------------------------------------------------------------ +# Dependency: Moderator oder Admin +# ------------------------------------------------------------------ +def require_mod(user=Depends(get_current_user)): + if user["rolle"] not in ("admin", "moderator") and not user.get("is_moderator"): + raise HTTPException(403, "Kein Zugriff.") + return user + +def require_admin(user=Depends(get_current_user)): + if user["rolle"] != "admin": + raise HTTPException(403, "Nur Admins.") + return user + + +# ------------------------------------------------------------------ +# Schemas +# ------------------------------------------------------------------ +class UserPatch(BaseModel): + rolle: Optional[str] = None # user | moderator | admin + is_moderator: Optional[int] = None + is_banned: Optional[int] = None + ban_reason: Optional[str] = None + +class ThreadAdminPatch(BaseModel): + is_pinned: Optional[int] = None + is_locked: Optional[int] = None + is_deleted: Optional[int] = None + + +# ------------------------------------------------------------------ +# GET /api/admin/stats +# ------------------------------------------------------------------ +@router.get("/stats") +async def stats(user=Depends(require_mod)): + with db() as conn: + users_total = conn.execute("SELECT COUNT(*) FROM users").fetchone()[0] + users_today = conn.execute( + "SELECT COUNT(*) FROM users WHERE DATE(created_at)=DATE('now')" + ).fetchone()[0] + threads = conn.execute( + "SELECT COUNT(*) FROM forum_threads WHERE is_deleted=0" + ).fetchone()[0] + posts = conn.execute( + "SELECT COUNT(*) FROM forum_posts WHERE is_deleted=0" + ).fetchone()[0] + open_reports = conn.execute( + "SELECT COUNT(*) FROM forum_reports WHERE resolved=0" + ).fetchone()[0] + banned = conn.execute( + "SELECT COUNT(*) FROM users WHERE is_banned=1" + ).fetchone()[0] + dogs_total = conn.execute("SELECT COUNT(*) FROM dogs").fetchone()[0] + poison_total = conn.execute("SELECT COUNT(*) FROM poison_alerts WHERE status='aktiv'").fetchone()[0] + + return { + "users_total": users_total, + "users_today": users_today, + "threads": threads, + "posts": posts, + "open_reports": open_reports, + "banned": banned, + "dogs_total": dogs_total, + "poison_active":poison_total, + } + + +# ------------------------------------------------------------------ +# GET /api/admin/users +# ------------------------------------------------------------------ +@router.get("/users") +async def list_users( + q: str = "", + rolle: str = "", + limit: int = 50, + offset: int = 0, + user=Depends(require_mod), +): + with db() as conn: + where = "WHERE 1=1" + params = [] + if q.strip(): + where += " AND (u.name LIKE ? OR u.email LIKE ?)" + params.extend([f"%{q.strip()}%", f"%{q.strip()}%"]) + if rolle: + where += " AND u.rolle = ?" + params.append(rolle) + + rows = conn.execute(f""" + SELECT u.id, u.name, u.email, u.rolle, u.is_premium, + 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 + FROM users u + {where} + ORDER BY u.created_at DESC + LIMIT ? OFFSET ? + """, [*params, limit, offset]).fetchall() + + total = conn.execute(f""" + SELECT COUNT(*) FROM users u {where} + """, params).fetchone()[0] + + return {"users": [dict(r) for r in rows], "total": total} + + +# ------------------------------------------------------------------ +# PATCH /api/admin/users/{id} — Rolle, Sperre +# ------------------------------------------------------------------ +@router.patch("/users/{uid}") +async def patch_user(uid: int, data: UserPatch, user=Depends(require_mod)): + # Rollenwechsel nur für Admins + if data.rolle is not None and user["rolle"] != "admin": + raise HTTPException(403, "Rollenwechsel nur für Admins.") + if data.rolle and data.rolle not in ("user", "moderator", "admin"): + raise HTTPException(400, "Ungültige Rolle.") + + with db() as conn: + target = conn.execute("SELECT id, rolle FROM users WHERE id=?", (uid,)).fetchone() + if not target: + raise HTTPException(404, "User nicht gefunden.") + # Mods dürfen keine Admins sperren + if target["rolle"] == "admin" and user["rolle"] != "admin": + raise HTTPException(403, "Admins können nur von Admins verwaltet werden.") + + updates = data.model_dump(exclude_none=True) + if not updates: + raise HTTPException(400, "Keine Änderungen.") + + # is_moderator aus rolle ableiten wenn rolle gesetzt wird + if "rolle" in updates: + updates["is_moderator"] = 1 if updates["rolle"] in ("moderator", "admin") else 0 + + cols = ", ".join(f"{k}=?" for k in updates) + conn.execute(f"UPDATE users SET {cols} WHERE id=?", [*updates.values(), uid]) + row = conn.execute( + "SELECT id, name, email, rolle, is_moderator, is_banned, ban_reason FROM users WHERE id=?", + (uid,) + ).fetchone() + + return dict(row) + + +# ------------------------------------------------------------------ +# DELETE /api/admin/users/{id} — Account löschen (Admin only) +# ------------------------------------------------------------------ +@router.delete("/users/{uid}", status_code=204) +async def delete_user(uid: int, user=Depends(require_admin)): + with db() as conn: + target = conn.execute("SELECT id, rolle FROM users WHERE id=?", (uid,)).fetchone() + if not target: + raise HTTPException(404, "User nicht gefunden.") + if target["id"] == user["id"]: + raise HTTPException(400, "Du kannst deinen eigenen Account nicht löschen.") + conn.execute("DELETE FROM users WHERE id=?", (uid,)) + + +# ------------------------------------------------------------------ +# GET /api/admin/forum/threads — alle Threads inkl. gelöschte +# ------------------------------------------------------------------ +@router.get("/forum/threads") +async def admin_threads( + q: str = "", + deleted: int = 0, + limit: int = 50, + offset: int = 0, + user=Depends(require_mod), +): + with db() as conn: + where = "WHERE 1=1" + params = [] + if not deleted: + where += " AND t.is_deleted=0" + if q.strip(): + where += " AND (t.titel LIKE ? OR t.text LIKE ?)" + params.extend([f"%{q.strip()}%", f"%{q.strip()}%"]) + + rows = conn.execute(f""" + SELECT t.id, t.kategorie, t.titel, SUBSTR(t.text,1,100) AS text_preview, + t.antworten, t.likes, t.views, + t.is_pinned, t.is_locked, t.is_deleted, t.created_at, + u.id AS user_id, u.name AS autor_name + FROM forum_threads t + LEFT JOIN users u ON u.id=t.user_id + {where} + ORDER BY t.created_at DESC + LIMIT ? OFFSET ? + """, [*params, limit, offset]).fetchall() + + total = conn.execute(f""" + SELECT COUNT(*) FROM forum_threads t {where} + """, params).fetchone()[0] + + return {"threads": [dict(r) for r in rows], "total": total} + + +# ------------------------------------------------------------------ +# PATCH /api/admin/forum/threads/{id} +# ------------------------------------------------------------------ +@router.patch("/forum/threads/{tid}") +async def admin_patch_thread(tid: int, data: ThreadAdminPatch, user=Depends(require_mod)): + with db() as conn: + if not conn.execute("SELECT 1 FROM forum_threads WHERE id=?", (tid,)).fetchone(): + raise HTTPException(404, "Thread nicht gefunden.") + updates = data.model_dump(exclude_none=True) + if not updates: + raise HTTPException(400, "Keine Änderungen.") + cols = ", ".join(f"{k}=?" for k in updates) + conn.execute(f"UPDATE forum_threads SET {cols} WHERE id=?", [*updates.values(), tid]) + return {"ok": True} + + +# ------------------------------------------------------------------ +# DELETE /api/admin/forum/threads/{id} +# ------------------------------------------------------------------ +@router.delete("/forum/threads/{tid}", status_code=204) +async def admin_delete_thread(tid: int, user=Depends(require_mod)): + with db() as conn: + if not conn.execute("SELECT 1 FROM forum_threads WHERE id=?", (tid,)).fetchone(): + raise HTTPException(404, "Thread nicht gefunden.") + conn.execute("UPDATE forum_threads SET is_deleted=1 WHERE id=?", (tid,)) + + +# ------------------------------------------------------------------ +# DELETE /api/admin/forum/posts/{id} +# ------------------------------------------------------------------ +@router.delete("/forum/posts/{pid}", status_code=204) +async def admin_delete_post(pid: int, user=Depends(require_mod)): + with db() as conn: + post = conn.execute("SELECT * FROM forum_posts WHERE id=?", (pid,)).fetchone() + if not post: + raise HTTPException(404, "Beitrag nicht gefunden.") + conn.execute("UPDATE forum_posts SET is_deleted=1 WHERE id=?", (pid,)) + conn.execute( + "UPDATE forum_threads SET antworten=MAX(0,antworten-1) WHERE id=?", + (post["thread_id"],) + ) + + +# ------------------------------------------------------------------ +# GET /api/admin/reports — offene Meldungen +# ------------------------------------------------------------------ +@router.get("/reports") +async def admin_reports(user=Depends(require_mod)): + with db() as conn: + rows = conn.execute(""" + SELECT r.id, r.target_type, r.target_id, r.grund, r.resolved, r.created_at, + u.name AS melder_name, + CASE r.target_type + WHEN 'thread' THEN (SELECT t.titel FROM forum_threads t WHERE t.id=r.target_id) + WHEN 'post' THEN (SELECT SUBSTR(p.text,1,80) FROM forum_posts p WHERE p.id=r.target_id) + END AS content_preview + FROM forum_reports r + LEFT JOIN users u ON u.id=r.user_id + ORDER BY r.resolved ASC, r.created_at DESC + LIMIT 100 + """).fetchall() + return [dict(r) for r in rows] + + +# ------------------------------------------------------------------ +# PATCH /api/admin/reports/{id} — erledigen / wiedereröffnen +# ------------------------------------------------------------------ +@router.patch("/reports/{rid}") +async def admin_resolve_report(rid: int, user=Depends(require_mod)): + with db() as conn: + r = conn.execute("SELECT resolved FROM forum_reports WHERE id=?", (rid,)).fetchone() + if not r: + raise HTTPException(404, "Meldung nicht gefunden.") + conn.execute( + "UPDATE forum_reports SET resolved=? WHERE id=?", + (0 if r["resolved"] else 1, rid) + ) + return {"ok": True} diff --git a/backend/routes/auth.py b/backend/routes/auth.py index 6fd851e..5bc3abc 100644 --- a/backend/routes/auth.py +++ b/backend/routes/auth.py @@ -32,20 +32,34 @@ def _set_cookie(response: Response, token: str): @router.post("/register") async def register(data: RegisterRequest, response: Response): + name = data.name.strip() + if len(name) < 2: + raise HTTPException(400, "Name muss mindestens 2 Zeichen lang sein.") + if len(name) > 40: + raise HTTPException(400, "Name darf maximal 40 Zeichen lang sein.") + with db() as conn: if conn.execute("SELECT 1 FROM users WHERE email=?", (data.email,)).fetchone(): raise HTTPException(400, "E-Mail bereits registriert.") - conn.execute( - "INSERT INTO users (email, pw_hash, name) VALUES (?,?,?)", - (data.email, hash_password(data.password), data.name) - ) + if conn.execute( + "SELECT 1 FROM users WHERE name=? COLLATE NOCASE", (name,) + ).fetchone(): + raise HTTPException(400, "Dieser Name ist bereits vergeben. Bitte wähle einen anderen.") + try: + conn.execute( + "INSERT INTO users (email, pw_hash, name) VALUES (?,?,?)", + (data.email, hash_password(data.password), name) + ) + except Exception: + # Fallback falls UNIQUE-Index greift (Race Condition) + raise HTTPException(400, "Dieser Name ist bereits vergeben. Bitte wähle einen anderen.") user = conn.execute( "SELECT id, rolle FROM users WHERE email=?", (data.email,) ).fetchone() token = create_token(user["id"], user["rolle"]) _set_cookie(response, token) - return {"token": token, "name": data.name} + return {"token": token, "name": name} @router.post("/login") diff --git a/backend/routes/friends.py b/backend/routes/friends.py index 27638a5..273ef1c 100644 --- a/backend/routes/friends.py +++ b/backend/routes/friends.py @@ -8,22 +8,38 @@ router = APIRouter() logger = logging.getLogger(__name__) +def _dogs_subquery(): + """JSON-Array der Hunde eines Users als Subquery.""" + return """( + SELECT json_group_array(json_object( + 'id', d.id, + 'name', d.name, + 'rasse', d.rasse, + 'foto_url',d.foto_url + )) + FROM dogs d WHERE d.user_id = u.id + )""" + + @router.get("/") async def list_friends(user=Depends(get_current_user)): uid = user["id"] + dogs_sq = _dogs_subquery() with db() as conn: - friends = conn.execute(""" + friends = conn.execute(f""" SELECT f.id, f.status, f.created_at, CASE WHEN f.requester_id=? THEN f.addressee_id ELSE f.requester_id END AS friend_id, - u.name AS friend_name + u.name AS friend_name, + {dogs_sq} AS dogs_json FROM friendships f JOIN users u ON u.id = CASE WHEN f.requester_id=? THEN f.addressee_id ELSE f.requester_id END WHERE (f.requester_id=? OR f.addressee_id=?) AND f.status='accepted' ORDER BY u.name """, (uid, uid, uid, uid)).fetchall() - incoming = conn.execute(""" - SELECT f.id, f.created_at, u.name AS requester_name, u.id AS requester_id + incoming = conn.execute(f""" + SELECT f.id, f.created_at, u.name AS requester_name, u.id AS requester_id, + {dogs_sq} AS dogs_json FROM friendships f JOIN users u ON u.id=f.requester_id WHERE f.addressee_id=? AND f.status='pending' @@ -38,9 +54,26 @@ async def list_friends(user=Depends(get_current_user)): ORDER BY f.created_at DESC """, (uid,)).fetchall() + import json + + def _parse(rows): + result = [] + for r in rows: + d = dict(r) + if d.get("dogs_json"): + try: + d["dogs"] = json.loads(d["dogs_json"]) + except Exception: + d["dogs"] = [] + else: + d["dogs"] = [] + d.pop("dogs_json", None) + result.append(d) + return result + return { - "friends": [dict(r) for r in friends], - "incoming": [dict(r) for r in incoming], + "friends": _parse(friends), + "incoming": _parse(incoming), "outgoing": [dict(r) for r in outgoing], } @@ -50,9 +83,12 @@ async def search_users(q: str = "", user=Depends(get_current_user)): if len(q.strip()) < 2: return [] uid = user["id"] + import json with db() as conn: rows = conn.execute(""" - SELECT u.id, u.name + SELECT u.id, u.name, + (SELECT json_group_array(json_object('name', d.name, 'rasse', d.rasse)) + FROM dogs d WHERE d.user_id=u.id AND d.is_public=1) AS dogs_json FROM users u WHERE u.id != ? AND u.name LIKE ? @@ -63,7 +99,17 @@ async def search_users(q: str = "", user=Depends(get_current_user)): ) LIMIT 20 """, (uid, f"%{q.strip()}%", uid, uid)).fetchall() - return [dict(r) for r in rows] + + result = [] + for r in rows: + d = dict(r) + try: + d["dogs"] = json.loads(d["dogs_json"]) if d.get("dogs_json") else [] + except Exception: + d["dogs"] = [] + d.pop("dogs_json", None) + result.append(d) + return result @router.post("/request/{target_id}", status_code=201) diff --git a/backend/routes/health.py b/backend/routes/health.py index 6bb2592..125cfd8 100644 --- a/backend/routes/health.py +++ b/backend/routes/health.py @@ -12,7 +12,7 @@ router = APIRouter() MEDIA_DIR = os.getenv("MEDIA_DIR", "/data/media") # Erlaubte Typen -TYPEN = {"impfung", "entwurmung", "tierarzt", "medikament", "gewicht", "allergie", "dokument"} +TYPEN = {"impfung", "entwurmung", "tierarzt", "medikament", "gewicht", "allergie", "dokument", "laeufigkeit"} # ------------------------------------------------------------------ diff --git a/backend/routes/ki.py b/backend/routes/ki.py index dd83ed0..3799823 100644 --- a/backend/routes/ki.py +++ b/backend/routes/ki.py @@ -1,3 +1,62 @@ -"""BAN YARO — ki Routes (Stub, wird ausgebaut)""" -from fastapi import APIRouter +"""BAN YARO — KI Routes""" +from fastapi import APIRouter, HTTPException +from pydantic import BaseModel +from typing import Optional +import ki as ki_module + router = APIRouter() + + +class TrainingRequest(BaseModel): + problem: str + rasse: Optional[str] = None + alter: Optional[str] = None + + +@router.post("/training") +async def ki_training(req: TrainingRequest): + """ + KI-Trainingsberatung für individuelle Verhaltens- und Trainingsprobleme. + Kostenlos für alle (nutzt lokales Modell). + """ + if not req.problem or len(req.problem.strip()) < 10: + raise HTTPException(400, "Bitte beschreibe das Problem genauer.") + if len(req.problem) > 1000: + raise HTTPException(400, "Beschreibung zu lang (max. 1000 Zeichen).") + + rasse = req.rasse or "unbekannt" + alter = req.alter or "unbekannt" + + system = ( + "Du bist ein erfahrener, zertifizierter Hundetrainer mit Schwerpunkt " + "auf positiver Verstärkung und gewaltfreier Erziehung. " + "Antworte immer auf Deutsch, konkret, verständlich und motivierend. " + "Gib keine Ratschläge die Schmerz oder Zwang beinhalten. " + "Wenn das Problem schwerwiegend ist (Aggression, starke Angst), " + "empfehle professionellen Hundetrainer vor Ort zusätzlich." + ) + + prompt = f"""Hund: {rasse}, {alter} alt. + +Problem: {req.problem.strip()} + +Bitte gib: +1. Eine kurze Einschätzung des Problems (1-2 Sätze) +2. 3-5 konkrete Trainingsschritte die ich heute starten kann +3. Was ich vermeiden sollte +4. Wann ich einen Profi hinzuziehen sollte (falls relevant) + +Schreibe klar und strukturiert, ohne unnötigen Fachjargon.""" + + try: + result = await ki_module.complete( + prompt=prompt, + system=system, + max_tokens=600, + requires_premium=False, + ) + return {"antwort": result} + except ki_module.KIUnavailableError as e: + raise HTTPException(503, str(e)) + except Exception as e: + raise HTTPException(500, "KI momentan nicht verfügbar.") diff --git a/backend/static/css/components.css b/backend/static/css/components.css index 17d8b89..25906b3 100644 --- a/backend/static/css/components.css +++ b/backend/static/css/components.css @@ -163,7 +163,69 @@ } /* ------------------------------------------------------------ - 3. BADGES & STATUS-PILLS + 3. BY-TABS — Einheitliche Tab/Filter-Navigation (app-weit) + ------------------------------------------------------------ */ +.by-tabs { + display: flex; + gap: var(--space-2); + overflow-x: auto; + flex-wrap: nowrap; + scrollbar-width: none; + -webkit-overflow-scrolling: touch; +} +.by-tabs::-webkit-scrollbar { display: none; } + +.by-tab { + flex-shrink: 0; + padding: var(--space-2) var(--space-3); + border: 1.5px solid var(--c-border); + border-radius: var(--radius-full); + background: var(--c-surface); + color: var(--c-text-secondary); + font-size: var(--text-sm); + font-weight: var(--weight-medium); + cursor: pointer; + white-space: nowrap; + transition: all var(--transition-fast); + touch-action: manipulation; +} +.by-tab.active { + background: var(--c-primary); + border-color: var(--c-primary); + color: var(--c-text-inverse); +} +.by-tab:hover:not(.active) { + border-color: var(--c-primary); + color: var(--c-primary); +} + +/* ------------------------------------------------------------ + 4. BY-SECTION-LABEL + BY-TOOLBAR — weitere gemeinsame Elemente + ------------------------------------------------------------ */ + +/* Kleines Überschriften-Label über Gruppen ("Aktuelle Medikamente" etc.) */ +.by-section-label { + font-size: var(--text-sm); + font-weight: var(--weight-semibold); + color: var(--c-text-secondary); + text-transform: uppercase; + letter-spacing: 0.05em; + padding: var(--space-3) 0 var(--space-1); +} + +/* Toolbar-Leiste oben auf einer Seite (Background + Border + Flex) */ +.by-toolbar { + display: flex; + align-items: center; + gap: var(--space-2); + padding: var(--space-3) var(--space-4); + background: var(--c-surface); + border-bottom: 1px solid var(--c-border); + flex-shrink: 0; +} + +/* ------------------------------------------------------------ + 5. BADGES & STATUS-PILLS ------------------------------------------------------------ */ .badge { display: inline-flex; @@ -863,12 +925,8 @@ textarea.form-control { GESUNDHEIT ============================================================ */ -/* Header mit KI-Button */ -.health-header { - display: flex; - justify-content: flex-end; - padding: var(--space-3) 0 var(--space-2); -} +/* .health-header → by-toolbar with flex-end override */ +.health-header { justify-content: flex-end; padding: var(--space-3) 0 var(--space-2); background: none; border-bottom: none; } /* Tab-Leiste — Mobile: horizontal scrollbar, Desktop: umbrechen */ .health-tabs { @@ -878,36 +936,7 @@ textarea.form-control { padding-bottom: var(--space-2); margin-bottom: var(--space-3); } -/* Auf sehr kleinen Screens: scrollen statt umbrechen */ -@media (max-width: 480px) { - .health-tabs { - flex-wrap: nowrap; - overflow-x: auto; - padding-right: var(--space-4); - scrollbar-width: none; - } - .health-tabs::-webkit-scrollbar { display: none; } -} - -.health-tab { - flex-shrink: 0; - padding: var(--space-2) var(--space-3); - border: 2px solid var(--c-border); - border-radius: var(--radius-full); - background: var(--c-surface); - color: var(--c-text-secondary); - font-size: var(--text-sm); - font-weight: var(--weight-medium); - cursor: pointer; - white-space: nowrap; - transition: all var(--transition-fast); - touch-action: manipulation; -} -.health-tab.active { - background: var(--c-primary); - border-color: var(--c-primary); - color: var(--c-text-inverse); -} +/* .health-tabs / .health-tab → now use .by-tabs / .by-tab */ /* Karten-Liste */ .health-list { @@ -954,15 +983,7 @@ textarea.form-control { .ampel-text-yellow { color: #d97706; } .ampel-text-red { color: #dc2626; } -/* Gruppen-Label (z.B. "Aktuelle Medikamente") */ -.health-group-label { - font-size: var(--text-sm); - font-weight: var(--weight-semibold); - color: var(--c-text-secondary); - text-transform: uppercase; - letter-spacing: 0.05em; - padding: var(--space-3) 0 var(--space-1); -} +/* .health-group-label → now uses .by-section-label */ /* Gewicht-Diagramm-Wrapper */ .health-chart-wrap { @@ -1464,10 +1485,10 @@ textarea.form-control { background: var(--c-bg); } .rk-header { - background: var(--c-surface); + background: var(--c-surface); border-bottom: 1px solid var(--c-border-light); - padding: var(--space-3) var(--space-4); - flex-shrink: 0; + padding: var(--space-3) var(--space-4); + flex-shrink: 0; } .rk-search-row { display: flex; @@ -2118,15 +2139,18 @@ textarea.form-control { /* ------------------------------------------------------------ GASSI-TREFFEN (walks.js) ------------------------------------------------------------ */ -.walks-toolbar { - display: flex; - align-items: center; - gap: var(--space-2); - padding: var(--space-3) var(--space-4); - background: var(--c-surface); - border-bottom: 1px solid var(--c-border); - flex-shrink: 0; +.walks-layout { + display: flex; + flex-direction: column; + height: 100%; + overflow: hidden; } +.walks-content { + flex: 1; + overflow-y: auto; + overflow-x: hidden; +} +/* .walks-toolbar → now uses .by-toolbar */ .walks-view-toggle { display: flex; gap: var(--space-1); @@ -2158,13 +2182,7 @@ textarea.form-control { flex-direction: column; gap: var(--space-3); } -.walks-section-label { - font-size: var(--text-sm); - font-weight: var(--weight-semibold); - color: var(--c-text-secondary); - padding: var(--space-1) 0; - margin-bottom: var(--space-1); -} +/* .walks-section-label → now uses .by-section-label */ .walks-card { background: var(--c-surface); border-radius: var(--radius-lg); @@ -2239,15 +2257,7 @@ textarea.form-control { /* ------------------------------------------------------------ EVENTS (events.js) ------------------------------------------------------------ */ -.events-toolbar { - display: flex; - align-items: center; - gap: var(--space-2); - padding: var(--space-3) var(--space-4); - background: var(--c-surface); - border-bottom: 1px solid var(--c-border); - flex-shrink: 0; -} +/* .events-toolbar → now uses .by-toolbar */ .events-view-toggle { display: flex; gap: var(--space-1); @@ -2272,33 +2282,12 @@ textarea.form-control { box-shadow: var(--shadow-xs); } .events-filter-bar { - display: flex; - gap: var(--space-2); - padding: var(--space-2) var(--space-4); - overflow-x: auto; - background: var(--c-surface); - border-bottom: 1px solid var(--c-border); - flex-shrink: 0; - scrollbar-width: none; -} -.events-filter-bar::-webkit-scrollbar { display: none; } -.events-filter-btn { - padding: var(--space-1) var(--space-3); - border-radius: var(--radius-full); - border: 1.5px solid var(--c-border); + padding: var(--space-2) var(--space-4); background: var(--c-surface); - color: var(--c-text-secondary); - font-size: var(--text-sm); - cursor: pointer; - white-space: nowrap; - transition: all 0.15s; + border-bottom: 1px solid var(--c-border); flex-shrink: 0; } -.events-filter-btn.active { - background: var(--c-primary); - color: #fff; - border-color: var(--c-primary); -} +/* .events-filter-btn → now uses .by-tab */ .events-list { flex: 1; overflow-y: auto; @@ -2398,42 +2387,12 @@ textarea.form-control { } /* Quelle-Filter-Leiste */ .events-source-bar { - display: flex; - gap: var(--space-2); - padding: var(--space-2) var(--space-4); - overflow-x: auto; - background: var(--c-bg); - border-bottom: 1px solid var(--c-border); - flex-shrink: 0; - scrollbar-width: none; -} -.events-source-bar::-webkit-scrollbar { display: none; } -.events-source-btn { - display: flex; - align-items: center; - gap: var(--space-1); - padding: var(--space-1) var(--space-3); - border-radius: var(--radius-full); - border: 1.5px solid var(--c-border); - background: var(--c-surface); - color: var(--c-text-secondary); - font-size: var(--text-sm); - white-space: nowrap; - cursor: pointer; - transition: border-color 0.15s, background 0.15s, color 0.15s; -} -.events-source-btn.active, -.events-source-btn:hover { - border-color: var(--c-primary); - background: var(--c-primary-soft, #e8f0fe); - color: var(--c-primary); -} -.events-source-vdh.active, -.events-source-vdh:hover { - border-color: #1a4fa0; - background: #e8eef8; - color: #1a4fa0; + padding: var(--space-2) var(--space-4); + background: var(--c-bg); + border-bottom: 1px solid var(--c-border); + flex-shrink: 0; } +/* .events-source-btn → now uses .by-tab */ /* Events-Karten: Aktions-Zeile */ .events-card-actions { margin-top: var(--space-1); @@ -2465,28 +2424,19 @@ textarea.form-control { /* ------------------------------------------------------------ SITTING (sitting.js) ------------------------------------------------------------ */ +.sitting-layout { + display: flex; + flex-direction: column; + height: 100%; + overflow: hidden; +} .sitting-tabs { - display: flex; + padding: var(--space-3) var(--space-4) var(--space-2); border-bottom: 1px solid var(--c-border); - background: var(--c-surface); - flex-shrink: 0; -} -.sitting-tab { - flex: 1; - padding: var(--space-3); - border: none; - background: transparent; - color: var(--c-text-secondary); - font-size: var(--text-sm); - font-weight: var(--weight-medium); - cursor: pointer; - border-bottom: 2px solid transparent; - transition: all 0.15s; -} -.sitting-tab.active { - color: var(--c-primary); - border-bottom-color: var(--c-primary); + background: var(--c-surface); + flex-shrink: 0; } +/* .sitting-tab → now uses .by-tab */ .sitting-content { flex: 1; overflow-y: auto; @@ -2566,12 +2516,7 @@ textarea.form-control { gap: var(--space-4); } .sitting-profil-fact { font-size: var(--text-sm); color: var(--c-text-secondary); } -.sitting-section-label { - font-size: var(--text-sm); - font-weight: var(--weight-semibold); - color: var(--c-text-secondary); - margin-bottom: var(--space-2); -} +/* .sitting-section-label → now uses .by-section-label */ .sitting-request-card { background: var(--c-surface); border-radius: var(--radius-lg); @@ -2707,23 +2652,7 @@ textarea.form-control { flex-wrap: wrap; } -.forum-tab { - background: var(--c-surface-2); - border: 1px solid var(--c-border); - border-radius: var(--radius-full); - padding: var(--space-1) var(--space-3); - font-size: var(--text-sm); - color: var(--c-text-secondary); - cursor: pointer; - transition: all var(--transition-fast); - white-space: nowrap; -} -.forum-tab:hover { background: var(--c-surface-3); } -.forum-tab.active { - background: var(--c-primary); - border-color: var(--c-primary); - color: #fff; -} +/* .forum-tab → now uses .by-tab */ .forum-list-inner { display: flex; flex-direction: column; gap: var(--space-3); } @@ -2853,16 +2782,10 @@ textarea.form-control { align-items: center; } -/* Category tabs — horizontal scroll */ +/* Category tabs — extends .by-tabs with extra bottom padding */ .forum-category-tabs { - display: flex; - gap: var(--space-1); - overflow-x: auto; - scrollbar-width: none; - flex-wrap: nowrap; padding-bottom: var(--space-1); } -.forum-category-tabs::-webkit-scrollbar { display: none; } /* Category badge (colored pill) */ .forum-category-badge { @@ -3229,39 +3152,7 @@ textarea.form-control { WIKI ============================================================ */ -/* Tab-Bar */ -.wiki-tab-bar { - display: flex; - gap: var(--space-1); - overflow-x: auto; - padding: var(--space-3) 0 var(--space-2); - scrollbar-width: none; - -webkit-overflow-scrolling: touch; -} -.wiki-tab-bar::-webkit-scrollbar { display: none; } - -.wiki-tab-btn { - flex-shrink: 0; - padding: var(--space-2) var(--space-3); - border: 1.5px solid var(--c-border); - border-radius: var(--radius-full); - background: var(--c-surface); - color: var(--c-text-secondary); - font-size: var(--text-sm); - font-weight: var(--weight-medium); - cursor: pointer; - white-space: nowrap; - transition: background var(--transition-fast), color var(--transition-fast), border-color var(--transition-fast); -} -.wiki-tab-btn.active { - background: var(--c-primary); - border-color: var(--c-primary); - color: #fff; -} -.wiki-tab-btn:hover:not(.active) { - border-color: var(--c-primary); - color: var(--c-primary); -} +/* .wiki-tab-bar / .wiki-tab-btn → now use .by-tabs / .by-tab */ /* Search */ .wiki-search-wrap { @@ -4349,7 +4240,7 @@ textarea.form-control { color: #fff; font-size: var(--text-xs); font-weight: var(--weight-bold); - border-radius: 99px; + border-radius: var(--radius-full); padding: 1px 6px; min-width: 18px; text-align: center; diff --git a/backend/static/css/design-system.css b/backend/static/css/design-system.css index 1130a2a..15f8df2 100644 --- a/backend/static/css/design-system.css +++ b/backend/static/css/design-system.css @@ -43,9 +43,13 @@ --c-success-subtle: #EBF4E7; --c-warning: #D4923A; --c-warning-subtle: #FDF3E3; + --c-amber: #E4A020; /* Goldgelb — "Heute"-Akzent, distinct von Primary */ --c-info: #4A7A9B; --c-info-subtle: #E8F2F8; + /* Primär-Akzentfläche (für Hover-Hintergründe über Primary) */ + --c-primary-soft: #FDF0E3; + /* Typografie */ --font-sans: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif; --font-mono: "SF Mono", "Fira Code", Consolas, monospace; diff --git a/backend/static/css/layout.css b/backend/static/css/layout.css index ba1555f..e0ef7c1 100644 --- a/backend/static/css/layout.css +++ b/backend/static/css/layout.css @@ -129,6 +129,32 @@ height: 100%; } +/* Gassi-Treffen + Sitting: volle Höhe, internes Scroll */ +#page-walks, +#page-sitting { + height: 100%; + overflow: hidden; +} +#page-walks > .page-body, +#page-sitting > .page-body { + padding: 0 !important; + gap: 0 !important; + overflow: hidden; + height: 100%; +} + +/* Routen: volle Höhe damit .rk-layout height:100% auflöst und + das Grid intern scrollt (nicht die gesamte Seite via #page-content) */ +#page-routes { + height: 100%; + overflow: hidden; +} +#page-routes > .page-body { + padding: 0 !important; + overflow: hidden; + height: 100%; +} + /* ------------------------------------------------------------ 3. BOTTOM NAVIGATION (Mobile) ------------------------------------------------------------ */ diff --git a/backend/static/index.html b/backend/static/index.html index 70164f4..f773267 100644 --- a/backend/static/index.html +++ b/backend/static/index.html @@ -22,8 +22,8 @@ - - + +
@@ -33,7 +33,7 @@