Newer
Older
cortex-hub / ui / client-app / src / pages / ProfilePage.js
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;