Spaces:
Sleeping
Sleeping
| import React, { useState, useEffect } from 'react'; | |
| import { motion, AnimatePresence } from 'framer-motion'; | |
| import { supabase } from '../../supabaseClient'; | |
| import FullProfileOverlay from '../FullProfileOverlay'; | |
| // βββ Icons βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| const ClusterIcon = () => ( | |
| <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"> | |
| <circle cx="12" cy="12" r="3" /><circle cx="4" cy="6" r="3" /><circle cx="20" cy="6" r="3" /> | |
| <circle cx="4" cy="18" r="3" /><circle cx="20" cy="18" r="3" /> | |
| <line x1="12" y1="9" x2="4" y2="7" /><line x1="12" y1="9" x2="20" y2="7" /> | |
| <line x1="12" y1="15" x2="4" y2="17" /><line x1="12" y1="15" x2="20" y2="17" /> | |
| </svg> | |
| ); | |
| const UsersIcon = () => ( | |
| <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"> | |
| <path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2" /><circle cx="9" cy="7" r="4" /> | |
| <path d="M23 21v-2a4 4 0 0 0-3-3.87" /><path d="M16 3.13a4 4 0 0 1 0 7.75" /> | |
| </svg> | |
| ); | |
| const SearchIcon = () => ( | |
| <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"> | |
| <circle cx="11" cy="11" r="8" /><line x1="21" y1="21" x2="16.65" y2="16.65" /> | |
| </svg> | |
| ); | |
| const ChevronDown = ({ open }) => ( | |
| <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" | |
| style={{ transform: open ? 'rotate(180deg)' : 'rotate(0deg)', transition: 'transform 0.3s ease' }}> | |
| <polyline points="6 9 12 15 18 9" /> | |
| </svg> | |
| ); | |
| const XIcon = () => ( | |
| <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"> | |
| <line x1="18" y1="6" x2="6" y2="18" /><line x1="6" y1="6" x2="18" y2="18" /> | |
| </svg> | |
| ); | |
| // βββ Cluster colour palette βββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| const CLUSTER_COLORS = [ | |
| { accent: '#EF4444', glow: 'rgba(239,68,68,0.15)', border: 'rgba(239,68,68,0.3)' }, | |
| { accent: '#8B5CF6', glow: 'rgba(139,92,246,0.15)', border: 'rgba(139,92,246,0.3)' }, | |
| { accent: '#06B6D4', glow: 'rgba(6,182,212,0.15)', border: 'rgba(6,182,212,0.3)' }, | |
| { accent: '#10B981', glow: 'rgba(16,185,129,0.15)', border: 'rgba(16,185,129,0.3)' }, | |
| { accent: '#F59E0B', glow: 'rgba(245,158,11,0.15)', border: 'rgba(245,158,11,0.3)' }, | |
| { accent: '#EC4899', glow: 'rgba(236,72,153,0.15)', border: 'rgba(236,72,153,0.3)' }, | |
| ]; | |
| const getColor = (idx) => CLUSTER_COLORS[idx % CLUSTER_COLORS.length]; | |
| // βββ Profile Card βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| const ProfileCard = ({ profile, accent, onView }) => { | |
| const [hovered, setHovered] = useState(false); | |
| const skills = Array.isArray(profile.technical_skills) | |
| ? profile.technical_skills.slice(0, 4) | |
| : typeof profile.technical_skills === 'string' | |
| ? profile.technical_skills.split(',').slice(0, 4).map(s => s.trim()) | |
| : []; | |
| return ( | |
| <motion.div | |
| onMouseEnter={() => setHovered(true)} | |
| onMouseLeave={() => setHovered(false)} | |
| whileHover={{ y: -4, scale: 1.01 }} | |
| onClick={() => onView(profile)} | |
| style={{ | |
| backgroundColor: hovered ? 'rgba(255,255,255,0.06)' : 'rgba(255,255,255,0.03)', | |
| border: `1px solid ${hovered ? accent : 'rgba(255,255,255,0.08)'}`, | |
| borderRadius: '12px', | |
| padding: '1rem', | |
| cursor: 'pointer', | |
| transition: 'border-color 0.2s', | |
| boxShadow: hovered ? `0 4px 20px ${accent}30` : 'none', | |
| }} | |
| > | |
| <div style={{ display: 'flex', alignItems: 'center', gap: '0.75rem', marginBottom: '0.6rem' }}> | |
| <img | |
| src={profile.avatar_url || `https://ui-avatars.com/api/?name=${encodeURIComponent(profile.full_name || 'User')}&background=random&size=48`} | |
| alt={profile.full_name} | |
| style={{ width: 40, height: 40, borderRadius: '50%', objectFit: 'cover', border: `2px solid ${accent}55` }} | |
| /> | |
| <div> | |
| <p style={{ fontWeight: '700', color: '#fff', fontSize: '0.9rem', marginBottom: 2 }}>{profile.full_name || 'Unknown'}</p> | |
| <p style={{ fontSize: '0.75rem', color: '#94a3b8' }}>{profile.headline || profile.role || 'β'}</p> | |
| </div> | |
| </div> | |
| <p style={{ fontSize: '0.75rem', color: '#64748b', marginBottom: '0.5rem' }}> | |
| {profile.experience_years ? `${profile.experience_years} yrs exp` : 'No experience listed'} | |
| </p> | |
| {skills.length > 0 && ( | |
| <div style={{ display: 'flex', flexWrap: 'wrap', gap: '0.3rem' }}> | |
| {skills.map((s, i) => ( | |
| <span key={i} style={{ | |
| fontSize: '0.7rem', padding: '2px 8px', borderRadius: '4px', | |
| backgroundColor: `${accent}20`, color: accent, | |
| border: `1px solid ${accent}40` | |
| }}>{s}</span> | |
| ))} | |
| </div> | |
| )} | |
| </motion.div> | |
| ); | |
| }; | |
| // βββ Cluster Card βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| const ClusterCard = ({ label, profiles, colorIdx, searchQuery, onViewProfile }) => { | |
| const [expanded, setExpanded] = useState(true); | |
| const color = getColor(colorIdx); | |
| const filtered = profiles.filter(p => { | |
| const q = searchQuery.toLowerCase(); | |
| return ( | |
| (p.full_name || '').toLowerCase().includes(q) || | |
| (p.headline || '').toLowerCase().includes(q) || | |
| (p.role || '').toLowerCase().includes(q) | |
| ); | |
| }); | |
| if (searchQuery && filtered.length === 0) return null; | |
| return ( | |
| <motion.div | |
| initial={{ opacity: 0, y: 20 }} | |
| animate={{ opacity: 1, y: 0 }} | |
| style={{ | |
| backgroundColor: color.glow, | |
| border: `1px solid ${color.border}`, | |
| borderRadius: '16px', | |
| overflow: 'hidden', | |
| marginBottom: '1.5rem', | |
| }} | |
| > | |
| {/* Header */} | |
| <button | |
| onClick={() => setExpanded(e => !e)} | |
| style={{ | |
| width: '100%', background: 'none', border: 'none', cursor: 'pointer', | |
| padding: '1.25rem 1.5rem', | |
| display: 'flex', alignItems: 'center', justifyContent: 'space-between', | |
| color: '#fff', | |
| }} | |
| > | |
| <div style={{ display: 'flex', alignItems: 'center', gap: '0.75rem' }}> | |
| <div style={{ | |
| width: 36, height: 36, borderRadius: '10px', | |
| backgroundColor: `${color.accent}22`, display: 'flex', alignItems: 'center', justifyContent: 'center', | |
| border: `1px solid ${color.accent}55` | |
| }}> | |
| <ClusterIcon style={{ color: color.accent }} /> | |
| </div> | |
| <div style={{ textAlign: 'left' }}> | |
| <h3 style={{ fontSize: '1.05rem', fontWeight: '700', color: '#fff', margin: 0 }}>{label}</h3> | |
| <div style={{ display: 'flex', alignItems: 'center', gap: '4px', color: '#94a3b8', fontSize: '0.8rem', marginTop: 2 }}> | |
| <UsersIcon /> | |
| <span>{filtered.length} {filtered.length === 1 ? 'profile' : 'profiles'}</span> | |
| </div> | |
| </div> | |
| </div> | |
| <div style={{ color: color.accent }}> | |
| <ChevronDown open={expanded} /> | |
| </div> | |
| </button> | |
| {/* Body */} | |
| <AnimatePresence initial={false}> | |
| {expanded && ( | |
| <motion.div | |
| key="body" | |
| initial={{ height: 0, opacity: 0 }} | |
| animate={{ height: 'auto', opacity: 1 }} | |
| exit={{ height: 0, opacity: 0 }} | |
| transition={{ duration: 0.3 }} | |
| style={{ overflow: 'hidden' }} | |
| > | |
| <div style={{ | |
| padding: '0 1.5rem 1.5rem', | |
| display: 'grid', | |
| gridTemplateColumns: 'repeat(auto-fill, minmax(220px, 1fr))', | |
| gap: '0.75rem' | |
| }}> | |
| {filtered.map(p => ( | |
| <ProfileCard | |
| key={p.id} | |
| profile={p} | |
| accent={color.accent} | |
| onView={onViewProfile} | |
| /> | |
| ))} | |
| </div> | |
| </motion.div> | |
| )} | |
| </AnimatePresence> | |
| </motion.div> | |
| ); | |
| }; | |
| // βββ Profile Detail Modal βββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| const ProfileModal = ({ profile, onClose }) => { | |
| if (!profile) return null; | |
| const skills = Array.isArray(profile.technical_skills) | |
| ? profile.technical_skills | |
| : typeof profile.technical_skills === 'string' | |
| ? profile.technical_skills.split(',').map(s => s.trim()) | |
| : []; | |
| return ( | |
| <AnimatePresence> | |
| <motion.div | |
| initial={{ opacity: 0 }} | |
| animate={{ opacity: 1 }} | |
| exit={{ opacity: 0 }} | |
| onClick={onClose} | |
| style={{ | |
| position: 'fixed', inset: 0, backgroundColor: 'rgba(0,0,0,0.7)', | |
| backdropFilter: 'blur(6px)', zIndex: 100, display: 'flex', | |
| alignItems: 'center', justifyContent: 'center', padding: '1rem' | |
| }} | |
| > | |
| <motion.div | |
| initial={{ scale: 0.9, opacity: 0 }} | |
| animate={{ scale: 1, opacity: 1 }} | |
| exit={{ scale: 0.9, opacity: 0 }} | |
| onClick={e => e.stopPropagation()} | |
| style={{ | |
| backgroundColor: '#0f172a', | |
| backgroundImage: ` | |
| radial-gradient(at 0% 0%, rgba(139,92,246,0.2) 0px, transparent 50%), | |
| radial-gradient(at 100% 100%, rgba(239,68,68,0.2) 0px, transparent 50%) | |
| `, | |
| border: '1px solid rgba(255,255,255,0.1)', | |
| borderRadius: '20px', | |
| width: '100%', maxWidth: '540px', | |
| maxHeight: '80vh', overflowY: 'auto', | |
| boxShadow: '0 25px 50px rgba(0,0,0,0.5)', | |
| padding: '2rem', | |
| }} | |
| > | |
| {/* Close */} | |
| <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '1.5rem' }}> | |
| <div style={{ display: 'flex', alignItems: 'center', gap: '1rem' }}> | |
| <img | |
| src={profile.avatar_url || `https://ui-avatars.com/api/?name=${encodeURIComponent(profile.full_name || 'User')}&background=random&size=80`} | |
| alt={profile.full_name} | |
| style={{ width: 56, height: 56, borderRadius: '50%', objectFit: 'cover', border: '2px solid rgba(239,68,68,0.4)' }} | |
| /> | |
| <div> | |
| <h2 style={{ fontSize: '1.4rem', fontWeight: '800', color: '#fff', margin: 0 }}>{profile.full_name}</h2> | |
| <p style={{ color: '#94a3b8', fontSize: '0.85rem', margin: 0 }}>{profile.headline || profile.role || 'β'}</p> | |
| </div> | |
| </div> | |
| <button onClick={onClose} style={{ background: 'none', border: 'none', color: '#64748b', cursor: 'pointer' }}> | |
| <XIcon /> | |
| </button> | |
| </div> | |
| {/* Stats Row */} | |
| <div style={{ display: 'grid', gridTemplateColumns: 'repeat(3, 1fr)', gap: '0.75rem', marginBottom: '1.5rem' }}> | |
| {[ | |
| { label: 'Experience', value: profile.experience_years ? `${profile.experience_years} yrs` : 'β' }, | |
| { label: 'Cluster', value: profile.cluster_label || 'β' }, | |
| { label: 'Email', value: profile.email ? profile.email.split('@')[0] : 'β' }, | |
| ].map(({ label, value }) => ( | |
| <div key={label} style={{ | |
| backgroundColor: 'rgba(255,255,255,0.05)', borderRadius: '10px', | |
| padding: '0.75rem', border: '1px solid rgba(255,255,255,0.08)' | |
| }}> | |
| <p style={{ fontSize: '0.7rem', color: '#64748b', textTransform: 'uppercase', letterSpacing: '0.05em', marginBottom: 4 }}>{label}</p> | |
| <p style={{ fontSize: '0.85rem', fontWeight: '600', color: '#e2e8f0', wordBreak: 'break-all' }}>{value}</p> | |
| </div> | |
| ))} | |
| </div> | |
| {/* Summary */} | |
| {profile.summary && ( | |
| <div style={{ marginBottom: '1.5rem' }}> | |
| <h4 style={{ fontSize: '0.85rem', color: '#94a3b8', fontWeight: '600', marginBottom: '0.5rem', textTransform: 'uppercase', letterSpacing: '0.05em' }}>Summary</h4> | |
| <p style={{ fontSize: '0.9rem', lineHeight: '1.6', color: '#cbd5e1', backgroundColor: 'rgba(255,255,255,0.04)', padding: '0.75rem', borderRadius: '8px', border: '1px solid rgba(255,255,255,0.05)' }}> | |
| {profile.summary} | |
| </p> | |
| </div> | |
| )} | |
| {/* Skills */} | |
| {skills.length > 0 && ( | |
| <div style={{ marginBottom: '1.5rem' }}> | |
| <h4 style={{ fontSize: '0.85rem', color: '#94a3b8', fontWeight: '600', marginBottom: '0.5rem', textTransform: 'uppercase', letterSpacing: '0.05em' }}>Technical Skills</h4> | |
| <div style={{ display: 'flex', flexWrap: 'wrap', gap: '0.4rem' }}> | |
| {skills.map((s, i) => ( | |
| <span key={i} style={{ | |
| fontSize: '0.8rem', padding: '4px 10px', borderRadius: '6px', | |
| backgroundColor: 'rgba(239,68,68,0.1)', color: '#EF4444', | |
| border: '1px solid rgba(239,68,68,0.2)' | |
| }}>{s}</span> | |
| ))} | |
| </div> | |
| </div> | |
| )} | |
| {/* Education */} | |
| {profile.education && ( | |
| <div> | |
| <h4 style={{ fontSize: '0.85rem', color: '#94a3b8', fontWeight: '600', marginBottom: '0.5rem', textTransform: 'uppercase', letterSpacing: '0.05em' }}>Education</h4> | |
| <p style={{ fontSize: '0.85rem', color: '#cbd5e1' }}> | |
| {typeof profile.education === 'string' ? profile.education : JSON.stringify(profile.education)} | |
| </p> | |
| </div> | |
| )} | |
| </motion.div> | |
| </motion.div> | |
| </AnimatePresence> | |
| ); | |
| }; | |
| // βββ MAIN PAGE ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| export default function TalentClusters() { | |
| const [clusters, setClusters] = useState({}); // { labelName: [profiles] } | |
| const [isLoading, setIsLoading] = useState(true); | |
| const [searchQuery, setSearchQuery] = useState(''); | |
| const [selectedProfile, setSelectedProfile] = useState(null); | |
| const [error, setError] = useState(null); | |
| const [isClusteringRunning, setIsClusteringRunning] = useState(false); | |
| useEffect(() => { | |
| fetchClusters(); | |
| }, []); | |
| const fetchClusters = async () => { | |
| setIsLoading(true); | |
| setError(null); | |
| try { | |
| const { data, error } = await supabase | |
| .from('profiles') | |
| .select('id, full_name, email, avatar_url, headline, role, experience_years, technical_skills, summary, education, cluster_label') | |
| .not('cluster_label', 'is', null); | |
| if (error) throw error; | |
| // Group by cluster_label | |
| const grouped = {}; | |
| data.forEach(profile => { | |
| const label = profile.cluster_label || 'Uncategorized'; | |
| if (!grouped[label]) grouped[label] = []; | |
| grouped[label].push(profile); | |
| }); | |
| setClusters(grouped); | |
| } catch (err) { | |
| console.error('Failed to fetch clusters:', err); | |
| setError('Failed to load talent clusters. Please try again.'); | |
| } finally { | |
| setIsLoading(false); | |
| } | |
| }; | |
| const runClustering = async () => { | |
| setIsClusteringRunning(true); | |
| try { | |
| const API_URL = import.meta.env.VITE_API_URL || 'http://localhost:8000'; | |
| const response = await fetch(`${API_URL}/run-clustering`, { | |
| method: 'POST', | |
| headers: { 'Content-Type': 'application/json' } | |
| }); | |
| if (!response.ok) { | |
| throw new Error('Failed to start clustering'); | |
| } | |
| const data = await response.json(); | |
| console.log('β Clustering started:', data); | |
| alert('Clustering pipeline started! This may take a few minutes. You will be notified when complete.'); | |
| // Poll for completion (check every 5 seconds for up to 5 minutes) | |
| const pollInterval = setInterval(async () => { | |
| try { | |
| await fetchClusters(); | |
| // If we get here and setClusters worked, clustering likely complete | |
| // Check if there are actually clusters now | |
| const { data: profiles } = await supabase | |
| .from('profiles') | |
| .select('cluster_label') | |
| .not('cluster_label', 'is', null) | |
| .limit(1); | |
| if (profiles && profiles.length > 0) { | |
| clearInterval(pollInterval); | |
| setIsClusteringRunning(false); | |
| alert('β Clustering complete! Clusters loaded.'); | |
| } | |
| } catch (err) { | |
| console.log('Still clustering...'); | |
| } | |
| }, 5000); | |
| // Clear interval after 5 minutes | |
| setTimeout(() => clearInterval(pollInterval), 5 * 60 * 1000); | |
| } catch (err) { | |
| console.error('Error starting clustering:', err); | |
| alert('Failed to start clustering. Check the backend logs.'); | |
| setIsClusteringRunning(false); | |
| } | |
| }; | |
| const clusterEntries = Object.entries(clusters).sort((a, b) => b[1].length - a[1].length); | |
| const totalProfiles = Object.values(clusters).reduce((s, arr) => s + arr.length, 0); | |
| return ( | |
| <div style={{ paddingBottom: '4rem' }}> | |
| <style>{` | |
| .hide-scrollbar::-webkit-scrollbar { display: none; } | |
| .hide-scrollbar { -ms-overflow-style: none; scrollbar-width: none; } | |
| @keyframes spin { 100% { transform: rotate(360deg); } } | |
| @keyframes pulse-dot { 0%,100% { opacity: 1; } 50% { opacity: 0.3; } } | |
| `}</style> | |
| {/* Header */} | |
| <header style={{ marginBottom: '2rem' }}> | |
| <div style={{ display: 'flex', alignItems: 'center', gap: '0.75rem', marginBottom: '0.5rem' }}> | |
| <div style={{ color: '#EF4444' }}><ClusterIcon /></div> | |
| <h1 style={{ fontSize: '1.875rem', fontWeight: 'bold', margin: 0 }}>Talent Clusters</h1> | |
| </div> | |
| <p style={{ color: '#64748b', fontSize: '0.9rem' }}> | |
| AI-grouped candidate profiles based on skills and experience similarity. | |
| </p> | |
| </header> | |
| {/* Stats Bar */} | |
| <div style={{ display: 'flex', gap: '1rem', marginBottom: '2rem', flexWrap: 'wrap' }}> | |
| {[ | |
| { label: 'Total Clusters', value: clusterEntries.length, color: '#EF4444' }, | |
| { label: 'Total Profiles', value: totalProfiles, color: '#8B5CF6' }, | |
| { label: 'Avg. Cluster Size', value: clusterEntries.length ? Math.round(totalProfiles / clusterEntries.length) : 0, color: '#06B6D4' }, | |
| ].map(({ label, value, color }) => ( | |
| <div key={label} style={{ | |
| flex: 1, minWidth: 140, | |
| backgroundColor: 'rgba(255,255,255,0.03)', | |
| border: '1px solid rgba(255,255,255,0.08)', | |
| borderRadius: '12px', padding: '1rem 1.25rem', | |
| }}> | |
| <p style={{ fontSize: '0.75rem', color: '#64748b', textTransform: 'uppercase', letterSpacing: '0.05em', marginBottom: 4 }}>{label}</p> | |
| <p style={{ fontSize: '1.8rem', fontWeight: '800', color, margin: 0, lineHeight: 1 }}>{isLoading ? 'β' : value}</p> | |
| </div> | |
| ))} | |
| </div> | |
| {/* Search + Refresh */} | |
| <div style={{ display: 'flex', gap: '0.75rem', marginBottom: '2rem', alignItems: 'center' }}> | |
| <div style={{ position: 'relative', flexGrow: 1 }}> | |
| <div style={{ position: 'absolute', left: 12, top: '50%', transform: 'translateY(-50%)', color: '#64748b' }}> | |
| <SearchIcon /> | |
| </div> | |
| <input | |
| type="text" | |
| placeholder="Search by name, role, or headline..." | |
| value={searchQuery} | |
| onChange={e => setSearchQuery(e.target.value)} | |
| style={{ | |
| width: '100%', padding: '0.75rem 0.75rem 0.75rem 2.25rem', | |
| borderRadius: '0.5rem', border: '1px solid rgba(239,68,68,0.3)', | |
| backgroundColor: 'rgba(255,255,255,0.04)', color: 'white', | |
| fontSize: '0.9rem', outline: 'none', boxSizing: 'border-box' | |
| }} | |
| /> | |
| </div> | |
| <motion.button | |
| onClick={runClustering} | |
| disabled={isClusteringRunning} | |
| whileHover={!isClusteringRunning ? { scale: 1.04 } : {}} | |
| whileTap={!isClusteringRunning ? { scale: 0.96 } : {}} | |
| style={{ | |
| backgroundColor: isClusteringRunning ? 'rgba(139,92,246,0.15)' : 'rgba(139,92,246,0.15)', | |
| border: '1px solid rgba(139,92,246,0.4)', | |
| color: isClusteringRunning ? '#94a3b8' : '#8B5CF6', | |
| padding: '0.75rem 1.25rem', | |
| borderRadius: '0.5rem', | |
| cursor: isClusteringRunning ? 'not-allowed' : 'pointer', | |
| fontWeight: '600', | |
| fontSize: '0.85rem', | |
| whiteSpace: 'nowrap', | |
| opacity: isClusteringRunning ? 0.6 : 1, | |
| transition: 'all 0.2s ease' | |
| }} | |
| > | |
| {isClusteringRunning ? 'β³ Clustering...' : 'β‘ Run Clustering'} | |
| </motion.button> | |
| </div> | |
| {/* Content */} | |
| {isLoading ? ( | |
| <div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', height: '300px', gap: '1rem' }}> | |
| <div style={{ | |
| width: 40, height: 40, border: '3px solid rgba(239,68,68,0.2)', | |
| borderTopColor: '#EF4444', borderRadius: '50%', | |
| animation: 'spin 0.8s linear infinite' | |
| }} /> | |
| <p style={{ color: '#64748b' }}>Loading talent clustersβ¦</p> | |
| </div> | |
| ) : error ? ( | |
| <div style={{ textAlign: 'center', padding: '3rem', color: '#EF4444' }}> | |
| <p>{error}</p> | |
| <button onClick={fetchClusters} style={{ marginTop: '1rem', backgroundColor: '#EF4444', color: 'white', border: 'none', padding: '0.5rem 1.5rem', borderRadius: '6px', cursor: 'pointer', fontWeight: '600' }}> | |
| Retry | |
| </button> | |
| </div> | |
| ) : clusterEntries.length === 0 ? ( | |
| <div style={{ textAlign: 'center', padding: '4rem', color: '#64748b' }}> | |
| <ClusterIcon /> | |
| <p style={{ marginTop: '1rem' }}>No clusters found. Run the clustering pipeline first.</p> | |
| </div> | |
| ) : ( | |
| <> | |
| {/* Cluster grid legend */} | |
| <div style={{ display: 'flex', flexWrap: 'wrap', gap: '0.5rem', marginBottom: '1.5rem' }}> | |
| {clusterEntries.map(([label, profiles], idx) => { | |
| const color = getColor(idx); | |
| return ( | |
| <span key={label} style={{ | |
| fontSize: '0.78rem', padding: '4px 12px', borderRadius: '99px', | |
| backgroundColor: `${color.accent}18`, color: color.accent, | |
| border: `1px solid ${color.accent}44`, fontWeight: '600' | |
| }}> | |
| {label} ({profiles.length}) | |
| </span> | |
| ); | |
| })} | |
| </div> | |
| {/* Cluster cards */} | |
| {clusterEntries.map(([label, profiles], idx) => ( | |
| <ClusterCard | |
| key={label} | |
| label={label} | |
| profiles={profiles} | |
| colorIdx={idx} | |
| searchQuery={searchQuery} | |
| onViewProfile={setSelectedProfile} | |
| /> | |
| ))} | |
| </> | |
| )} | |
| {/* Profile modal */} | |
| <AnimatePresence> | |
| {selectedProfile && ( | |
| <ProfileModal profile={selectedProfile} onClose={() => setSelectedProfile(null)} /> | |
| )} | |
| </AnimatePresence> | |
| </div> | |
| ); | |
| } | |