All tweaks added
This commit is contained in:
@@ -0,0 +1,266 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import api from '../utils/api'
|
||||
import { useAuthStore } from '../hooks/useAuth'
|
||||
|
||||
function Section({ title, children }) {
|
||||
return (
|
||||
<div className="bg-gray-900 rounded-xl border border-gray-800 p-5 space-y-4">
|
||||
<h2 className="text-sm font-semibold text-gray-300">{title}</h2>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function Field({ label, hint, children }) {
|
||||
return (
|
||||
<div>
|
||||
<label className="text-xs text-gray-400 block mb-1">{label}</label>
|
||||
{children}
|
||||
{hint && <p className="text-xs text-gray-600 mt-1">{hint}</p>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function Input({ type = 'text', value, onChange, placeholder, min, max }) {
|
||||
return (
|
||||
<input type={type} value={value} onChange={onChange} placeholder={placeholder} min={min} max={max}
|
||||
className="w-full bg-gray-800 border border-gray-700 rounded-lg px-3 py-2.5 text-sm text-white focus:outline-none focus:ring-2 focus:ring-blue-500" />
|
||||
)
|
||||
}
|
||||
|
||||
function SaveButton({ onClick, loading, saved, label = 'Save' }) {
|
||||
return (
|
||||
<div className="flex items-center gap-3 pt-1">
|
||||
<button onClick={onClick} disabled={loading}
|
||||
className="bg-blue-600 hover:bg-blue-700 disabled:opacity-50 text-white text-sm font-medium px-4 py-2 rounded-lg transition-colors">
|
||||
{loading ? 'Saving…' : label}
|
||||
</button>
|
||||
{saved && <span className="text-green-400 text-sm">✓ Saved</span>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default function ProfilePage() {
|
||||
const qc = useQueryClient()
|
||||
const { user } = useAuthStore()
|
||||
|
||||
const { data: profile } = useQuery({
|
||||
queryKey: ['profile'],
|
||||
queryFn: () => api.get('/profile/').then(r => r.data),
|
||||
})
|
||||
|
||||
const { data: pocketidConfig } = useQuery({
|
||||
queryKey: ['pocketid-config'],
|
||||
queryFn: () => api.get('/profile/pocketid-config').then(r => r.data),
|
||||
enabled: !!user?.is_admin,
|
||||
})
|
||||
|
||||
// HR / measurements form
|
||||
const [hrForm, setHrForm] = useState({ max_heart_rate: '', resting_heart_rate: '', birth_year: '', height_cm: '' })
|
||||
const [hrSaved, setHrSaved] = useState(false)
|
||||
useEffect(() => {
|
||||
if (profile) setHrForm({
|
||||
max_heart_rate: profile.max_heart_rate || '',
|
||||
resting_heart_rate: profile.resting_heart_rate || '',
|
||||
birth_year: profile.birth_year || '',
|
||||
height_cm: profile.height_cm || '',
|
||||
})
|
||||
}, [profile])
|
||||
|
||||
const updateProfile = useMutation({
|
||||
mutationFn: data => api.patch('/profile/', data).then(r => r.data),
|
||||
onSuccess: () => { qc.invalidateQueries({ queryKey: ['profile'] }); setHrSaved(true); setTimeout(() => setHrSaved(false), 3000) },
|
||||
})
|
||||
|
||||
// Weight log
|
||||
const { data: weightLog } = useQuery({
|
||||
queryKey: ['weight-log'],
|
||||
queryFn: () => api.get('/profile/weight').then(r => r.data),
|
||||
})
|
||||
const [weightForm, setWeightForm] = useState({ weight_kg: '', body_fat_pct: '', date: new Date().toISOString().slice(0, 16) })
|
||||
const [weightSaved, setWeightSaved] = useState(false)
|
||||
const addWeight = useMutation({
|
||||
mutationFn: data => api.post('/profile/weight', data).then(r => r.data),
|
||||
onSuccess: () => { qc.invalidateQueries({ queryKey: ['weight-log'] }); setWeightSaved(true); setTimeout(() => setWeightSaved(false), 3000); setWeightForm(f => ({ ...f, weight_kg: '', body_fat_pct: '' })) },
|
||||
})
|
||||
const deleteWeight = useMutation({
|
||||
mutationFn: id => api.delete(`/profile/weight/${id}`),
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: ['weight-log'] }),
|
||||
})
|
||||
|
||||
// Password change
|
||||
const [pwForm, setPwForm] = useState({ current_password: '', new_password: '', confirm: '' })
|
||||
const [pwError, setPwError] = useState('')
|
||||
const [pwSaved, setPwSaved] = useState(false)
|
||||
const changePassword = useMutation({
|
||||
mutationFn: data => api.post('/profile/change-password', data).then(r => r.data),
|
||||
onSuccess: () => { setPwSaved(true); setPwForm({ current_password: '', new_password: '', confirm: '' }); setTimeout(() => setPwSaved(false), 3000) },
|
||||
onError: e => setPwError(e.response?.data?.detail || 'Failed to change password'),
|
||||
})
|
||||
|
||||
// PocketID config
|
||||
const [pidForm, setPidForm] = useState({ issuer: '', client_id: '', client_secret: '' })
|
||||
const [pidSaved, setPidSaved] = useState(false)
|
||||
useEffect(() => {
|
||||
if (pocketidConfig) setPidForm({ issuer: pocketidConfig.issuer || '', client_id: pocketidConfig.client_id || '', client_secret: '' })
|
||||
}, [pocketidConfig])
|
||||
const savePocketID = useMutation({
|
||||
mutationFn: data => api.post('/profile/pocketid-config', data).then(r => r.data),
|
||||
onSuccess: () => { qc.invalidateQueries({ queryKey: ['pocketid-config'] }); setPidSaved(true); setTimeout(() => setPidSaved(false), 3000) },
|
||||
})
|
||||
|
||||
const effectiveMaxHr = profile?.max_heart_rate || profile?.estimated_max_hr
|
||||
|
||||
return (
|
||||
<div className="p-6 max-w-2xl space-y-6">
|
||||
<h1 className="text-2xl font-bold text-white">Profile & Settings</h1>
|
||||
|
||||
{/* HR & Measurements */}
|
||||
<Section title="Heart Rate & Measurements">
|
||||
<div className="bg-blue-950/30 border border-blue-900/40 rounded-lg p-3 text-xs text-gray-400">
|
||||
Max HR is used for accurate zone calculations. Set it from your hardest recorded effort or a lab test.
|
||||
{effectiveMaxHr && (
|
||||
<div className="mt-2 text-white">
|
||||
Effective max HR: <strong>{effectiveMaxHr} bpm</strong>
|
||||
{!profile?.max_heart_rate && ' (estimated from age)'}
|
||||
{' · '}Zones: Z1 <{Math.round(effectiveMaxHr * 0.6)}, Z2 {Math.round(effectiveMaxHr * 0.6)}–{Math.round(effectiveMaxHr * 0.7)}, Z3 {Math.round(effectiveMaxHr * 0.7)}–{Math.round(effectiveMaxHr * 0.8)}, Z4 {Math.round(effectiveMaxHr * 0.8)}–{Math.round(effectiveMaxHr * 0.9)}, Z5 >{Math.round(effectiveMaxHr * 0.9)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<Field label="Max heart rate (bpm)" hint="Best from a sprint test or hard race">
|
||||
<Input type="number" value={hrForm.max_heart_rate} placeholder="e.g. 185" min={100} max={250}
|
||||
onChange={e => setHrForm(f => ({ ...f, max_heart_rate: e.target.value }))} />
|
||||
</Field>
|
||||
<Field label="Resting heart rate (bpm)" hint="First thing in the morning">
|
||||
<Input type="number" value={hrForm.resting_heart_rate} placeholder="e.g. 52" min={20} max={120}
|
||||
onChange={e => setHrForm(f => ({ ...f, resting_heart_rate: e.target.value }))} />
|
||||
</Field>
|
||||
<Field label="Birth year" hint="Used to estimate max HR if not set above">
|
||||
<Input type="number" value={hrForm.birth_year} placeholder="e.g. 1988" min={1920} max={2010}
|
||||
onChange={e => setHrForm(f => ({ ...f, birth_year: e.target.value }))} />
|
||||
</Field>
|
||||
<Field label="Height (cm)">
|
||||
<Input type="number" value={hrForm.height_cm} placeholder="e.g. 178" min={50} max={300}
|
||||
onChange={e => setHrForm(f => ({ ...f, height_cm: e.target.value }))} />
|
||||
</Field>
|
||||
</div>
|
||||
|
||||
<SaveButton
|
||||
onClick={() => updateProfile.mutate(Object.fromEntries(
|
||||
Object.entries(hrForm).filter(([,v]) => v !== '').map(([k,v]) => [k, parseFloat(v)])
|
||||
))}
|
||||
loading={updateProfile.isPending}
|
||||
saved={hrSaved}
|
||||
/>
|
||||
</Section>
|
||||
|
||||
{/* Weight log */}
|
||||
<Section title="Weight Log">
|
||||
<div className="grid grid-cols-3 gap-3">
|
||||
<Field label="Weight (kg)">
|
||||
<Input type="number" value={weightForm.weight_kg} placeholder="75.5" min={20} max={500}
|
||||
onChange={e => setWeightForm(f => ({ ...f, weight_kg: e.target.value }))} />
|
||||
</Field>
|
||||
<Field label="Body fat % (optional)">
|
||||
<Input type="number" value={weightForm.body_fat_pct} placeholder="18.5" min={1} max={70}
|
||||
onChange={e => setWeightForm(f => ({ ...f, body_fat_pct: e.target.value }))} />
|
||||
</Field>
|
||||
<Field label="Date">
|
||||
<Input type="datetime-local" value={weightForm.date}
|
||||
onChange={e => setWeightForm(f => ({ ...f, date: e.target.value }))} />
|
||||
</Field>
|
||||
</div>
|
||||
<SaveButton
|
||||
onClick={() => addWeight.mutate({
|
||||
weight_kg: parseFloat(weightForm.weight_kg),
|
||||
body_fat_pct: weightForm.body_fat_pct ? parseFloat(weightForm.body_fat_pct) : null,
|
||||
date: new Date(weightForm.date).toISOString(),
|
||||
})}
|
||||
loading={addWeight.isPending}
|
||||
saved={weightSaved}
|
||||
label="Log weight"
|
||||
/>
|
||||
|
||||
{weightLog && weightLog.length > 0 && (
|
||||
<div className="mt-2">
|
||||
<p className="text-xs text-gray-500 mb-2">Recent entries</p>
|
||||
<div className="space-y-1 max-h-48 overflow-y-auto">
|
||||
{weightLog.slice(0, 20).map(entry => (
|
||||
<div key={entry.id} className="flex items-center justify-between py-1.5 border-b border-gray-800/50 text-sm">
|
||||
<span className="text-gray-500 text-xs">{new Date(entry.date).toLocaleDateString('en-GB', { day: 'numeric', month: 'short', year: 'numeric' })}</span>
|
||||
<span className="text-white font-medium">{entry.weight_kg.toFixed(1)} kg</span>
|
||||
{entry.body_fat_pct && <span className="text-gray-400 text-xs">{entry.body_fat_pct.toFixed(1)}% fat</span>}
|
||||
<button onClick={() => deleteWeight.mutate(entry.id)}
|
||||
className="text-gray-700 hover:text-red-400 text-xs transition-colors">✕</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Section>
|
||||
|
||||
{/* Password change */}
|
||||
<Section title="Change Password">
|
||||
<div className="space-y-3">
|
||||
<Field label="Current password">
|
||||
<Input type="password" value={pwForm.current_password}
|
||||
onChange={e => { setPwForm(f => ({ ...f, current_password: e.target.value })); setPwError('') }} />
|
||||
</Field>
|
||||
<Field label="New password (min 8 characters)">
|
||||
<Input type="password" value={pwForm.new_password}
|
||||
onChange={e => setPwForm(f => ({ ...f, new_password: e.target.value }))} />
|
||||
</Field>
|
||||
<Field label="Confirm new password">
|
||||
<Input type="password" value={pwForm.confirm}
|
||||
onChange={e => setPwForm(f => ({ ...f, confirm: e.target.value }))} />
|
||||
</Field>
|
||||
{pwError && <p className="text-red-400 text-xs">{pwError}</p>}
|
||||
</div>
|
||||
<SaveButton
|
||||
onClick={() => {
|
||||
if (pwForm.new_password !== pwForm.confirm) { setPwError('Passwords do not match'); return }
|
||||
changePassword.mutate({ current_password: pwForm.current_password, new_password: pwForm.new_password })
|
||||
}}
|
||||
loading={changePassword.isPending}
|
||||
saved={pwSaved}
|
||||
label="Change password"
|
||||
/>
|
||||
</Section>
|
||||
|
||||
{/* PocketID — admin only */}
|
||||
{user?.is_admin && (
|
||||
<Section title="🔑 PocketID Passkey Authentication (Admin)">
|
||||
<p className="text-xs text-gray-500">
|
||||
Configure passkey authentication via PocketID. Once set, a "Sign in with passkey" button appears on the login page.
|
||||
</p>
|
||||
<div className="space-y-3">
|
||||
<Field label="PocketID issuer URL" hint="e.g. https://auth.yourdomain.com">
|
||||
<Input value={pidForm.issuer} placeholder="https://auth.example.com"
|
||||
onChange={e => setPidForm(f => ({ ...f, issuer: e.target.value }))} />
|
||||
</Field>
|
||||
<Field label="Client ID">
|
||||
<Input value={pidForm.client_id} placeholder="milevault"
|
||||
onChange={e => setPidForm(f => ({ ...f, client_id: e.target.value }))} />
|
||||
</Field>
|
||||
<Field label="Client secret" hint="Leave blank to keep existing secret">
|
||||
<Input type="password" value={pidForm.client_secret} placeholder="••••••••"
|
||||
onChange={e => setPidForm(f => ({ ...f, client_secret: e.target.value }))} />
|
||||
</Field>
|
||||
{pocketidConfig?.enabled && (
|
||||
<p className="text-xs text-green-400">✓ PocketID is currently active</p>
|
||||
)}
|
||||
</div>
|
||||
<SaveButton
|
||||
onClick={() => savePocketID.mutate(pidForm)}
|
||||
loading={savePocketID.isPending}
|
||||
saved={pidSaved}
|
||||
label="Save PocketID config"
|
||||
/>
|
||||
</Section>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user