Compare commits
6 commits
41c4ba3dd6
...
596c11d207
| Author | SHA1 | Date | |
|---|---|---|---|
| 596c11d207 | |||
| 3642995409 | |||
| 84253b724f | |||
| 692e6f9378 | |||
| a60db21782 | |||
| 9bd8701a1d |
13 changed files with 636 additions and 34 deletions
1
.claude/worktrees/agent-a1140340
Submodule
1
.claude/worktrees/agent-a1140340
Submodule
|
|
@ -0,0 +1 @@
|
||||||
|
Subproject commit 692e6f937856ace638d9773c17f7447ca439d881
|
||||||
1
.claude/worktrees/agent-a88ce9b7
Submodule
1
.claude/worktrees/agent-a88ce9b7
Submodule
|
|
@ -0,0 +1 @@
|
||||||
|
Subproject commit a60db217827213e019a15bca7c0ab05a5b183275
|
||||||
1
.claude/worktrees/agent-aa5d905d
Submodule
1
.claude/worktrees/agent-aa5d905d
Submodule
|
|
@ -0,0 +1 @@
|
||||||
|
Subproject commit 9bd8701a1d38e3fdb1823bb936b260d7ec0fe165
|
||||||
|
|
@ -444,6 +444,14 @@ def _migrate(conn_factory):
|
||||||
("users", "ban_reason", "TEXT"),
|
("users", "ban_reason", "TEXT"),
|
||||||
# WebCal: Kalender-Abo-Token
|
# WebCal: Kalender-Abo-Token
|
||||||
("users", "calendar_token", "TEXT"),
|
("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:
|
with conn_factory() as conn:
|
||||||
for table, column, col_type in migrations:
|
for table, column, col_type in migrations:
|
||||||
|
|
|
||||||
148
backend/mailer.py
Normal file
148
backend/mailer.py
Normal file
|
|
@ -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 <noreply@banyaro.app>")
|
||||||
|
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 <noreply@banyaro.app>" 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"""\
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="de">
|
||||||
|
<head><meta charset="utf-8"></head>
|
||||||
|
<body style="font-family:sans-serif;background:#f9f9f9;margin:0;padding:0">
|
||||||
|
<div style="max-width:520px;margin:32px auto;background:#fff;border-radius:12px;
|
||||||
|
padding:40px 32px;box-shadow:0 2px 8px rgba(0,0,0,.08)">
|
||||||
|
<h1 style="color:#C4843A;margin:0 0 8px;font-size:24px">Ban Yaro 🐾</h1>
|
||||||
|
<p style="color:#444;margin:0 0 24px">Hallo {name},</p>
|
||||||
|
<p style="color:#444;margin:0 0 24px">
|
||||||
|
bitte bestätige deine E-Mail-Adresse, damit dein Konto vollständig eingerichtet ist.
|
||||||
|
</p>
|
||||||
|
<p style="margin:0 0 32px">
|
||||||
|
<a href="{url}"
|
||||||
|
style="background:#C4843A;color:#fff;padding:14px 28px;border-radius:8px;
|
||||||
|
text-decoration:none;font-weight:700;font-size:15px;display:inline-block">
|
||||||
|
E-Mail bestätigen
|
||||||
|
</a>
|
||||||
|
</p>
|
||||||
|
<p style="color:#888;font-size:13px;margin:0 0 8px">
|
||||||
|
Der Link ist 48 Stunden gültig.
|
||||||
|
</p>
|
||||||
|
<p style="color:#bbb;font-size:12px;margin:0">
|
||||||
|
Falls du dich nicht bei Ban Yaro registriert hast, ignoriere diese E-Mail einfach.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>"""
|
||||||
|
|
||||||
|
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)
|
||||||
|
|
@ -72,6 +72,7 @@ from routes.friends import router as friends_router
|
||||||
from routes.chat import router as chat_router
|
from routes.chat import router as chat_router
|
||||||
from routes.admin import router as admin_router
|
from routes.admin import router as admin_router
|
||||||
from routes.webcal import router as webcal_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(auth_router, prefix="/api/auth", tags=["Auth"])
|
||||||
app.include_router(dogs_router, prefix="/api/dogs", tags=["Hunde"])
|
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(chat_router, prefix="/api/chat", tags=["Chat"])
|
||||||
app.include_router(admin_router, prefix="/api/admin", tags=["Admin"])
|
app.include_router(admin_router, prefix="/api/admin", tags=["Admin"])
|
||||||
app.include_router(webcal_router, prefix="/api/webcal", tags=["WebCal"])
|
app.include_router(webcal_router, prefix="/api/webcal", tags=["WebCal"])
|
||||||
|
app.include_router(profile_router, prefix="/api/profile", tags=["Profil"])
|
||||||
|
|
||||||
|
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
|
|
|
||||||
37
backend/ratelimit.py
Normal file
37
backend/ratelimit.py
Normal file
|
|
@ -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)
|
||||||
|
|
@ -92,10 +92,16 @@ async def logout(response: Response):
|
||||||
|
|
||||||
@router.get("/me")
|
@router.get("/me")
|
||||||
async def me(user=Depends(get_current_user)):
|
async def me(user=Depends(get_current_user)):
|
||||||
return {
|
with db() as conn:
|
||||||
"id": user["id"],
|
row = conn.execute(
|
||||||
"name": user["name"],
|
"""SELECT id, name, email, rolle, is_premium, email_verified,
|
||||||
"email": user["email"],
|
bio, wohnort, erfahrung, social_link,
|
||||||
"rolle": user["rolle"],
|
profil_sichtbarkeit, avatar_url, created_at
|
||||||
"is_premium": bool(user["is_premium"]),
|
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
|
||||||
|
|
|
||||||
|
|
@ -30,6 +30,8 @@ async def list_friends(user=Depends(get_current_user)):
|
||||||
SELECT f.id, f.status, f.created_at,
|
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,
|
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,
|
||||||
|
u.bio, u.wohnort, u.erfahrung, u.social_link,
|
||||||
|
u.profil_sichtbarkeit, u.avatar_url,
|
||||||
{dogs_sq} AS dogs_json
|
{dogs_sq} AS dogs_json
|
||||||
FROM friendships f
|
FROM friendships f
|
||||||
JOIN users u ON u.id = CASE WHEN f.requester_id=? THEN f.addressee_id ELSE f.requester_id END
|
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"""
|
incoming = conn.execute(f"""
|
||||||
SELECT f.id, f.created_at, u.name AS requester_name, u.id AS requester_id,
|
SELECT f.id, f.created_at, u.name AS requester_name, u.id AS requester_id,
|
||||||
|
u.avatar_url,
|
||||||
{dogs_sq} AS dogs_json
|
{dogs_sq} AS dogs_json
|
||||||
FROM friendships f
|
FROM friendships f
|
||||||
JOIN users u ON u.id=f.requester_id
|
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:
|
with db() as conn:
|
||||||
rows = conn.execute("""
|
rows = conn.execute("""
|
||||||
SELECT u.id, u.name,
|
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))
|
(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 dogs d WHERE d.user_id=u.id AND d.is_public=1) AS dogs_json
|
||||||
FROM users u
|
FROM users u
|
||||||
|
|
|
||||||
111
backend/routes/profile.py
Normal file
111
backend/routes/profile.py
Normal file
|
|
@ -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}
|
||||||
|
|
@ -3,7 +3,7 @@
|
||||||
Router, State-Management, Navigation, Initialisierung.
|
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 = (() => {
|
const App = (() => {
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -187,7 +187,7 @@ window.Page_friends = (() => {
|
||||||
<div class="card" style="padding:var(--space-4);margin-bottom:var(--space-3);
|
<div class="card" style="padding:var(--space-4);margin-bottom:var(--space-3);
|
||||||
border-left:3px solid var(--c-primary)">
|
border-left:3px solid var(--c-primary)">
|
||||||
<div style="display:flex;align-items:center;gap:var(--space-3)">
|
<div style="display:flex;align-items:center;gap:var(--space-3)">
|
||||||
${_userAvatar(r.requester_name, r.dogs?.[0])}
|
${_userAvatar(r.requester_name, r.dogs?.[0], r.avatar_url)}
|
||||||
<div style="flex:1;min-width:0">
|
<div style="flex:1;min-width:0">
|
||||||
<div style="font-weight:var(--weight-semibold);color:var(--c-text)">
|
<div style="font-weight:var(--weight-semibold);color:var(--c-text)">
|
||||||
${_esc(r.requester_name)}
|
${_esc(r.requester_name)}
|
||||||
|
|
@ -284,30 +284,46 @@ window.Page_friends = (() => {
|
||||||
const fid = parseInt(card.dataset.friendId);
|
const fid = parseInt(card.dataset.friendId);
|
||||||
const fname = card.dataset.friendName;
|
const fname = card.dataset.friendName;
|
||||||
const fdogs = JSON.parse(card.dataset.dogs || '[]');
|
const fdogs = JSON.parse(card.dataset.dogs || '[]');
|
||||||
_showProfile(fid, fname, fdogs);
|
const fprofile = JSON.parse(card.dataset.profile || '{}');
|
||||||
|
_showProfile(fid, fname, fdogs, fprofile);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function _friendCard(f) {
|
function _friendCard(f) {
|
||||||
const dogs = f.dogs || [];
|
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 `
|
return `
|
||||||
<div class="card fr-card" style="padding:var(--space-4);margin-bottom:var(--space-3);cursor:pointer;
|
<div class="card fr-card" style="padding:var(--space-4);margin-bottom:var(--space-3);cursor:pointer;
|
||||||
transition:box-shadow 0.15s"
|
transition:box-shadow 0.15s"
|
||||||
data-friend-id="${f.friend_id}"
|
data-friend-id="${f.friend_id}"
|
||||||
data-friend-name="${_esc(f.friend_name)}"
|
data-friend-name="${_esc(f.friend_name)}"
|
||||||
data-dogs="${_esc(JSON.stringify(dogs))}">
|
data-dogs="${_esc(JSON.stringify(dogs))}"
|
||||||
|
data-profile="${_esc(JSON.stringify(profile))}">
|
||||||
<div style="display:flex;align-items:center;gap:var(--space-3)">
|
<div style="display:flex;align-items:center;gap:var(--space-3)">
|
||||||
|
|
||||||
<!-- Avatar (erstes Hunde-Foto oder Initiale) -->
|
<!-- Avatar (User-Avatar, erstes Hunde-Foto oder Initiale) -->
|
||||||
${_userAvatar(f.friend_name, dogs[0])}
|
${_userAvatar(f.friend_name, dogs[0], f.avatar_url)}
|
||||||
|
|
||||||
<!-- Name + Hunde -->
|
<!-- Name + Infos + Hunde -->
|
||||||
<div style="flex:1;min-width:0">
|
<div style="flex:1;min-width:0">
|
||||||
<div style="font-weight:var(--weight-semibold);color:var(--c-text);
|
<div style="display:flex;align-items:center;flex-wrap:wrap;gap:2px;
|
||||||
margin-bottom:var(--space-1)">
|
margin-bottom:var(--space-1)">
|
||||||
|
<span style="font-weight:var(--weight-semibold);color:var(--c-text)">
|
||||||
${_esc(f.friend_name)}
|
${_esc(f.friend_name)}
|
||||||
|
</span>
|
||||||
|
${_erfahrungSpan(f.erfahrung)}
|
||||||
</div>
|
</div>
|
||||||
|
${_wohnortLine(f.wohnort)}
|
||||||
|
${_bioLine(f.bio, f.profil_sichtbarkeit)}
|
||||||
|
<div style="margin-top:var(--space-1)">
|
||||||
${dogs.length
|
${dogs.length
|
||||||
? `<div style="display:flex;flex-wrap:wrap;gap:var(--space-1);align-items:center">
|
? `<div style="display:flex;flex-wrap:wrap;gap:var(--space-1);align-items:center">
|
||||||
${_dogPills(dogs, 3)}
|
${_dogPills(dogs, 3)}
|
||||||
|
|
@ -315,6 +331,7 @@ window.Page_friends = (() => {
|
||||||
: `<div style="font-size:var(--text-xs);color:var(--c-text-muted)">Noch kein Hund eingetragen</div>`
|
: `<div style="font-size:var(--text-xs);color:var(--c-text-muted)">Noch kein Hund eingetragen</div>`
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Aktionen -->
|
<!-- Aktionen -->
|
||||||
<div style="display:flex;gap:var(--space-1);flex-shrink:0">
|
<div style="display:flex;gap:var(--space-1);flex-shrink:0">
|
||||||
|
|
@ -362,7 +379,7 @@ window.Page_friends = (() => {
|
||||||
// ----------------------------------------------------------
|
// ----------------------------------------------------------
|
||||||
// MINI-PROFIL MODAL
|
// MINI-PROFIL MODAL
|
||||||
// ----------------------------------------------------------
|
// ----------------------------------------------------------
|
||||||
function _showProfile(friendId, friendName, dogs) {
|
function _showProfile(friendId, friendName, dogs, profile = {}) {
|
||||||
const dogsHTML = dogs.length
|
const dogsHTML = dogs.length
|
||||||
? `<div style="display:grid;grid-template-columns:repeat(auto-fill,minmax(100px,1fr));
|
? `<div style="display:grid;grid-template-columns:repeat(auto-fill,minmax(100px,1fr));
|
||||||
gap:var(--space-3);margin-top:var(--space-4)">
|
gap:var(--space-3);margin-top:var(--space-4)">
|
||||||
|
|
@ -389,10 +406,41 @@ window.Page_friends = (() => {
|
||||||
Noch kein Hund eingetragen.
|
Noch kein Hund eingetragen.
|
||||||
</p>`;
|
</p>`;
|
||||||
|
|
||||||
|
const profileInfoHTML = (() => {
|
||||||
|
const parts = [];
|
||||||
|
if (profile.wohnort) {
|
||||||
|
parts.push(`<div style="display:flex;align-items:center;gap:var(--space-2);
|
||||||
|
font-size:var(--text-sm);color:var(--c-text-secondary)">
|
||||||
|
📍 ${_esc(profile.wohnort)}
|
||||||
|
</div>`);
|
||||||
|
}
|
||||||
|
if (profile.erfahrung && _erfahrungBadge[profile.erfahrung]) {
|
||||||
|
parts.push(`<div style="font-size:var(--text-sm);color:var(--c-text-secondary)">
|
||||||
|
${_erfahrungBadge[profile.erfahrung]}
|
||||||
|
</div>`);
|
||||||
|
}
|
||||||
|
if (profile.bio && profile.profil_sichtbarkeit !== 'private') {
|
||||||
|
parts.push(`<div style="font-size:var(--text-sm);color:var(--c-text);
|
||||||
|
line-height:1.5;padding-top:var(--space-2)">
|
||||||
|
${_esc(profile.bio)}
|
||||||
|
</div>`);
|
||||||
|
}
|
||||||
|
if (profile.social_link) {
|
||||||
|
parts.push(`<div style="font-size:var(--text-xs)">
|
||||||
|
<a href="${_esc(profile.social_link)}" target="_blank" rel="noopener noreferrer"
|
||||||
|
style="color:var(--c-primary)">${_esc(profile.social_link)}</a>
|
||||||
|
</div>`);
|
||||||
|
}
|
||||||
|
if (!parts.length) return '';
|
||||||
|
return `<div style="display:flex;flex-direction:column;gap:var(--space-2);
|
||||||
|
margin-bottom:var(--space-4)">${parts.join('')}</div>`;
|
||||||
|
})();
|
||||||
|
|
||||||
UI.modal.open({
|
UI.modal.open({
|
||||||
title: _esc(friendName),
|
title: _esc(friendName),
|
||||||
body: `
|
body: `
|
||||||
<div>
|
<div>
|
||||||
|
${profileInfoHTML}
|
||||||
<div class="by-section-label">${dogs.length === 1 ? 'Hund' : 'Hunde'}</div>
|
<div class="by-section-label">${dogs.length === 1 ? 'Hund' : 'Hunde'}</div>
|
||||||
${dogsHTML}
|
${dogsHTML}
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -443,12 +491,19 @@ window.Page_friends = (() => {
|
||||||
<div style="display:flex;align-items:center;gap:var(--space-3);
|
<div style="display:flex;align-items:center;gap:var(--space-3);
|
||||||
padding:var(--space-3) var(--space-4);
|
padding:var(--space-3) var(--space-4);
|
||||||
${i < results.length - 1 ? 'border-bottom:1px solid var(--c-border)' : ''}">
|
${i < results.length - 1 ? 'border-bottom:1px solid var(--c-border)' : ''}">
|
||||||
${_userAvatar(u.name, null)}
|
${_userAvatar(u.name, null, u.avatar_url)}
|
||||||
<div style="flex:1;min-width:0">
|
<div style="flex:1;min-width:0">
|
||||||
<div style="font-size:var(--text-sm);font-weight:var(--weight-semibold);
|
<div style="display:flex;align-items:center;flex-wrap:wrap;gap:2px;
|
||||||
color:var(--c-text)">${_esc(u.name)}</div>
|
margin-bottom:2px">
|
||||||
|
<span style="font-size:var(--text-sm);font-weight:var(--weight-semibold);
|
||||||
|
color:var(--c-text)">${_esc(u.name)}</span>
|
||||||
|
${_erfahrungSpan(u.erfahrung)}
|
||||||
|
</div>
|
||||||
|
${_wohnortLine(u.wohnort)}
|
||||||
|
${_bioLine(u.bio, u.profil_sichtbarkeit)}
|
||||||
${u.dogs?.length
|
${u.dogs?.length
|
||||||
? `<div style="font-size:var(--text-xs);color:var(--c-text-secondary)">
|
? `<div style="font-size:var(--text-xs);color:var(--c-text-secondary);
|
||||||
|
margin-top:2px">
|
||||||
${u.dogs.map(d => _esc(d.name) + (d.rasse ? ` · ${_esc(d.rasse)}` : '')).join(' | ')}
|
${u.dogs.map(d => _esc(d.name) + (d.rasse ? ` · ${_esc(d.rasse)}` : '')).join(' | ')}
|
||||||
</div>`
|
</div>`
|
||||||
: ''}
|
: ''}
|
||||||
|
|
@ -537,7 +592,12 @@ window.Page_friends = (() => {
|
||||||
// ----------------------------------------------------------
|
// ----------------------------------------------------------
|
||||||
// RENDER-HELPERS
|
// RENDER-HELPERS
|
||||||
// ----------------------------------------------------------
|
// ----------------------------------------------------------
|
||||||
function _userAvatar(name, firstDog) {
|
function _userAvatar(name, firstDog, avatarUrl) {
|
||||||
|
if (avatarUrl) {
|
||||||
|
return `<img src="${_esc(avatarUrl)}" alt="${_esc(name)}"
|
||||||
|
style="width:44px;height:44px;border-radius:50%;object-fit:cover;
|
||||||
|
border:2px solid var(--c-primary);flex-shrink:0">`;
|
||||||
|
}
|
||||||
if (firstDog?.foto_url) {
|
if (firstDog?.foto_url) {
|
||||||
return `<img src="${_esc(firstDog.foto_url)}" alt="${_esc(firstDog.name)}"
|
return `<img src="${_esc(firstDog.foto_url)}" alt="${_esc(firstDog.name)}"
|
||||||
style="width:44px;height:44px;border-radius:50%;object-fit:cover;
|
style="width:44px;height:44px;border-radius:50%;object-fit:cover;
|
||||||
|
|
@ -553,6 +613,34 @@ window.Page_friends = (() => {
|
||||||
</div>`;
|
</div>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const _erfahrungBadge = {
|
||||||
|
einsteiger: '🐾 Einsteiger',
|
||||||
|
erfahren: '⭐ Erfahren',
|
||||||
|
trainer: '🎓 Trainer',
|
||||||
|
zuechter: '🏅 Züchter',
|
||||||
|
};
|
||||||
|
|
||||||
|
function _erfahrungSpan(erfahrung) {
|
||||||
|
if (!erfahrung || !_erfahrungBadge[erfahrung]) return '';
|
||||||
|
return `<span style="font-size:10px;padding:1px 5px;border-radius:3px;
|
||||||
|
background:var(--c-surface-2);color:var(--c-text-secondary);
|
||||||
|
margin-left:4px;white-space:nowrap">${_erfahrungBadge[erfahrung]}</span>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function _wohnortLine(wohnort) {
|
||||||
|
if (!wohnort) return '';
|
||||||
|
return `<span style="font-size:var(--text-xs);color:var(--c-text-muted)">📍 ${_esc(wohnort)}</span>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function _bioLine(bio, sichtbarkeit) {
|
||||||
|
if (!bio || sichtbarkeit === 'private') return '';
|
||||||
|
const text = bio.length > 120 ? bio.substring(0, 120) + '…' : bio;
|
||||||
|
return `<div style="font-size:var(--text-xs);color:var(--c-text-secondary);
|
||||||
|
margin-top:var(--space-1);line-height:1.4;
|
||||||
|
overflow:hidden;display:-webkit-box;-webkit-line-clamp:2;
|
||||||
|
-webkit-box-orient:vertical">${_esc(text)}</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
function _dogPills(dogs, max) {
|
function _dogPills(dogs, max) {
|
||||||
if (!dogs?.length) return '';
|
if (!dogs?.length) return '';
|
||||||
const visible = dogs.slice(0, max);
|
const visible = dogs.slice(0, max);
|
||||||
|
|
|
||||||
|
|
@ -38,17 +38,51 @@ window.Page_settings = (() => {
|
||||||
// ----------------------------------------------------------
|
// ----------------------------------------------------------
|
||||||
function _renderAccount() {
|
function _renderAccount() {
|
||||||
const u = _appState.user;
|
const u = _appState.user;
|
||||||
|
|
||||||
|
// Avatar: Bild oder Buchstabe
|
||||||
|
const avatarInner = u.avatar_url
|
||||||
|
? `<img src="${_esc(u.avatar_url)}" alt="Avatar"
|
||||||
|
style="width:56px;height:56px;border-radius:50%;object-fit:cover;display:block">`
|
||||||
|
: _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 = `
|
_container.innerHTML = `
|
||||||
<div style="max-width:400px;margin:0 auto;padding:var(--space-4) 0">
|
<div style="max-width:400px;margin:0 auto;padding:var(--space-4) 0">
|
||||||
|
|
||||||
<div class="card" style="padding:var(--space-5);margin-bottom:var(--space-4)">
|
<div class="card" style="padding:var(--space-5);margin-bottom:var(--space-4)">
|
||||||
<div style="display:flex;align-items:center;gap:var(--space-4)">
|
<div style="display:flex;align-items:center;gap:var(--space-4)">
|
||||||
<div style="width:56px;height:56px;border-radius:50%;
|
<div id="settings-avatar-btn"
|
||||||
|
style="width:56px;height:56px;border-radius:50%;
|
||||||
background:var(--c-primary);color:#fff;
|
background:var(--c-primary);color:#fff;
|
||||||
display:flex;align-items:center;justify-content:center;
|
display:flex;align-items:center;justify-content:center;
|
||||||
font-size:1.5rem;font-weight:700;flex-shrink:0">
|
font-size:1.5rem;font-weight:700;flex-shrink:0;
|
||||||
${_esc(u.name.charAt(0).toUpperCase())}
|
cursor:pointer;overflow:hidden;position:relative">
|
||||||
|
${avatarInner}
|
||||||
|
<div style="position:absolute;inset:0;background:rgba(0,0,0,0.25);
|
||||||
|
display:flex;align-items:center;justify-content:center;
|
||||||
|
opacity:0;transition:opacity .15s"
|
||||||
|
class="avatar-overlay">
|
||||||
|
<svg style="width:20px;height:20px;color:#fff" fill="currentColor" viewBox="0 0 256 256">
|
||||||
|
<use href="/icons/phosphor.svg#camera"></use>
|
||||||
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
<input type="file" id="settings-avatar-input" accept="image/*"
|
||||||
|
style="display:none">
|
||||||
<div>
|
<div>
|
||||||
<div style="font-weight:700;font-size:var(--text-lg)">${_esc(u.name)}</div>
|
<div style="font-weight:700;font-size:var(--text-lg)">${_esc(u.name)}</div>
|
||||||
<div style="color:var(--c-text-secondary);font-size:var(--text-sm)">${_esc(u.email)}</div>
|
<div style="color:var(--c-text-secondary);font-size:var(--text-sm)">${_esc(u.email)}</div>
|
||||||
|
|
@ -64,6 +98,53 @@ window.Page_settings = (() => {
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Mein Profil -->
|
||||||
|
<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);
|
||||||
|
display:flex;align-items:center;justify-content:space-between">
|
||||||
|
<span>Mein Profil</span>
|
||||||
|
<button id="settings-profile-edit-btn"
|
||||||
|
class="btn btn-ghost"
|
||||||
|
style="font-size:var(--text-xs);padding:var(--space-1) var(--space-2)">
|
||||||
|
Profil bearbeiten
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div style="padding:var(--space-4);display:flex;flex-direction:column;gap:var(--space-3)">
|
||||||
|
${memberSince
|
||||||
|
? `<div style="font-size:var(--text-sm);color:var(--c-text-secondary)">
|
||||||
|
Mitglied seit ${_esc(memberSince)}
|
||||||
|
</div>`
|
||||||
|
: ''}
|
||||||
|
${u.bio
|
||||||
|
? `<div style="font-size:var(--text-sm)">${_esc(u.bio)}</div>`
|
||||||
|
: ''}
|
||||||
|
${u.wohnort
|
||||||
|
? `<div style="font-size:var(--text-sm);color:var(--c-text-secondary)">
|
||||||
|
📍 ${_esc(u.wohnort)}
|
||||||
|
</div>`
|
||||||
|
: ''}
|
||||||
|
${u.erfahrung && erfahrungLabel[u.erfahrung]
|
||||||
|
? `<div style="font-size:var(--text-sm);color:var(--c-text-secondary)">
|
||||||
|
${_esc(erfahrungLabel[u.erfahrung])}
|
||||||
|
</div>`
|
||||||
|
: ''}
|
||||||
|
${u.social_link
|
||||||
|
? `<div style="font-size:var(--text-sm)">
|
||||||
|
<a href="${_esc(u.social_link)}" target="_blank" rel="noopener"
|
||||||
|
style="color:var(--c-primary)">${_esc(u.social_link)}</a>
|
||||||
|
</div>`
|
||||||
|
: ''}
|
||||||
|
${!u.bio && !u.wohnort && !u.erfahrung && !u.social_link
|
||||||
|
? `<div style="font-size:var(--text-sm);color:var(--c-text-secondary)">
|
||||||
|
Noch kein Profil ausgefüllt.
|
||||||
|
</div>`
|
||||||
|
: ''}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="card" style="margin-bottom:var(--space-4)">
|
<div class="card" style="margin-bottom:var(--space-4)">
|
||||||
<div class="card-body" style="padding:0">
|
<div class="card-body" style="padding:0">
|
||||||
<div class="sidebar-item" data-page="dog-profile"
|
<div class="sidebar-item" data-page="dog-profile"
|
||||||
|
|
@ -147,6 +228,119 @@ window.Page_settings = (() => {
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
|
|
||||||
|
// 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]) =>
|
||||||
|
`<option value="${_esc(val)}" ${u.erfahrung === val ? 'selected' : ''}>${_esc(label)}</option>`
|
||||||
|
).join('');
|
||||||
|
|
||||||
|
const sichtbarkeitOpts = [
|
||||||
|
['public', 'Öffentlich'],
|
||||||
|
['friends', 'Nur Freunde'],
|
||||||
|
['private', 'Privat'],
|
||||||
|
].map(([val, label]) =>
|
||||||
|
`<option value="${_esc(val)}" ${(u.profil_sichtbarkeit || 'public') === val ? 'selected' : ''}>${_esc(label)}</option>`
|
||||||
|
).join('');
|
||||||
|
|
||||||
|
UI.modal.open({
|
||||||
|
title: 'Profil bearbeiten',
|
||||||
|
body: `
|
||||||
|
<form id="profile-form" style="display:flex;flex-direction:column;gap:var(--space-4)">
|
||||||
|
<div>
|
||||||
|
<label style="display:block;font-size:var(--text-sm);font-weight:600;margin-bottom:var(--space-1)">Bio</label>
|
||||||
|
<textarea name="bio" maxlength="300" rows="4"
|
||||||
|
placeholder="Kurze Vorstellung (max. 300 Zeichen)"
|
||||||
|
style="${inputStyle};resize:vertical">${_esc(u.bio || '')}</textarea>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label style="display:block;font-size:var(--text-sm);font-weight:600;margin-bottom:var(--space-1)">Wohnort</label>
|
||||||
|
<input name="wohnort" type="text" maxlength="60"
|
||||||
|
placeholder="z.B. München"
|
||||||
|
value="${_esc(u.wohnort || '')}"
|
||||||
|
style="${inputStyle}">
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label style="display:block;font-size:var(--text-sm);font-weight:600;margin-bottom:var(--space-1)">Erfahrung</label>
|
||||||
|
<select name="erfahrung" style="${inputStyle}">${erfahrungOpts}</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label style="display:block;font-size:var(--text-sm);font-weight:600;margin-bottom:var(--space-1)">Social-Link</label>
|
||||||
|
<input name="social_link" type="url" maxlength="120"
|
||||||
|
placeholder="https://instagram.com/dein-hundeaccount"
|
||||||
|
value="${_esc(u.social_link || '')}"
|
||||||
|
style="${inputStyle}">
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label style="display:block;font-size:var(--text-sm);font-weight:600;margin-bottom:var(--space-1)">Profil-Sichtbarkeit</label>
|
||||||
|
<select name="profil_sichtbarkeit" style="${inputStyle}">${sichtbarkeitOpts}</select>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
`,
|
||||||
|
footer: `
|
||||||
|
<button type="submit" form="profile-form" class="btn btn-primary">Speichern</button>
|
||||||
|
<button type="button" class="btn btn-ghost" data-modal-close>Abbrechen</button>
|
||||||
|
`,
|
||||||
|
});
|
||||||
|
|
||||||
|
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 () => {
|
document.getElementById('settings-logout-btn')?.addEventListener('click', async () => {
|
||||||
const ok = await UI.modal.confirm({
|
const ok = await UI.modal.confirm({
|
||||||
title : 'Abmelden?',
|
title : 'Abmelden?',
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue