All tweaks added
Build and push images / build-backend (push) Successful in 33s
Build and push images / build-worker (push) Successful in 32s
Build and push images / build-frontend (push) Failing after 6s

This commit is contained in:
2026-06-06 18:10:35 +01:00
parent 043b3b7269
commit ec5a01d12a
92 changed files with 7517 additions and 784 deletions
+266
View File
@@ -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 &lt;{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 &gt;{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>
)
}