diff --git a/.claude/worktrees/agent-a1140340 b/.claude/worktrees/agent-a1140340 deleted file mode 160000 index 692e6f9..0000000 --- a/.claude/worktrees/agent-a1140340 +++ /dev/null @@ -1 +0,0 @@ -Subproject commit 692e6f937856ace638d9773c17f7447ca439d881 diff --git a/.claude/worktrees/agent-a88ce9b7 b/.claude/worktrees/agent-a88ce9b7 deleted file mode 160000 index a60db21..0000000 --- a/.claude/worktrees/agent-a88ce9b7 +++ /dev/null @@ -1 +0,0 @@ -Subproject commit a60db217827213e019a15bca7c0ab05a5b183275 diff --git a/.claude/worktrees/agent-aa5d905d b/.claude/worktrees/agent-aa5d905d deleted file mode 160000 index 9bd8701..0000000 --- a/.claude/worktrees/agent-aa5d905d +++ /dev/null @@ -1 +0,0 @@ -Subproject commit 9bd8701a1d38e3fdb1823bb936b260d7ec0fe165 diff --git a/backend/database.py b/backend/database.py index 9fe0d24..e1f3405 100644 --- a/backend/database.py +++ b/backend/database.py @@ -444,14 +444,6 @@ 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 deleted file mode 100644 index b47fd5d..0000000 --- a/backend/mailer.py +++ /dev/null @@ -1,148 +0,0 @@ -""" -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 2b2b35c..a115694 100644 --- a/backend/main.py +++ b/backend/main.py @@ -72,7 +72,6 @@ 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"]) @@ -97,7 +96,6 @@ 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 deleted file mode 100644 index b9623b0..0000000 --- a/backend/ratelimit.py +++ /dev/null @@ -1,37 +0,0 @@ -""" -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 501f06a..5bc3abc 100644 --- a/backend/routes/auth.py +++ b/backend/routes/auth.py @@ -92,16 +92,10 @@ async def logout(response: Response): @router.get("/me") async def me(user=Depends(get_current_user)): - 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 + return { + "id": user["id"], + "name": user["name"], + "email": user["email"], + "rolle": user["rolle"], + "is_premium": bool(user["is_premium"]), + } diff --git a/backend/routes/friends.py b/backend/routes/friends.py index 56528dd..273ef1c 100644 --- a/backend/routes/friends.py +++ b/backend/routes/friends.py @@ -30,8 +30,6 @@ 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 @@ -41,7 +39,6 @@ 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 @@ -90,8 +87,6 @@ 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 deleted file mode 100644 index 1be34d4..0000000 --- a/backend/routes/profile.py +++ /dev/null @@ -1,111 +0,0 @@ -"""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 37b0a51..6f0fff1 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 = '87'; // ← bei jedem Deploy mit Frontend-Änderungen erhöhen +const APP_VER = '86'; // ← 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 17a4ff2..61adb19 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], r.avatar_url)} + ${_userAvatar(r.requester_name, r.dogs?.[0])}
${_esc(r.requester_name)} @@ -281,56 +281,39 @@ 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 || '[]'); - const fprofile = JSON.parse(card.dataset.profile || '{}'); - _showProfile(fid, fname, fdogs, fprofile); + const fid = parseInt(card.dataset.friendId); + const fname = card.dataset.friendName; + const fdogs = JSON.parse(card.dataset.dogs || '[]'); + _showProfile(fid, fname, fdogs); }); }); } 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))}">
- - ${_userAvatar(f.friend_name, dogs[0], f.avatar_url)} + + ${_userAvatar(f.friend_name, dogs[0])} - +
-
- - ${_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
` - } + ${_esc(f.friend_name)}
+ ${dogs.length + ? `
+ ${_dogPills(dogs, 3)} +
` + : `
Noch kein Hund eingetragen
` + }
@@ -379,7 +362,7 @@ window.Page_friends = (() => { // ---------------------------------------------------------- // MINI-PROFIL MODAL // ---------------------------------------------------------- - function _showProfile(friendId, friendName, dogs, profile = {}) { + function _showProfile(friendId, friendName, dogs) { const dogsHTML = dogs.length ? `
@@ -406,41 +389,10 @@ 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}
@@ -491,19 +443,12 @@ window.Page_friends = (() => {
- ${_userAvatar(u.name, null, u.avatar_url)} + ${_userAvatar(u.name, null)}
-
- ${_esc(u.name)} - ${_erfahrungSpan(u.erfahrung)} -
- ${_wohnortLine(u.wohnort)} - ${_bioLine(u.bio, u.profil_sichtbarkeit)} +
${_esc(u.name)}
${u.dogs?.length - ? `
+ ? `
${u.dogs.map(d => _esc(d.name) + (d.rasse ? ` · ${_esc(d.rasse)}` : '')).join('  |  ')}
` : ''} @@ -592,12 +537,7 @@ window.Page_friends = (() => { // ---------------------------------------------------------- // RENDER-HELPERS // ---------------------------------------------------------- - function _userAvatar(name, firstDog, avatarUrl) { - if (avatarUrl) { - return `${_esc(name)}`; - } + function _userAvatar(name, firstDog) { 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 43cf9e7..8c54919 100644 --- a/backend/static/js/pages/settings.js +++ b/backend/static/js/pages/settings.js @@ -38,51 +38,17 @@ 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 = `
-
- ${avatarInner} -
- - - -
+ font-size:1.5rem;font-weight:700;flex-shrink:0"> + ${_esc(u.name.charAt(0).toUpperCase())}
-
${_esc(u.name)}
${_esc(u.email)}
@@ -98,53 +64,6 @@ 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?',