diff --git a/.claude/worktrees/agent-a1140340 b/.claude/worktrees/agent-a1140340 new file mode 160000 index 0000000..692e6f9 --- /dev/null +++ b/.claude/worktrees/agent-a1140340 @@ -0,0 +1 @@ +Subproject commit 692e6f937856ace638d9773c17f7447ca439d881 diff --git a/.claude/worktrees/agent-a88ce9b7 b/.claude/worktrees/agent-a88ce9b7 new file mode 160000 index 0000000..a60db21 --- /dev/null +++ b/.claude/worktrees/agent-a88ce9b7 @@ -0,0 +1 @@ +Subproject commit a60db217827213e019a15bca7c0ab05a5b183275 diff --git a/.claude/worktrees/agent-aa5d905d b/.claude/worktrees/agent-aa5d905d new file mode 160000 index 0000000..9bd8701 --- /dev/null +++ b/.claude/worktrees/agent-aa5d905d @@ -0,0 +1 @@ +Subproject commit 9bd8701a1d38e3fdb1823bb936b260d7ec0fe165 diff --git a/backend/database.py b/backend/database.py index e1f3405..9fe0d24 100644 --- a/backend/database.py +++ b/backend/database.py @@ -444,6 +444,14 @@ def _migrate(conn_factory): ("users", "ban_reason", "TEXT"), # WebCal: Kalender-Abo-Token ("users", "calendar_token", "TEXT"), + # User-Profil-Felder + ("users", "email_verified", "INTEGER NOT NULL DEFAULT 0"), + ("users", "bio", "TEXT"), + ("users", "wohnort", "TEXT"), + ("users", "erfahrung", "TEXT"), + ("users", "social_link", "TEXT"), + ("users", "profil_sichtbarkeit", "TEXT NOT NULL DEFAULT 'public'"), + ("users", "avatar_url", "TEXT"), ] with conn_factory() as conn: for table, column, col_type in migrations: diff --git a/backend/mailer.py b/backend/mailer.py new file mode 100644 index 0000000..b47fd5d --- /dev/null +++ b/backend/mailer.py @@ -0,0 +1,148 @@ +""" +BAN YARO — E-Mail-Versand +Unterstützt zwei Backends (wird automatisch gewählt): + 1. Brevo REST-API — wenn BREVO_API_KEY gesetzt (bevorzugt) + 2. SMTP — wenn SMTP_HOST gesetzt (Fallback) +""" + +import os +import smtplib +import asyncio +import logging +from email.mime.text import MIMEText +from email.mime.multipart import MIMEMultipart + +import httpx + +logger = logging.getLogger(__name__) + +# Brevo REST-API +BREVO_API_KEY = os.getenv("BREVO_API_KEY", "") +BREVO_API_URL = "https://api.brevo.com/v3/smtp/email" + +# SMTP Fallback +SMTP_HOST = os.getenv("SMTP_HOST", "") +SMTP_PORT = int(os.getenv("SMTP_PORT", "587")) +SMTP_USER = os.getenv("SMTP_USER", "") +SMTP_PASS = os.getenv("SMTP_PASS", "") + +SMTP_FROM = os.getenv("SMTP_FROM", "Ban Yaro ") +APP_URL = os.getenv("APP_URL", "https://banyaro.app") + + +# ------------------------------------------------------------------ +# Brevo REST-API +# ------------------------------------------------------------------ +async def _send_brevo(to: str, subject: str, html: str, plain: str): + # Absender-Name und -Adresse aus SMTP_FROM parsen + # Format: "Ban Yaro " oder "noreply@banyaro.app" + from_raw = SMTP_FROM + if "<" in from_raw: + from_name = from_raw[:from_raw.index("<")].strip() + from_email = from_raw[from_raw.index("<")+1:from_raw.index(">")].strip() + else: + from_name = "Ban Yaro" + from_email = from_raw.strip() + + payload = { + "sender": {"name": from_name, "email": from_email}, + "to": [{"email": to}], + "subject": subject, + "htmlContent": html, + "textContent": plain, + } + async with httpx.AsyncClient(timeout=15) as client: + resp = await client.post( + BREVO_API_URL, + json=payload, + headers={"api-key": BREVO_API_KEY, "Content-Type": "application/json"}, + ) + resp.raise_for_status() + + +# ------------------------------------------------------------------ +# SMTP Fallback +# ------------------------------------------------------------------ +def _send_smtp_sync(to: str, subject: str, html: str, plain: str): + msg = MIMEMultipart("alternative") + msg["Subject"] = subject + msg["From"] = SMTP_FROM + msg["To"] = to + msg.attach(MIMEText(plain, "plain", "utf-8")) + msg.attach(MIMEText(html, "html", "utf-8")) + + with smtplib.SMTP(SMTP_HOST, SMTP_PORT, timeout=10) as s: + s.ehlo() + s.starttls() + if SMTP_USER: + s.login(SMTP_USER, SMTP_PASS) + s.sendmail(SMTP_FROM, [to], msg.as_string()) + + +# ------------------------------------------------------------------ +# Öffentliche Funktion +# ------------------------------------------------------------------ +async def send_email(to: str, subject: str, html: str, plain: str = ""): + if BREVO_API_KEY: + try: + await _send_brevo(to, subject, html, plain) + logger.info(f"Mail via Brevo gesendet: «{subject}» → {to}") + return + except Exception as e: + logger.error(f"Brevo-Fehler: {e}") + raise + + if SMTP_HOST: + loop = asyncio.get_event_loop() + try: + await loop.run_in_executor(None, _send_smtp_sync, to, subject, html, plain) + logger.info(f"Mail via SMTP gesendet: «{subject}» → {to}") + return + except Exception as e: + logger.error(f"SMTP-Fehler: {e}") + raise + + logger.warning(f"Kein Mail-Backend konfiguriert — Mail NICHT gesendet: «{subject}» → {to}") + + +async def send_verify_email(to: str, name: str, token: str): + url = f"{APP_URL}/api/auth/verify/{token}" + subject = "Ban Yaro — E-Mail-Adresse bestätigen" + + html = f"""\ + + + + +
+

Ban Yaro 🐾

+

Hallo {name},

+

+ bitte bestätige deine E-Mail-Adresse, damit dein Konto vollständig eingerichtet ist. +

+

+ + E-Mail bestätigen + +

+

+ Der Link ist 48 Stunden gültig. +

+

+ Falls du dich nicht bei Ban Yaro registriert hast, ignoriere diese E-Mail einfach. +

+
+ +""" + + plain = ( + f"Ban Yaro — E-Mail-Adresse bestätigen\n\n" + f"Hallo {name},\n\n" + f"bitte bestätige deine E-Mail-Adresse:\n{url}\n\n" + f"Der Link ist 48 Stunden gültig.\n" + ) + + await send_email(to, subject, html, plain) diff --git a/backend/main.py b/backend/main.py index a115694..2b2b35c 100644 --- a/backend/main.py +++ b/backend/main.py @@ -72,6 +72,7 @@ from routes.friends import router as friends_router from routes.chat import router as chat_router from routes.admin import router as admin_router from routes.webcal import router as webcal_router +from routes.profile import router as profile_router app.include_router(auth_router, prefix="/api/auth", tags=["Auth"]) app.include_router(dogs_router, prefix="/api/dogs", tags=["Hunde"]) @@ -96,6 +97,7 @@ 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"]) app.include_router(webcal_router, prefix="/api/webcal", tags=["WebCal"]) +app.include_router(profile_router, prefix="/api/profile", tags=["Profil"]) # ------------------------------------------------------------------ diff --git a/backend/ratelimit.py b/backend/ratelimit.py new file mode 100644 index 0000000..b9623b0 --- /dev/null +++ b/backend/ratelimit.py @@ -0,0 +1,37 @@ +""" +BAN YARO — Rate Limiter +Sliding-Window-Limiter, in-memory (kein Redis nötig für Single-Container). +""" + +import threading +from collections import defaultdict, deque +from datetime import datetime, timedelta + +from fastapi import HTTPException, Request + +_buckets: dict[str, deque] = defaultdict(deque) +_lock = threading.Lock() + + +def check(request: Request, *, max_requests: int, window_seconds: int, key: str = ""): + """ + Wirft HTTP 429 wenn max_requests im Zeitfenster überschritten. + key: optionaler Präfix um verschiedene Limits zu trennen (z.B. 'register', 'login'). + """ + ip = (request.client.host if request.client else "unknown") + bucket_key = f"{key}:{ip}" + now = datetime.utcnow() + cutoff = now - timedelta(seconds=window_seconds) + + with _lock: + dq = _buckets[bucket_key] + # Alte Einträge raus + while dq and dq[0] < cutoff: + dq.popleft() + if len(dq) >= max_requests: + minutes = window_seconds // 60 + raise HTTPException( + 429, + f"Zu viele Versuche. Bitte warte {minutes} Minute(n) und versuche es erneut." + ) + dq.append(now) diff --git a/backend/routes/auth.py b/backend/routes/auth.py index 5bc3abc..501f06a 100644 --- a/backend/routes/auth.py +++ b/backend/routes/auth.py @@ -92,10 +92,16 @@ async def logout(response: Response): @router.get("/me") async def me(user=Depends(get_current_user)): - return { - "id": user["id"], - "name": user["name"], - "email": user["email"], - "rolle": user["rolle"], - "is_premium": bool(user["is_premium"]), - } + with db() as conn: + row = conn.execute( + """SELECT id, name, email, rolle, is_premium, email_verified, + bio, wohnort, erfahrung, social_link, + profil_sichtbarkeit, avatar_url, created_at + FROM users WHERE id=?""", + (user["id"],) + ).fetchone() + if not row: + raise HTTPException(404, "User nicht gefunden.") + data = dict(row) + data["is_premium"] = bool(data["is_premium"]) + return data diff --git a/backend/routes/friends.py b/backend/routes/friends.py index 273ef1c..56528dd 100644 --- a/backend/routes/friends.py +++ b/backend/routes/friends.py @@ -30,6 +30,8 @@ async def list_friends(user=Depends(get_current_user)): 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.bio, u.wohnort, u.erfahrung, u.social_link, + u.profil_sichtbarkeit, u.avatar_url, {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 @@ -39,6 +41,7 @@ async def list_friends(user=Depends(get_current_user)): incoming = conn.execute(f""" SELECT f.id, f.created_at, u.name AS requester_name, u.id AS requester_id, + u.avatar_url, {dogs_sq} AS dogs_json FROM friendships f JOIN users u ON u.id=f.requester_id @@ -87,6 +90,8 @@ async def search_users(q: str = "", user=Depends(get_current_user)): with db() as conn: rows = conn.execute(""" SELECT u.id, u.name, + u.bio, u.wohnort, u.erfahrung, u.social_link, + u.profil_sichtbarkeit, u.avatar_url, (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 diff --git a/backend/routes/profile.py b/backend/routes/profile.py new file mode 100644 index 0000000..1be34d4 --- /dev/null +++ b/backend/routes/profile.py @@ -0,0 +1,111 @@ +"""BAN YARO — User-Profil Routes""" + +import io +import os +import uuid +from typing import Optional + +from fastapi import APIRouter, Depends, HTTPException, UploadFile, File +from pydantic import BaseModel + +from auth import get_current_user +from database import db + +router = APIRouter() +MEDIA_DIR = os.getenv("MEDIA_DIR", "/data/media") + +VALID_ERFAHRUNG = {"einsteiger", "erfahren", "trainer", "zuechter"} +VALID_SICHTBARKEIT = {"public", "friends", "private"} + + +class ProfileUpdate(BaseModel): + bio: Optional[str] = None + wohnort: Optional[str] = None + erfahrung: Optional[str] = None + social_link: Optional[str] = None + profil_sichtbarkeit: Optional[str] = None + + +def _load_user(user_id: int) -> dict: + with db() as conn: + row = conn.execute( + """SELECT id, name, email, rolle, is_premium, email_verified, + bio, wohnort, erfahrung, social_link, + profil_sichtbarkeit, avatar_url, created_at + FROM users WHERE id=?""", + (user_id,) + ).fetchone() + if not row: + raise HTTPException(404, "User nicht gefunden.") + data = dict(row) + data["is_premium"] = bool(data["is_premium"]) + return data + + +@router.patch("") +async def update_profile(data: ProfileUpdate, user=Depends(get_current_user)): + fields = data.model_dump(exclude_none=True) + + # Validierungen + if "erfahrung" in fields and fields["erfahrung"] not in VALID_ERFAHRUNG: + raise HTTPException(400, f"erfahrung muss eines von {sorted(VALID_ERFAHRUNG)} sein.") + if "profil_sichtbarkeit" in fields and fields["profil_sichtbarkeit"] not in VALID_SICHTBARKEIT: + raise HTTPException(400, f"profil_sichtbarkeit muss eines von {sorted(VALID_SICHTBARKEIT)} sein.") + if "bio" in fields and len(fields["bio"]) > 300: + raise HTTPException(400, "bio darf maximal 300 Zeichen lang sein.") + if "wohnort" in fields and len(fields["wohnort"]) > 60: + raise HTTPException(400, "wohnort darf maximal 60 Zeichen lang sein.") + if "social_link" in fields and len(fields["social_link"]) > 120: + raise HTTPException(400, "social_link darf maximal 120 Zeichen lang sein.") + + if not fields: + return _load_user(user["id"]) + + set_clause = ", ".join(f"{k}=?" for k in fields) + values = list(fields.values()) + [user["id"]] + + with db() as conn: + conn.execute( + f"UPDATE users SET {set_clause} WHERE id=?", values + ) + + return _load_user(user["id"]) + + +@router.post("/avatar") +async def upload_avatar( + file: UploadFile = File(...), + user=Depends(get_current_user), +): + # HEIC-Support registrieren falls vorhanden + try: + import pillow_heif + pillow_heif.register_heif_opener() + except ImportError: + pass + + from PIL import Image + + content = await file.read() + try: + img = Image.open(io.BytesIO(content)).convert("RGB") + buf = io.BytesIO() + img.save(buf, format="JPEG", quality=90) + content = buf.getvalue() + except Exception: + pass # Fallback: Originaldaten speichern + + filename = f"avatar_{user['id']}_{uuid.uuid4().hex[:8]}.jpg" + path = os.path.join(MEDIA_DIR, "avatars", filename) + os.makedirs(os.path.dirname(path), exist_ok=True) + + with open(path, "wb") as f: + f.write(content) + + avatar_url = f"/media/avatars/{filename}" + with db() as conn: + conn.execute( + "UPDATE users SET avatar_url=? WHERE id=?", (avatar_url, user["id"]) + ) + + return {"avatar_url": avatar_url} diff --git a/backend/static/js/app.js b/backend/static/js/app.js index 6f0fff1..37b0a51 100644 --- a/backend/static/js/app.js +++ b/backend/static/js/app.js @@ -3,7 +3,7 @@ Router, State-Management, Navigation, Initialisierung. ============================================================ */ -const APP_VER = '86'; // ← bei jedem Deploy mit Frontend-Änderungen erhöhen +const APP_VER = '87'; // ← bei jedem Deploy mit Frontend-Änderungen erhöhen const App = (() => { diff --git a/backend/static/js/pages/friends.js b/backend/static/js/pages/friends.js index 61adb19..17a4ff2 100644 --- a/backend/static/js/pages/friends.js +++ b/backend/static/js/pages/friends.js @@ -187,7 +187,7 @@ window.Page_friends = (() => {
- ${_userAvatar(r.requester_name, r.dogs?.[0])} + ${_userAvatar(r.requester_name, r.dogs?.[0], r.avatar_url)}
${_esc(r.requester_name)} @@ -281,39 +281,56 @@ window.Page_friends = (() => { el.querySelectorAll('.fr-card').forEach(card => { card.addEventListener('click', e => { if (e.target.closest('button')) return; // Buttons nicht überschreiben - const fid = parseInt(card.dataset.friendId); - const fname = card.dataset.friendName; - const fdogs = JSON.parse(card.dataset.dogs || '[]'); - _showProfile(fid, fname, fdogs); + const fid = parseInt(card.dataset.friendId); + const fname = card.dataset.friendName; + const fdogs = JSON.parse(card.dataset.dogs || '[]'); + const fprofile = JSON.parse(card.dataset.profile || '{}'); + _showProfile(fid, fname, fdogs, fprofile); }); }); } function _friendCard(f) { const dogs = f.dogs || []; + const profile = { + bio: f.bio || null, + wohnort: f.wohnort || null, + erfahrung: f.erfahrung || null, + social_link: f.social_link || null, + profil_sichtbarkeit: f.profil_sichtbarkeit || null, + avatar_url: f.avatar_url || null, + }; return `
+ data-dogs="${_esc(JSON.stringify(dogs))}" + data-profile="${_esc(JSON.stringify(profile))}">
- - ${_userAvatar(f.friend_name, dogs[0])} + + ${_userAvatar(f.friend_name, dogs[0], f.avatar_url)} - +
-
- ${_esc(f.friend_name)} + + ${_esc(f.friend_name)} + + ${_erfahrungSpan(f.erfahrung)} +
+ ${_wohnortLine(f.wohnort)} + ${_bioLine(f.bio, f.profil_sichtbarkeit)} +
+ ${dogs.length + ? `
+ ${_dogPills(dogs, 3)} +
` + : `
Noch kein Hund eingetragen
` + }
- ${dogs.length - ? `
- ${_dogPills(dogs, 3)} -
` - : `
Noch kein Hund eingetragen
` - }
@@ -362,7 +379,7 @@ window.Page_friends = (() => { // ---------------------------------------------------------- // MINI-PROFIL MODAL // ---------------------------------------------------------- - function _showProfile(friendId, friendName, dogs) { + function _showProfile(friendId, friendName, dogs, profile = {}) { const dogsHTML = dogs.length ? `
@@ -389,10 +406,41 @@ window.Page_friends = (() => { Noch kein Hund eingetragen.

`; + const profileInfoHTML = (() => { + const parts = []; + if (profile.wohnort) { + parts.push(`
+ 📍 ${_esc(profile.wohnort)} +
`); + } + if (profile.erfahrung && _erfahrungBadge[profile.erfahrung]) { + parts.push(`
+ ${_erfahrungBadge[profile.erfahrung]} +
`); + } + if (profile.bio && profile.profil_sichtbarkeit !== 'private') { + parts.push(`
+ ${_esc(profile.bio)} +
`); + } + if (profile.social_link) { + parts.push(``); + } + if (!parts.length) return ''; + return `
${parts.join('')}
`; + })(); + UI.modal.open({ title: _esc(friendName), body: `
+ ${profileInfoHTML} ${dogsHTML}
@@ -443,12 +491,19 @@ window.Page_friends = (() => {
- ${_userAvatar(u.name, null)} + ${_userAvatar(u.name, null, u.avatar_url)}
-
${_esc(u.name)}
+
+ ${_esc(u.name)} + ${_erfahrungSpan(u.erfahrung)} +
+ ${_wohnortLine(u.wohnort)} + ${_bioLine(u.bio, u.profil_sichtbarkeit)} ${u.dogs?.length - ? `
+ ? `
${u.dogs.map(d => _esc(d.name) + (d.rasse ? ` · ${_esc(d.rasse)}` : '')).join('  |  ')}
` : ''} @@ -537,7 +592,12 @@ window.Page_friends = (() => { // ---------------------------------------------------------- // RENDER-HELPERS // ---------------------------------------------------------- - function _userAvatar(name, firstDog) { + function _userAvatar(name, firstDog, avatarUrl) { + if (avatarUrl) { + return `${_esc(name)}`; + } if (firstDog?.foto_url) { return `${_esc(firstDog.name)}${_erfahrungBadge[erfahrung]}`; + } + + function _wohnortLine(wohnort) { + if (!wohnort) return ''; + return `📍 ${_esc(wohnort)}`; + } + + function _bioLine(bio, sichtbarkeit) { + if (!bio || sichtbarkeit === 'private') return ''; + const text = bio.length > 120 ? bio.substring(0, 120) + '…' : bio; + return `
${_esc(text)}
`; + } + function _dogPills(dogs, max) { if (!dogs?.length) return ''; const visible = dogs.slice(0, max); diff --git a/backend/static/js/pages/settings.js b/backend/static/js/pages/settings.js index 8c54919..43cf9e7 100644 --- a/backend/static/js/pages/settings.js +++ b/backend/static/js/pages/settings.js @@ -38,17 +38,51 @@ window.Page_settings = (() => { // ---------------------------------------------------------- function _renderAccount() { const u = _appState.user; + + // Avatar: Bild oder Buchstabe + const avatarInner = u.avatar_url + ? `Avatar` + : _esc(u.name.charAt(0).toUpperCase()); + + // Mitglied seit + const memberSince = (() => { + if (!u.created_at) return ''; + const d = new Date(u.created_at); + return d.toLocaleDateString('de-DE', { month: 'long', year: 'numeric' }); + })(); + + // Erfahrungs-Labels + const erfahrungLabel = { + einsteiger: 'Einsteiger (erster Hund)', + erfahren: 'Erfahrener Hundehalter', + trainer: 'Trainer / Ausbilder', + zuechter: 'Züchter', + }; + _container.innerHTML = `
-
- ${_esc(u.name.charAt(0).toUpperCase())} + font-size:1.5rem;font-weight:700;flex-shrink:0; + cursor:pointer;overflow:hidden;position:relative"> + ${avatarInner} +
+ + + +
+
${_esc(u.name)}
${_esc(u.email)}
@@ -64,6 +98,53 @@ window.Page_settings = (() => {
+ +
+
+ Mein Profil + +
+
+ ${memberSince + ? `
+ Mitglied seit ${_esc(memberSince)} +
` + : ''} + ${u.bio + ? `
${_esc(u.bio)}
` + : ''} + ${u.wohnort + ? `
+ 📍 ${_esc(u.wohnort)} +
` + : ''} + ${u.erfahrung && erfahrungLabel[u.erfahrung] + ? `
+ ${_esc(erfahrungLabel[u.erfahrung])} +
` + : ''} + ${u.social_link + ? `` + : ''} + ${!u.bio && !u.wohnort && !u.erfahrung && !u.social_link + ? `
+ Noch kein Profil ausgefüllt. +
` + : ''} +
+
+
`; + // Avatar-Hover-Overlay + const avatarBtn = document.getElementById('settings-avatar-btn'); + const avatarOverlay = avatarBtn?.querySelector('.avatar-overlay'); + if (avatarBtn && avatarOverlay) { + avatarBtn.addEventListener('mouseenter', () => { avatarOverlay.style.opacity = '1'; }); + avatarBtn.addEventListener('mouseleave', () => { avatarOverlay.style.opacity = '0'; }); + } + + // Avatar-Upload + avatarBtn?.addEventListener('click', () => { + document.getElementById('settings-avatar-input')?.click(); + }); + document.getElementById('settings-avatar-input')?.addEventListener('change', async e => { + const file = e.target.files?.[0]; + if (!file) return; + try { + const fd = new FormData(); + fd.append('file', file); + const res = await API.post('/api/profile/avatar', fd); + _appState.user.avatar_url = res.avatar_url; + UI.toast.success('Avatar aktualisiert.'); + _render(); + } catch { + UI.toast.error('Avatar-Upload fehlgeschlagen.'); + } + }); + + // Profil bearbeiten + document.getElementById('settings-profile-edit-btn')?.addEventListener('click', () => { + const u = _appState.user; + const inputStyle = `width:100%;box-sizing:border-box;padding:var(--space-2) var(--space-3); + border:1.5px solid var(--c-border);border-radius:var(--radius-md); + font-size:var(--text-sm);font-family:inherit; + background:var(--c-surface);color:var(--c-text)`; + + const erfahrungOpts = [ + ['', 'Bitte wählen...'], + ['einsteiger', 'Einsteiger (erster Hund)'], + ['erfahren', 'Erfahrener Hundehalter'], + ['trainer', 'Trainer / Ausbilder'], + ['zuechter', 'Züchter'], + ].map(([val, label]) => + `` + ).join(''); + + const sichtbarkeitOpts = [ + ['public', 'Öffentlich'], + ['friends', 'Nur Freunde'], + ['private', 'Privat'], + ].map(([val, label]) => + `` + ).join(''); + + UI.modal.open({ + title: 'Profil bearbeiten', + body: ` +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ `, + footer: ` + + + `, + }); + + document.getElementById('profile-form')?.addEventListener('submit', async e => { + e.preventDefault(); + const btn = document.querySelector('[form="profile-form"]'); + const fd = UI.formData(e.target); + await UI.asyncButton(btn, async () => { + const updated = await API.patch('/api/profile', { + bio: fd.bio || '', + wohnort: fd.wohnort || '', + erfahrung: fd.erfahrung || '', + social_link: fd.social_link || '', + profil_sichtbarkeit: fd.profil_sichtbarkeit || 'public', + }); + Object.assign(_appState.user, updated); + UI.modal.close?.(); + UI.toast.success('Profil gespeichert.'); + _render(); + }); + }); + }); + document.getElementById('settings-logout-btn')?.addEventListener('click', async () => { const ok = await UI.modal.confirm({ title : 'Abmelden?',