Spaces:
Sleeping
Sleeping
| import React, { useState } from 'react'; | |
| import { motion, AnimatePresence } from 'framer-motion'; | |
| import { X, ExternalLink, ThumbsUp, ThumbsDown, BrainCircuit } from 'lucide-react'; | |
| import FullProfileOverlay from './FullProfileOverlay'; | |
| import { saveAs } from 'file-saver'; | |
| import { supabase } from '../supabaseClient'; | |
| const CandidateDrawer = ({ isOpen, onClose, candidate }) => { | |
| const [showFullProfile, setShowFullProfile] = useState(false); | |
| const [analysis, setAnalysis] = useState(null); | |
| const [loadingAnalysis, setLoadingAnalysis] = useState(false); | |
| React.useEffect(() => { | |
| if (isOpen && candidate?.userId && !analysis) { | |
| fetchAnalysis(); | |
| } | |
| }, [isOpen, candidate, analysis]); | |
| // Reset analysis when candidate changes | |
| React.useEffect(() => { | |
| setAnalysis(null); | |
| }, [candidate?.id]); | |
| const fetchAnalysis = async () => { | |
| setLoadingAnalysis(true); | |
| try { | |
| const API_URL = import.meta.env.VITE_API_URL || 'http://localhost:8000'; | |
| const response = await fetch(`${API_URL}/analyze-candidate`, { | |
| method: 'POST', | |
| headers: { 'Content-Type': 'application/json' }, | |
| body: JSON.stringify({ | |
| candidate_id: candidate.userId, | |
| job_id: candidate.jobId | |
| }) | |
| }); | |
| const result = await response.json(); | |
| if (result.status === 'success') { | |
| setAnalysis(result.data); | |
| } | |
| } catch (error) { | |
| console.error("Failed to fetch analysis:", error); | |
| } finally { | |
| setLoadingAnalysis(false); | |
| } | |
| }; | |
| const handleDownloadCV = async () => { | |
| let url = candidate.resumeUrl; | |
| if (Array.isArray(url)) url = url[0]; | |
| url = (url || '').trim(); | |
| if (!url) { | |
| alert("No resume available for this candidate."); | |
| return; | |
| } | |
| try { | |
| if (url.startsWith('http')) { | |
| const response = await fetch(url); | |
| const blob = await response.blob(); | |
| saveAs(blob, `${candidate.name.replace(/\s+/g, '_')}_Resume.pdf`); | |
| return; | |
| } | |
| // 1. Try common bucket and path combinations | |
| const tryDownload = async (path) => { | |
| const { data, error } = await supabase.storage.from('resume').createSignedUrl(path, 60); | |
| if (!error && data?.signedUrl) return data.signedUrl; | |
| // Try 'resumes' plural bucket too | |
| const fallback = await supabase.storage.from('resumes').createSignedUrl(path, 60); | |
| if (!fallback.error && fallback.data?.signedUrl) return fallback.data.signedUrl; | |
| return null; | |
| }; | |
| // Path strategy 1: Use as stored | |
| let signedUrl = await tryDownload(url); | |
| // Path strategy 2: If no folder in path, try prepending userID (as per user clarification) | |
| if (!signedUrl && !url.includes('/') && candidate.userId) { | |
| const prefixedPath = `${candidate.userId}/${url}`; | |
| console.log(`Retrying with userID prefix: ${prefixedPath}`); | |
| signedUrl = await tryDownload(prefixedPath); | |
| } | |
| if (signedUrl) { | |
| window.open(signedUrl, '_blank'); | |
| } else { | |
| throw new Error("File not found in storage"); | |
| } | |
| } catch (error) { | |
| console.error("Download failed:", error); | |
| // 2. Final Fallback: Attempt Public URL (best guess) | |
| try { | |
| const { data: { publicUrl } } = supabase.storage.from('resume').getPublicUrl(url); | |
| window.open(publicUrl, '_blank'); | |
| } catch (finalError) { | |
| alert(`Could not access the file. Please ensure the resume is in the 'resume' bucket inside a folder named '${candidate.userId}'.`); | |
| } | |
| } | |
| }; | |
| if (!candidate) return null; | |
| const summary = analysis?.summary || candidate.summary || "No summary available."; | |
| const strengths = analysis?.strengths || candidate.insights?.strengths || []; | |
| const weaknesses = analysis?.weaknesses || candidate.insights?.weaknesses || []; | |
| const missingSkills = analysis?.missing_skills || []; | |
| return ( | |
| <AnimatePresence> | |
| {isOpen && ( | |
| <> | |
| <motion.div | |
| initial={{ opacity: 0 }} | |
| animate={{ opacity: 1 }} | |
| exit={{ opacity: 0 }} | |
| onClick={onClose} | |
| style={{ | |
| position: 'fixed', | |
| inset: 0, | |
| backgroundColor: 'rgba(0,0,0,0.6)', | |
| backdropFilter: 'blur(4px)', | |
| zIndex: 40 | |
| }} | |
| /> | |
| <motion.div | |
| initial={{ x: '100%' }} | |
| animate={{ x: 0 }} | |
| exit={{ x: '100%' }} | |
| transition={{ type: 'spring', damping: 25, stiffness: 200 }} | |
| style={{ | |
| position: 'fixed', | |
| top: 0, | |
| right: 0, | |
| height: '100%', | |
| width: '100%', | |
| maxWidth: '500px', | |
| backgroundColor: '#0f172a', | |
| backgroundImage: ` | |
| radial-gradient(at 0% 0%, rgba(56, 189, 248, 0.25) 0px, transparent 50%), | |
| radial-gradient(at 100% 100%, rgba(239, 68, 68, 0.25) 0px, transparent 50%), | |
| linear-gradient(135deg, rgba(255,255,255,0.03) 0%, transparent 100%) | |
| `, | |
| borderLeft: '1px solid rgba(255,255,255,0.1)', | |
| zIndex: 50, | |
| overflowY: 'auto', | |
| boxShadow: '-10px 0 25px rgba(0,0,0,0.5)' | |
| }} | |
| > | |
| <div style={{ padding: '2rem' }}> | |
| {/* Header */} | |
| <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'start', marginBottom: '2rem' }}> | |
| <div> | |
| <h2 style={{ fontSize: '1.75rem', fontWeight: 'bold', color: 'white' }}>{candidate.name}</h2> | |
| <p style={{ color: '#94a3b8' }}>{candidate.jobTitle || candidate.role} • {candidate.experience} </p> | |
| </div> | |
| <button onClick={onClose} style={{ background: 'none', border: 'none', color: '#94a3b8', cursor: 'pointer' }}> | |
| <X size={24} /> | |
| </button> | |
| </div> | |
| <div style={{ marginBottom: '2rem', padding: '1rem', backgroundColor: 'rgba(239, 68, 68, 0.05)', borderRadius: '0.75rem', border: '1px solid rgba(239, 68, 68, 0.2)' }}> | |
| <div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: '0.5rem' }}> | |
| <span style={{ fontWeight: 'bold', color: '#EF4444' }}>{analysis?.score !== undefined ? 'Gemini AI Score' : 'Semantic Match Score'}</span> | |
| <span style={{ fontWeight: 'bold', color: 'white' }}>{analysis?.score ?? (candidate.score || candidate.matchScore || 0)}%</span> | |
| </div> | |
| <div style={{ width: '100%', height: '6px', backgroundColor: 'rgba(255,255,255,0.1)', borderRadius: '3px' }}> | |
| <motion.div | |
| initial={{ width: 0 }} | |
| animate={{ width: `${analysis?.score ?? (candidate.score || candidate.matchScore || 0)}%` }} | |
| transition={{ delay: 0.2, duration: 1 }} | |
| style={{ height: '100%', backgroundColor: '#EF4444', borderRadius: '3px', boxShadow: '0 0 10px rgba(239,68,68,0.5)' }} | |
| /> | |
| </div> | |
| </div> | |
| {loadingAnalysis && ( | |
| <div style={{ padding: '1rem', textAlign: 'center', color: '#EF4444', fontSize: '0.9rem' }}> | |
| Analyzing candidate with AI... | |
| </div> | |
| )} | |
| {/* AI Insights Section */} | |
| <div style={{ marginBottom: '2rem' }}> | |
| <h3 style={{ display: 'flex', alignItems: 'center', gap: '8px', fontSize: '1.1rem', fontWeight: '600', color: 'white', marginBottom: '1rem' }}> | |
| <BrainCircuit size={18} color="#EF4444" /> AI Insights | |
| </h3> | |
| <div style={{ display: 'flex', flexDirection: 'column', gap: '0.75rem' }}> | |
| {strengths.map((str, i) => ( | |
| <div key={`str-${i}`} style={{ display: 'flex', gap: '10px', fontSize: '0.9rem', color: '#cbd5e1' }}> | |
| <ThumbsUp size={16} color="#34d399" style={{ marginTop: '3px', flexShrink: 0 }} /> | |
| <span>{str}</span> | |
| </div> | |
| ))} | |
| {weaknesses.map((wk, i) => ( | |
| <div key={`wk-${i}`} style={{ display: 'flex', gap: '10px', fontSize: '0.9rem', color: '#cbd5e1' }}> | |
| <ThumbsDown size={16} color="#fb7185" style={{ marginTop: '3px', flexShrink: 0 }} /> | |
| <span>{wk}</span> | |
| </div> | |
| ))} | |
| </div> | |
| </div> | |
| {/* Missing Skills Section */} | |
| {missingSkills.length > 0 && ( | |
| <div style={{ marginBottom: '2rem' }}> | |
| <h3 style={{ fontSize: '1.1rem', fontWeight: '600', color: 'white', marginBottom: '0.75rem' }}>Missing Skills (Gap Analysis)</h3> | |
| <div style={{ display: 'flex', flexWrap: 'wrap', gap: '0.5rem' }}> | |
| {missingSkills.map((skill, i) => ( | |
| <span key={i} style={{ backgroundColor: 'rgba(239, 68, 68, 0.1)', color: '#EF4444', padding: '4px 10px', borderRadius: '4px', fontSize: '0.8rem', border: '1px solid rgba(239, 68, 68, 0.2)' }}> | |
| {skill} | |
| </span> | |
| ))} | |
| </div> | |
| </div> | |
| )} | |
| {/* Resume Summary */} | |
| <div style={{ marginBottom: '2rem' }}> | |
| <h3 style={{ fontSize: '1.1rem', fontWeight: '600', color: 'white', marginBottom: '0.75rem' }}>AI Professional Summary</h3> | |
| <p style={{ fontSize: '0.9rem', lineHeight: '1.6', color: '#94a3b8', backgroundColor: 'rgba(255,255,255,0.05)', padding: '1rem', borderRadius: '0.5rem', border: '1px solid rgba(255,255,255,0.05)' }}> | |
| {summary} | |
| </p> | |
| </div> | |
| {/* Portfolio & Projects */} | |
| <div style={{ marginBottom: '2rem' }}> | |
| <h3 style={{ fontSize: '1.1rem', fontWeight: '600', color: 'white', marginBottom: '0.75rem' }}>Portfolio & Projects</h3> | |
| <div style={{ display: 'flex', flexWrap: 'wrap', gap: '0.75rem' }}> | |
| {candidate.projects?.map((project, i) => ( | |
| <div key={i} style={{ display: 'flex', alignItems: 'center', gap: '6px', fontSize: '0.85rem', color: 'white', backgroundColor: 'rgba(255,255,255,0.1)', padding: '0.4rem 0.8rem', borderRadius: '6px', border: '1px solid rgba(255,255,255,0.1)', cursor: 'default' }}> | |
| {typeof project === 'object' ? (project.title || project.name) : project} <ExternalLink size={12} /> | |
| </div> | |
| ))} | |
| </div> | |
| </div> | |
| {/* Footer Actions */} | |
| <div style={{ marginTop: '3rem', paddingTop: '1.5rem', borderTop: '1px solid rgba(255,255,255,0.1)', display: 'flex', gap: '1rem' }}> | |
| <button | |
| onClick={() => setShowFullProfile(true)} | |
| style={{ flex: 1, padding: '0.75rem', borderRadius: '0.5rem', backgroundColor: '#EF4444', color: 'white', border: 'none', fontWeight: '600', cursor: 'pointer', boxShadow: '0 4px 12px rgba(239, 68, 68, 0.3)' }} | |
| > | |
| Full Profile | |
| </button> | |
| <button | |
| onClick={handleDownloadCV} | |
| style={{ flex: 1, padding: '0.75rem', borderRadius: '0.5rem', backgroundColor: 'transparent', color: 'white', border: '1px solid rgba(255,255,255,0.2)', fontWeight: '600', cursor: 'pointer' }} | |
| > | |
| Download CV | |
| </button> | |
| </div> | |
| </div> | |
| </motion.div> | |
| {showFullProfile && ( | |
| <AnimatePresence> | |
| <FullProfileOverlay | |
| candidate={candidate} | |
| onClose={() => setShowFullProfile(false)} | |
| /> | |
| </AnimatePresence> | |
| )} | |
| </> | |
| )} | |
| </AnimatePresence> | |
| ); | |
| }; | |
| export default CandidateDrawer; |