import React, { useState, useEffect } from 'react';
import { getUserProfile, updateUserProfile, getUserConfig, updateUserConfig } from '../services/apiService';
const ProfilePage = () => {
const [profile, setProfile] = useState(null);
const [config, setConfig] = useState(null);
const [available, setAvailable] = useState({ llm: [], tts: [], stt: [] });
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [message, setMessage] = useState({ type: '', text: '' });
const [editData, setEditData] = useState({
full_name: '',
username: '',
avatar_url: ''
});
useEffect(() => {
loadData();
}, []);
const loadData = async () => {
try {
setLoading(true);
const [prof, conf] = await Promise.all([
getUserProfile(),
getUserConfig()
]);
setProfile(prof);
setConfig(conf.preferences);
setAvailable({
llm: Object.entries(conf.effective?.llm?.providers || {}).map(([id, p]) => ({ id, label: id, model: p?.model || null })),
tts: Object.entries(conf.effective?.tts?.providers || {}).map(([id, p]) => ({ id, label: id, model: p?.model || null, voice: p?.voice || null })),
stt: Object.entries(conf.effective?.stt?.providers || {}).map(([id, p]) => ({ id, label: id, model: p?.model || null }))
});
setEditData({
full_name: prof.full_name || '',
username: prof.username || '',
avatar_url: prof.avatar_url || ''
});
} catch (err) {
console.error("Failed to load profile data", err);
setMessage({ type: 'error', text: 'Failed to load profile.' });
} finally {
setLoading(false);
}
};
const handleProfileSubmit = async (e) => {
e.preventDefault();
try {
setSaving(true);
const updated = await updateUserProfile(editData);
setProfile(updated);
setMessage({ type: 'success', text: 'Profile updated successfully!' });
setTimeout(() => setMessage({ type: '', text: '' }), 3000);
} catch (err) {
setMessage({ type: 'error', text: 'Failed to update profile.' });
} finally {
setSaving(false);
}
};
const handlePreferenceChange = async (section, providerId) => {
try {
const newConfig = {
...config,
[section]: { ...config[section], active_provider: providerId }
};
await updateUserConfig(newConfig);
setConfig(newConfig);
setMessage({ type: 'success', text: `Primary ${section.toUpperCase()} set to ${providerId}` });
setTimeout(() => setMessage({ type: '', text: '' }), 3000);
} catch (err) {
setMessage({ type: 'error', text: 'Failed to update preferences.' });
}
};
if (loading) {
return (
<div className="flex h-screen items-center justify-center dark:bg-gray-900">
<div className="text-xl text-gray-400 animate-pulse">Loading identity...</div>
</div>
);
}
const inputClass = "w-full border border-gray-300 dark:border-gray-600 rounded-xl p-3 bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-indigo-500 transition-all";
const labelClass = "block text-sm font-bold text-gray-700 dark:text-gray-300 mb-2 ml-1";
return (
<div className="min-h-screen bg-gray-50 dark:bg-gray-900 pt-20 px-4 sm:px-6 lg:px-8">
<div className="max-w-4xl mx-auto space-y-8">
<header className="flex flex-col sm:flex-row items-center gap-6 bg-white dark:bg-gray-800 p-8 rounded-3xl shadow-xl border border-gray-100 dark:border-gray-700 backdrop-blur-sm">
<div className="relative group">
<div className="w-24 h-24 rounded-full bg-indigo-600 flex items-center justify-center text-white text-4xl font-black shadow-lg overflow-hidden border-4 border-white dark:border-gray-700">
{profile.avatar_url ? <img src={profile.avatar_url} alt="Avatar" className="w-full h-full object-cover" /> : profile.email[0].toUpperCase()}
</div>
</div>
<div className="text-center sm:text-left flex-1">
<h1 className="text-3xl font-black text-gray-900 dark:text-white truncate">
{profile.full_name || profile.username || 'Citizen'}
</h1>
<p className="text-indigo-500 font-bold tracking-tight">{profile.email}</p>
<p className="text-[10px] text-gray-400 font-bold uppercase tracking-widest mt-1">Member since {new Date(profile.created_at).toLocaleDateString()}</p>
<div className="mt-4 flex flex-wrap gap-2 justify-center sm:justify-start">
<span className="px-3 py-1 bg-indigo-50 dark:bg-indigo-900/40 text-indigo-700 dark:text-indigo-300 rounded-full text-xs font-black uppercase tracking-widest border border-indigo-100 dark:border-indigo-800">
{profile.role}
</span>
{profile.group_name && (
<span className="px-3 py-1 bg-emerald-50 dark:bg-emerald-900/40 text-emerald-700 dark:text-emerald-300 rounded-full text-xs font-black uppercase tracking-widest border border-emerald-100 dark:border-emerald-800">
{profile.group_name} Group
</span>
)}
</div>
</div>
</header>
{message.text && (
<div className={`p-4 rounded-xl shadow-sm border animate-in fade-in slide-in-from-top-4 duration-300 ${message.type === 'error' ? 'bg-red-50 dark:bg-red-900/30 text-red-700 dark:text-red-400 border-red-200 dark:border-red-800' : 'bg-emerald-50 dark:bg-emerald-900/30 text-emerald-700 dark:text-emerald-400 border-emerald-200 dark:border-emerald-800'}`}>
{message.text}
</div>
)}
<div className="grid grid-cols-1 md:grid-cols-2 gap-8">
{/* General Information */}
<div className="bg-white dark:bg-gray-800 p-8 rounded-3xl shadow-lg border border-gray-100 dark:border-gray-700">
<h2 className="text-xl font-black mb-6 flex items-center gap-3">
<svg className="w-6 h-6 text-indigo-500" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2.5} d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" /></svg>
General Information
</h2>
<form onSubmit={handleProfileSubmit} className="space-y-4">
<div>
<label className={labelClass}>Full Name</label>
<input
className={inputClass}
value={editData.full_name}
onChange={e => setEditData({ ...editData, full_name: e.target.value })}
placeholder="Enter your full name"
/>
</div>
<div>
<label className={labelClass}>Username</label>
<input
className={inputClass}
value={editData.username}
onChange={e => setEditData({ ...editData, username: e.target.value })}
placeholder="Display name"
/>
</div>
<div className="p-4 bg-gray-50 dark:bg-gray-900/50 rounded-xl border border-dashed border-gray-200 dark:border-gray-700">
<label className={labelClass}>Operational Group</label>
<p className="ml-1 text-sm font-bold text-indigo-600 dark:text-indigo-400 uppercase tracking-widest">
{profile.group_name || 'Ungrouped'}
</p>
<p className="ml-1 mt-1 text-[10px] text-gray-400 italic">Groups are managed by your administrator.</p>
</div>
<div className="pt-4">
<button
type="submit"
disabled={saving}
className="w-full py-3 bg-indigo-600 hover:bg-indigo-700 text-white font-black rounded-xl shadow-lg shadow-indigo-200 dark:shadow-indigo-900/40 transition-all active:scale-95 disabled:opacity-50"
>
{saving ? 'Updating...' : 'Save Changes'}
</button>
</div>
</form>
</div>
{/* Service Preferences */}
<div className="bg-white dark:bg-gray-800 p-8 rounded-3xl shadow-lg border border-gray-100 dark:border-gray-700">
<h2 className="text-xl font-black mb-6 flex items-center gap-3">
<svg className="w-6 h-6 text-indigo-500" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2.5} d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z" /><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2.5} d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" /></svg>
Service Preferences
</h2>
<div className="space-y-6">
<ServiceSelect
label="Primary LLM"
section="llm"
providers={available.llm}
active={config?.llm?.active_provider}
statuses={config?.statuses || {}}
onChange={handlePreferenceChange}
/>
<ServiceSelect
label="Primary TTS"
section="tts"
providers={available.tts}
active={config?.tts?.active_provider}
statuses={config?.statuses || {}}
onChange={handlePreferenceChange}
/>
<ServiceSelect
label="Primary STT"
section="stt"
providers={available.stt}
active={config?.stt?.active_provider}
statuses={config?.statuses || {}}
onChange={handlePreferenceChange}
/>
</div>
<div className="mt-6 flex flex-wrap gap-4 items-center p-3 bg-gray-50 dark:bg-gray-800/50 rounded-xl border border-dashed border-gray-200 dark:border-gray-700">
<span className="text-[10px] font-black uppercase tracking-widest text-gray-400">Status:</span>
<div className="flex items-center gap-1.5">
<div className="w-2 h-2 rounded-full bg-emerald-500" />
<span className="text-[10px] font-bold text-gray-500">Verified</span>
</div>
<div className="flex items-center gap-1.5">
<div className="w-2 h-2 rounded-full bg-red-500" />
<span className="text-[10px] font-bold text-gray-500">Failed</span>
</div>
<div className="flex items-center gap-1.5">
<div className="w-2 h-2 rounded-full bg-gray-300" />
<span className="text-[10px] font-bold text-gray-500">Untested</span>
</div>
</div>
<p className="mt-8 text-xs text-gray-400 italic font-medium leading-relaxed">
These selections determine which AI service is used by default when you interact with the hub. Individual session settings may override these.
</p>
</div>
</div>
</div>
</div>
);
};
const ServiceSelect = ({ label, section, providers, active, statuses, onChange }) => {
return (
<div className="space-y-3">
<label className="block text-sm font-bold text-gray-700 dark:text-gray-300 ml-1">{label}</label>
<div className="flex flex-wrap gap-2">
{providers.length === 0 ? (
<p className="text-xs text-gray-500 bg-gray-100 dark:bg-gray-800 p-3 rounded-xl w-full border border-dashed border-gray-300 dark:border-gray-700">No providers configured yet.</p>
) : (
providers.map(p => {
const statusKey = `${section}_${p.id}`;
const status = statuses?.[statusKey];
const statusColor = status === 'success' ? 'bg-emerald-500' : status === 'error' ? 'bg-red-500' : 'bg-gray-300';
const baseType = p.id.split('_')[0];
const suffix = p.id.includes('_') ? p.id.split('_').slice(1).join('_') : '';
const formattedLabel = baseType.charAt(0).toUpperCase() + baseType.slice(1) + (suffix ? ` (${suffix})` : '');
const modelDisplay = p.model || null;
const voiceDisplay = section === 'tts' ? (p.voice || null) : null;
const isActive = active === p.id;
return (
<button
key={p.id}
onClick={() => onChange(section, p.id)}
title={[
modelDisplay && `Model: ${modelDisplay}`,
voiceDisplay && `Voice: ${voiceDisplay}`
].filter(Boolean).join(' · ') || undefined}
className={`group flex flex-col items-start gap-0.5 px-4 py-2 rounded-xl text-xs font-black uppercase tracking-widest border-2 transition-all ${isActive
? 'bg-indigo-600 border-indigo-600 text-white shadow-lg shadow-indigo-200 dark:shadow-none translate-y-[-2px]'
: 'bg-white dark:bg-gray-800 border-gray-200 dark:border-gray-700 text-gray-500 dark:text-gray-400 hover:border-indigo-300 dark:hover:border-indigo-600'
}`}
>
<div className="flex items-center gap-2">
<div className={`w-2 h-2 rounded-full flex-shrink-0 transition-transform group-hover:scale-125 ${statusColor}`} />
<span>{formattedLabel}</span>
</div>
{modelDisplay && (
<span
className={`text-[9px] font-medium normal-case tracking-normal leading-tight pl-4 truncate max-w-[180px] ${isActive ? 'text-indigo-200' : 'text-gray-400 dark:text-gray-500'
}`}
title={modelDisplay}
>
{modelDisplay}
{voiceDisplay && <span className="ml-1 opacity-70">· {voiceDisplay}</span>}
</span>
)}
</button>
);
})
)}
</div>
</div>
);
};
export default ProfilePage;