feat: Enhance profile update functionality with email validation and improved error handling
This commit is contained in:
parent
b7f2e2adbe
commit
c63ec5f700
|
|
@ -28,6 +28,7 @@ class ProfileUpdate(BaseModel):
|
||||||
goal_weight: Optional[float] = None
|
goal_weight: Optional[float] = None
|
||||||
goal_bf_pct: Optional[float] = None
|
goal_bf_pct: Optional[float] = None
|
||||||
quality_filter_level: Optional[str] = None # Issue #31: Global quality filter
|
quality_filter_level: Optional[str] = None # Issue #31: Global quality filter
|
||||||
|
email: Optional[str] = None # Self-service; leer = entfernen; Änderung setzt Verifikation zurück
|
||||||
|
|
||||||
|
|
||||||
# ── Tracking Models ───────────────────────────────────────────────────────────
|
# ── Tracking Models ───────────────────────────────────────────────────────────
|
||||||
|
|
|
||||||
|
|
@ -68,13 +68,62 @@ def get_profile(pid: str, session=Depends(require_auth)):
|
||||||
|
|
||||||
@router.put("/profiles/{pid}")
|
@router.put("/profiles/{pid}")
|
||||||
def update_profile(pid: str, p: ProfileUpdate, session=Depends(require_auth)):
|
def update_profile(pid: str, p: ProfileUpdate, session=Depends(require_auth)):
|
||||||
"""Update profile by ID (admin)."""
|
"""Update profile by ID."""
|
||||||
with get_db() as conn:
|
with get_db() as conn:
|
||||||
data = {k:v for k,v in p.model_dump().items() if v is not None}
|
|
||||||
data['updated'] = datetime.now().isoformat()
|
|
||||||
cur = get_cursor(conn)
|
cur = get_cursor(conn)
|
||||||
cur.execute(f"UPDATE profiles SET {', '.join(f'{k}=%s' for k in data)} WHERE id=%s",
|
cur.execute("SELECT * FROM profiles WHERE id=%s", (pid,))
|
||||||
list(data.values())+[pid])
|
row = cur.fetchone()
|
||||||
|
if not row:
|
||||||
|
raise HTTPException(404, "Profil nicht gefunden")
|
||||||
|
rowd = r2d(row)
|
||||||
|
cur_email_norm = (rowd.get("email") or "").strip().lower()
|
||||||
|
|
||||||
|
patch = p.model_dump(exclude_unset=True)
|
||||||
|
data = {}
|
||||||
|
|
||||||
|
if "email" in patch:
|
||||||
|
ev = patch["email"]
|
||||||
|
if ev is None or (isinstance(ev, str) and ev.strip() == ""):
|
||||||
|
if rowd.get("email") is not None:
|
||||||
|
data["email"] = None
|
||||||
|
data["email_verified"] = False
|
||||||
|
data["verification_token"] = None
|
||||||
|
data["verification_expires"] = None
|
||||||
|
else:
|
||||||
|
email_norm = ev.strip().lower()
|
||||||
|
if "@" not in email_norm or len(email_norm) < 5:
|
||||||
|
raise HTTPException(400, "Ungültige E-Mail-Adresse")
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
SELECT id FROM profiles
|
||||||
|
WHERE email IS NOT NULL AND lower(trim(email)) = %s AND id <> %s
|
||||||
|
""",
|
||||||
|
(email_norm, pid),
|
||||||
|
)
|
||||||
|
if cur.fetchone():
|
||||||
|
raise HTTPException(409, "E-Mail wird bereits verwendet")
|
||||||
|
data["email"] = email_norm
|
||||||
|
if email_norm != cur_email_norm:
|
||||||
|
data["email_verified"] = False
|
||||||
|
data["verification_token"] = None
|
||||||
|
data["verification_expires"] = None
|
||||||
|
|
||||||
|
nullable_keys = {"goal_weight", "goal_bf_pct", "dob"}
|
||||||
|
for k, v in patch.items():
|
||||||
|
if k == "email":
|
||||||
|
continue
|
||||||
|
if v is None and k in nullable_keys:
|
||||||
|
data[k] = None
|
||||||
|
elif v is not None:
|
||||||
|
data[k] = v
|
||||||
|
|
||||||
|
if not data:
|
||||||
|
return get_profile(pid, session)
|
||||||
|
|
||||||
|
data["updated"] = datetime.now().isoformat()
|
||||||
|
cols = ", ".join(f"{k}=%s" for k in data)
|
||||||
|
vals = list(data.values()) + [pid]
|
||||||
|
cur.execute(f"UPDATE profiles SET {cols} WHERE id=%s", vals)
|
||||||
return get_profile(pid, session)
|
return get_profile(pid, session)
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
import { useState, useEffect } from 'react'
|
import { useState, useEffect } from 'react'
|
||||||
import { Save, Download, Upload, Trash2, Plus, Check, Pencil, X, LogOut, Key, BarChart3 } from 'lucide-react'
|
import { Save, Download, Upload, Check, LogOut, Key, BarChart3 } from 'lucide-react'
|
||||||
|
import { Link } from 'react-router-dom'
|
||||||
import { useProfile } from '../context/ProfileContext'
|
import { useProfile } from '../context/ProfileContext'
|
||||||
import { useAuth } from '../context/AuthContext'
|
import { useAuth } from '../context/AuthContext'
|
||||||
import { Avatar } from './ProfileSelect'
|
import { Avatar } from './ProfileSelect'
|
||||||
|
|
@ -9,93 +10,15 @@ import UsageBadge from '../components/UsageBadge'
|
||||||
|
|
||||||
const COLORS = ['#1D9E75','#378ADD','#D85A30','#EF9F27','#7F77DD','#D4537E','#639922','#888780']
|
const COLORS = ['#1D9E75','#378ADD','#D85A30','#EF9F27','#7F77DD','#D4537E','#639922','#888780']
|
||||||
|
|
||||||
function ProfileForm({ profile, onSave, onCancel, title }) {
|
function dobInputValue(dob) {
|
||||||
const [form, setForm] = useState({
|
if (!dob) return ''
|
||||||
name: profile?.name || '',
|
const s = String(dob)
|
||||||
sex: profile?.sex || 'm',
|
return s.length >= 10 ? s.slice(0, 10) : s
|
||||||
dob: profile?.dob || '',
|
|
||||||
height: profile?.height || '',
|
|
||||||
goal_weight: profile?.goal_weight || '',
|
|
||||||
goal_bf_pct: profile?.goal_bf_pct || '',
|
|
||||||
avatar_color: profile?.avatar_color || COLORS[0],
|
|
||||||
})
|
|
||||||
const set = (k,v) => setForm(f=>({...f,[k]:v}))
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div style={{background:'var(--surface2)',borderRadius:10,padding:14,marginTop:8,
|
|
||||||
border:'1.5px solid var(--accent)'}}>
|
|
||||||
{title && <div style={{fontWeight:600,fontSize:14,marginBottom:12,color:'var(--accent)'}}>{title}</div>}
|
|
||||||
|
|
||||||
<div className="form-row">
|
|
||||||
<label className="form-label">Name</label>
|
|
||||||
<input type="text" className="form-input" value={form.name}
|
|
||||||
onChange={e=>set('name',e.target.value)} autoFocus/>
|
|
||||||
<span className="form-unit"/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div style={{marginBottom:12}}>
|
|
||||||
<div style={{fontSize:12,color:'var(--text3)',marginBottom:8}}>Avatar-Farbe</div>
|
|
||||||
<div style={{display:'flex',gap:8,alignItems:'center'}}>
|
|
||||||
<Avatar profile={{...form}} size={36}/>
|
|
||||||
<div style={{display:'flex',gap:6,flexWrap:'wrap'}}>
|
|
||||||
{COLORS.map(c=>(
|
|
||||||
<div key={c} onClick={()=>set('avatar_color',c)}
|
|
||||||
style={{width:26,height:26,borderRadius:'50%',background:c,cursor:'pointer',
|
|
||||||
border:`3px solid ${form.avatar_color===c?'white':'transparent'}`,
|
|
||||||
boxShadow:form.avatar_color===c?`0 0 0 2px ${c}`:'none'}}/>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="form-row">
|
|
||||||
<label className="form-label">Geschlecht</label>
|
|
||||||
<select className="form-select" value={form.sex} onChange={e=>set('sex',e.target.value)}>
|
|
||||||
<option value="m">Männlich</option>
|
|
||||||
<option value="f">Weiblich</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
<div className="form-row">
|
|
||||||
<label className="form-label">Geburtsdatum</label>
|
|
||||||
<input type="date" className="form-input" style={{width:140}} value={form.dob||''}
|
|
||||||
onChange={e=>set('dob',e.target.value)}/>
|
|
||||||
<span className="form-unit"/>
|
|
||||||
</div>
|
|
||||||
<div className="form-row">
|
|
||||||
<label className="form-label">Größe</label>
|
|
||||||
<input type="number" className="form-input" min={100} max={250} value={form.height||''}
|
|
||||||
onChange={e=>set('height',e.target.value)}/>
|
|
||||||
<span className="form-unit">cm</span>
|
|
||||||
</div>
|
|
||||||
<div style={{fontSize:11,fontWeight:600,color:'var(--text3)',textTransform:'uppercase',
|
|
||||||
letterSpacing:'0.04em',margin:'10px 0 6px'}}>Ziele (optional)</div>
|
|
||||||
<div className="form-row">
|
|
||||||
<label className="form-label">Zielgewicht</label>
|
|
||||||
<input type="number" className="form-input" min={30} max={300} step={0.1}
|
|
||||||
value={form.goal_weight||''} onChange={e=>set('goal_weight',e.target.value)} placeholder="–"/>
|
|
||||||
<span className="form-unit">kg</span>
|
|
||||||
</div>
|
|
||||||
<div className="form-row">
|
|
||||||
<label className="form-label">Ziel-KF%</label>
|
|
||||||
<input type="number" className="form-input" min={3} max={50} step={0.1}
|
|
||||||
value={form.goal_bf_pct||''} onChange={e=>set('goal_bf_pct',e.target.value)} placeholder="–"/>
|
|
||||||
<span className="form-unit">%</span>
|
|
||||||
</div>
|
|
||||||
<div style={{display:'flex',gap:8,marginTop:12}}>
|
|
||||||
<button className="btn btn-primary" style={{flex:1}} onClick={()=>onSave(form)}>
|
|
||||||
<Save size={13}/> Speichern
|
|
||||||
</button>
|
|
||||||
<button className="btn btn-secondary" style={{flex:1}} onClick={onCancel}>
|
|
||||||
<X size={13}/> Abbrechen
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function SettingsPage() {
|
export default function SettingsPage() {
|
||||||
const { profiles, activeProfile, setActiveProfile, refreshProfiles } = useProfile()
|
const { profiles, activeProfile, setActiveProfile, refreshProfiles } = useProfile()
|
||||||
const { logout, canExport } = useAuth()
|
const { logout, canExport, isAdmin } = useAuth()
|
||||||
const [pinOpen, setPinOpen] = useState(false)
|
const [pinOpen, setPinOpen] = useState(false)
|
||||||
const [newPin, setNewPin] = useState('')
|
const [newPin, setNewPin] = useState('')
|
||||||
const [pinMsg, setPinMsg] = useState(null)
|
const [pinMsg, setPinMsg] = useState(null)
|
||||||
|
|
@ -129,8 +52,19 @@ export default function SettingsPage() {
|
||||||
setTimeout(()=>setPinMsg(null), 2000)
|
setTimeout(()=>setPinMsg(null), 2000)
|
||||||
} catch(e) { setPinMsg('Fehler beim Speichern') }
|
} catch(e) { setPinMsg('Fehler beim Speichern') }
|
||||||
}
|
}
|
||||||
// editingId: string ID of profile being edited, or 'new' for new profile, or null
|
const [form, setForm] = useState({
|
||||||
const [editingId, setEditingId] = useState(null)
|
name: '',
|
||||||
|
email: '',
|
||||||
|
sex: 'm',
|
||||||
|
dob: '',
|
||||||
|
height: '',
|
||||||
|
goal_weight: '',
|
||||||
|
goal_bf_pct: '',
|
||||||
|
avatar_color: COLORS[0],
|
||||||
|
})
|
||||||
|
const setF = (k, v) => setForm((f) => ({ ...f, [k]: v }))
|
||||||
|
|
||||||
|
const [profileErr, setProfileErr] = useState(null)
|
||||||
const [saved, setSaved] = useState(false)
|
const [saved, setSaved] = useState(false)
|
||||||
const [importing, setImporting] = useState(false)
|
const [importing, setImporting] = useState(false)
|
||||||
const [importMsg, setImportMsg] = useState(null)
|
const [importMsg, setImportMsg] = useState(null)
|
||||||
|
|
@ -200,53 +134,78 @@ export default function SettingsPage() {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!activeProfile) return
|
||||||
|
const sexRaw = activeProfile.sex || 'm'
|
||||||
|
setForm({
|
||||||
|
name: activeProfile.name || '',
|
||||||
|
email: activeProfile.email || '',
|
||||||
|
sex: sexRaw === 'f' ? 'w' : sexRaw,
|
||||||
|
dob: dobInputValue(activeProfile.dob),
|
||||||
|
height: activeProfile.height != null ? String(activeProfile.height) : '',
|
||||||
|
goal_weight: activeProfile.goal_weight != null ? String(activeProfile.goal_weight) : '',
|
||||||
|
goal_bf_pct: activeProfile.goal_bf_pct != null ? String(activeProfile.goal_bf_pct) : '',
|
||||||
|
avatar_color: activeProfile.avatar_color || COLORS[0],
|
||||||
|
})
|
||||||
|
setProfileErr(null)
|
||||||
|
}, [activeProfile?.id])
|
||||||
|
|
||||||
const handleQualityFilterChange = async (level) => {
|
const handleQualityFilterChange = async (level) => {
|
||||||
// Issue #31: Update global quality filter
|
|
||||||
await api.updateActiveProfile({ quality_filter_level: level })
|
await api.updateActiveProfile({ quality_filter_level: level })
|
||||||
await refreshProfiles()
|
await refreshProfiles()
|
||||||
const updated = profiles.find(p => p.id === activeProfile?.id)
|
const updated = profiles.find(p => p.id === activeProfile?.id)
|
||||||
if (updated) setActiveProfile({...updated, quality_filter_level: level})
|
if (updated) setActiveProfile({ ...updated, quality_filter_level: level })
|
||||||
setSaved(true)
|
setSaved(true)
|
||||||
setTimeout(() => setSaved(false), 2000)
|
setTimeout(() => setSaved(false), 2000)
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleSave = async (form, profileId) => {
|
const handleSaveMyProfile = async () => {
|
||||||
const data = {}
|
if (!activeProfile) return
|
||||||
if (form.name) data.name = form.name
|
const name = form.name.trim()
|
||||||
if (form.sex) data.sex = form.sex
|
if (!name) {
|
||||||
if (form.dob) data.dob = form.dob
|
setProfileErr('Bitte einen Namen eingeben.')
|
||||||
if (form.height) data.height = parseFloat(form.height)
|
return
|
||||||
if (form.avatar_color) data.avatar_color = form.avatar_color
|
}
|
||||||
if (form.goal_weight) data.goal_weight = parseFloat(form.goal_weight)
|
const h = parseFloat(form.height)
|
||||||
if (form.goal_bf_pct) data.goal_bf_pct = parseFloat(form.goal_bf_pct)
|
if (!form.height || Number.isNaN(h) || h < 100 || h > 250) {
|
||||||
|
setProfileErr('Bitte eine gültige Größe (100–250 cm) eingeben.')
|
||||||
if (profileId === 'new') {
|
return
|
||||||
const p = await api.createProfile({ ...data, name: form.name || 'Neues Profil' })
|
}
|
||||||
await refreshProfiles()
|
let goal_weight = null
|
||||||
// Don't auto-switch – just close the form
|
if (form.goal_weight !== '') {
|
||||||
} else {
|
goal_weight = parseFloat(form.goal_weight)
|
||||||
await api.updateProfile(profileId, data)
|
if (Number.isNaN(goal_weight)) {
|
||||||
await refreshProfiles()
|
setProfileErr('Zielgewicht: bitte eine gültige Zahl eingeben oder leer lassen.')
|
||||||
// If editing active profile, update it
|
return
|
||||||
if (profileId === activeProfile?.id) {
|
|
||||||
const updated = profiles.find(p => p.id === profileId)
|
|
||||||
if (updated) setActiveProfile({...updated, ...data})
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
setEditingId(null)
|
let goal_bf_pct = null
|
||||||
setSaved(true)
|
if (form.goal_bf_pct !== '') {
|
||||||
setTimeout(() => setSaved(false), 2000)
|
goal_bf_pct = parseFloat(form.goal_bf_pct)
|
||||||
}
|
if (Number.isNaN(goal_bf_pct)) {
|
||||||
|
setProfileErr('Ziel-KF%: bitte eine gültige Zahl eingeben oder leer lassen.')
|
||||||
const handleDelete = async (id) => {
|
return
|
||||||
if (!confirm('Profil und ALLE zugehörigen Daten unwiderruflich löschen?')) return
|
}
|
||||||
await api.deleteProfile(id)
|
}
|
||||||
await refreshProfiles()
|
setProfileErr(null)
|
||||||
if (activeProfile?.id === id) {
|
try {
|
||||||
const remaining = profiles.filter(p => p.id !== id)
|
const payload = {
|
||||||
if (remaining.length) setActiveProfile(remaining[0])
|
name,
|
||||||
|
sex: form.sex,
|
||||||
|
dob: form.dob ? form.dob : null,
|
||||||
|
height: h,
|
||||||
|
avatar_color: form.avatar_color,
|
||||||
|
goal_weight,
|
||||||
|
goal_bf_pct,
|
||||||
|
email: form.email.trim() === '' ? null : form.email.trim(),
|
||||||
|
}
|
||||||
|
await api.updateActiveProfile(payload)
|
||||||
|
await refreshProfiles()
|
||||||
|
setSaved(true)
|
||||||
|
setTimeout(() => setSaved(false), 2000)
|
||||||
|
} catch (e) {
|
||||||
|
setProfileErr(e.message || 'Speichern fehlgeschlagen')
|
||||||
}
|
}
|
||||||
setEditingId(null)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleExportPlaceholders = async () => {
|
const handleExportPlaceholders = async () => {
|
||||||
|
|
@ -270,69 +229,197 @@ export default function SettingsPage() {
|
||||||
<div>
|
<div>
|
||||||
<h1 className="page-title">Einstellungen</h1>
|
<h1 className="page-title">Einstellungen</h1>
|
||||||
|
|
||||||
{/* Profile list */}
|
{/* Aktives Profil (nur eigenes Profil; weitere Profile nur im Admin) */}
|
||||||
<div className="card section-gap">
|
<div className="card section-gap">
|
||||||
<div className="card-title">Profile ({profiles.length})</div>
|
<div className="card-title" style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
|
||||||
|
<Avatar profile={{ ...form, name: form.name || '?' }} size={40} />
|
||||||
{profiles.map(p => (
|
Mein Profil
|
||||||
<div key={p.id}>
|
</div>
|
||||||
<div style={{display:'flex',alignItems:'center',gap:10,padding:'10px 0',
|
<p style={{ fontSize: 13, color: 'var(--text2)', marginBottom: 14, lineHeight: 1.6 }}>
|
||||||
borderBottom:'1px solid var(--border)'}}>
|
Hier bearbeitest du nur das <strong>aktive Profil</strong>. Zum Anlegen weiterer Profile oder zum
|
||||||
<Avatar profile={p} size={40}/>
|
Verwalten anderer Nutzer nutzt du den Admin-Bereich (Zugriff nur als Administrator).
|
||||||
<div style={{flex:1}}>
|
</p>
|
||||||
<div style={{fontSize:14,fontWeight:600}}>{p.name}</div>
|
{isAdmin && (
|
||||||
<div style={{fontSize:11,color:'var(--text3)'}}>
|
<div
|
||||||
{p.sex==='m'?'Männlich':'Weiblich'}
|
style={{
|
||||||
{p.height ? ` · ${p.height} cm` : ''}
|
fontSize: 12,
|
||||||
{p.goal_weight ? ` · Ziel: ${p.goal_weight} kg` : ''}
|
color: 'var(--accent-dark)',
|
||||||
</div>
|
background: 'var(--accent-light)',
|
||||||
</div>
|
padding: '10px 12px',
|
||||||
<div style={{display:'flex',gap:6,alignItems:'center'}}>
|
borderRadius: 8,
|
||||||
{activeProfile?.id === p.id
|
marginBottom: 14,
|
||||||
? <span style={{fontSize:11,color:'var(--accent)',fontWeight:600,padding:'3px 8px',
|
lineHeight: 1.5,
|
||||||
background:'var(--accent-light)',borderRadius:6}}>Aktiv</span>
|
}}
|
||||||
: <button className="btn btn-secondary" style={{padding:'4px 10px',fontSize:12}}
|
>
|
||||||
onClick={handleLogout}>
|
Admin:{' '}
|
||||||
Nutzer wechseln
|
<Link to="/admin/g/users" style={{ fontWeight: 600, color: 'var(--accent-dark)' }}>
|
||||||
</button>
|
Benutzerverwaltung
|
||||||
}
|
</Link>
|
||||||
<button className="btn btn-secondary" style={{padding:'4px 8px'}}
|
|
||||||
onClick={()=>setEditingId(editingId===p.id ? null : p.id)}>
|
|
||||||
<Pencil size={12}/>
|
|
||||||
</button>
|
|
||||||
{profiles.length > 1 && (
|
|
||||||
<button className="btn btn-danger" style={{padding:'4px 8px'}}
|
|
||||||
onClick={()=>handleDelete(p.id)}>
|
|
||||||
<Trash2 size={12}/>
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Edit form – only shown for THIS profile */}
|
|
||||||
{editingId === p.id && (
|
|
||||||
<ProfileForm
|
|
||||||
profile={p}
|
|
||||||
onSave={(form) => handleSave(form, p.id)}
|
|
||||||
onCancel={() => setEditingId(null)}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
))}
|
|
||||||
|
|
||||||
{/* New profile */}
|
|
||||||
{editingId === 'new' ? (
|
|
||||||
<ProfileForm
|
|
||||||
title="Neues Profil"
|
|
||||||
onSave={(form) => handleSave(form, 'new')}
|
|
||||||
onCancel={() => setEditingId(null)}
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<button className="btn btn-secondary btn-full" style={{marginTop:12}}
|
|
||||||
onClick={() => setEditingId('new')}>
|
|
||||||
<Plus size={14}/> Neues Profil anlegen
|
|
||||||
</button>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
<div className="form-row">
|
||||||
|
<label className="form-label">Name</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
className="form-input"
|
||||||
|
value={form.name}
|
||||||
|
onChange={(e) => setF('name', e.target.value)}
|
||||||
|
/>
|
||||||
|
<span className="form-unit" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="form-row">
|
||||||
|
<label className="form-label">E-Mail</label>
|
||||||
|
<input
|
||||||
|
type="email"
|
||||||
|
className="form-input"
|
||||||
|
placeholder="für Login, Recovery & Zusammenfassungen"
|
||||||
|
value={form.email}
|
||||||
|
onChange={(e) => setF('email', e.target.value)}
|
||||||
|
/>
|
||||||
|
<span className="form-unit" />
|
||||||
|
</div>
|
||||||
|
{activeProfile?.email && activeProfile?.email_verified === false && (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
fontSize: 12,
|
||||||
|
color: 'var(--warn-text)',
|
||||||
|
background: 'var(--warn-bg)',
|
||||||
|
padding: '8px 10px',
|
||||||
|
borderRadius: 8,
|
||||||
|
marginBottom: 12,
|
||||||
|
lineHeight: 1.5,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Diese E-Mail ist noch nicht bestätigt. Nach einer Änderung der Adresse ist ggf. erneut eine
|
||||||
|
Bestätigung nötig.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div style={{ marginBottom: 12 }}>
|
||||||
|
<div style={{ fontSize: 12, color: 'var(--text3)', marginBottom: 8 }}>Avatar-Farbe</div>
|
||||||
|
<div style={{ display: 'flex', gap: 8, alignItems: 'center' }}>
|
||||||
|
<Avatar profile={{ ...form, name: form.name || '?' }} size={36} />
|
||||||
|
<div style={{ display: 'flex', gap: 6, flexWrap: 'wrap' }}>
|
||||||
|
{COLORS.map((c) => (
|
||||||
|
<div
|
||||||
|
key={c}
|
||||||
|
role="button"
|
||||||
|
tabIndex={0}
|
||||||
|
onClick={() => setF('avatar_color', c)}
|
||||||
|
onKeyDown={(e) => e.key === 'Enter' && setF('avatar_color', c)}
|
||||||
|
style={{
|
||||||
|
width: 26,
|
||||||
|
height: 26,
|
||||||
|
borderRadius: '50%',
|
||||||
|
background: c,
|
||||||
|
cursor: 'pointer',
|
||||||
|
border: `3px solid ${form.avatar_color === c ? 'white' : 'transparent'}`,
|
||||||
|
boxShadow: form.avatar_color === c ? `0 0 0 2px ${c}` : 'none',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="form-row">
|
||||||
|
<label className="form-label">Geschlecht</label>
|
||||||
|
<select className="form-select" value={form.sex} onChange={(e) => setF('sex', e.target.value)}>
|
||||||
|
<option value="m">Männlich</option>
|
||||||
|
<option value="w">Weiblich</option>
|
||||||
|
<option value="d">Divers</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div className="form-row">
|
||||||
|
<label className="form-label">Geburtsdatum</label>
|
||||||
|
<input
|
||||||
|
type="date"
|
||||||
|
className="form-input"
|
||||||
|
style={{ width: 'auto', minWidth: 140 }}
|
||||||
|
value={form.dob}
|
||||||
|
onChange={(e) => setF('dob', e.target.value)}
|
||||||
|
/>
|
||||||
|
<span className="form-unit" />
|
||||||
|
</div>
|
||||||
|
<div className="form-row">
|
||||||
|
<label className="form-label">Größe</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
className="form-input"
|
||||||
|
min={100}
|
||||||
|
max={250}
|
||||||
|
value={form.height}
|
||||||
|
onChange={(e) => setF('height', e.target.value)}
|
||||||
|
/>
|
||||||
|
<span className="form-unit">cm</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
fontSize: 11,
|
||||||
|
fontWeight: 600,
|
||||||
|
color: 'var(--text3)',
|
||||||
|
textTransform: 'uppercase',
|
||||||
|
letterSpacing: '0.04em',
|
||||||
|
margin: '14px 0 6px',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Ziele (optional, Legacy)
|
||||||
|
</div>
|
||||||
|
<p style={{ fontSize: 12, color: 'var(--text3)', marginBottom: 10, lineHeight: 1.5 }}>
|
||||||
|
Diese Felder bleiben vorerst erhalten; strategische Ziele verwaltest du unter{' '}
|
||||||
|
<Link to="/goals">Analyse → Ziele</Link>.
|
||||||
|
</p>
|
||||||
|
<div className="form-row">
|
||||||
|
<label className="form-label">Zielgewicht</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
className="form-input"
|
||||||
|
min={30}
|
||||||
|
max={300}
|
||||||
|
step={0.1}
|
||||||
|
value={form.goal_weight}
|
||||||
|
onChange={(e) => setF('goal_weight', e.target.value)}
|
||||||
|
placeholder="–"
|
||||||
|
/>
|
||||||
|
<span className="form-unit">kg</span>
|
||||||
|
</div>
|
||||||
|
<div className="form-row">
|
||||||
|
<label className="form-label">Ziel-KF%</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
className="form-input"
|
||||||
|
min={3}
|
||||||
|
max={50}
|
||||||
|
step={0.1}
|
||||||
|
value={form.goal_bf_pct}
|
||||||
|
onChange={(e) => setF('goal_bf_pct', e.target.value)}
|
||||||
|
placeholder="–"
|
||||||
|
/>
|
||||||
|
<span className="form-unit">%</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{profileErr && (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
fontSize: 13,
|
||||||
|
color: '#D85A30',
|
||||||
|
background: '#FCEBEB',
|
||||||
|
padding: '10px 12px',
|
||||||
|
borderRadius: 8,
|
||||||
|
marginBottom: 12,
|
||||||
|
lineHeight: 1.4,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{profileErr}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<button type="button" className="btn btn-primary btn-full" style={{ marginTop: 8 }} onClick={handleSaveMyProfile}>
|
||||||
|
<Save size={14} /> Profil speichern
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Auth actions */}
|
{/* Auth actions */}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user