Spaces:
Running
Running
/** | |
* @license | |
* SPDX-License-Identifier: Apache-2.0 | |
*/ | |
import React, { useState, useEffect, useCallback } from 'react'; | |
import ControlPanel from './components/ControlPanel'; | |
import ResumePreview from './components/ResumePreview'; | |
import JobMatchDashboard from './components/JobMatchDashboard'; | |
import LoadingSpinner from './components/LoadingSpinner'; | |
import type { InitialInput, ResumeDocument, ResumeSectionType, ScoreResponse, ActiveEditor } from './lib/types'; | |
// Custom hook for debouncing | |
const useDebounce = (callback: (...args: any[]) => void, delay: number) => { | |
const [timeoutId, setTimeoutId] = useState<NodeJS.Timeout | null>(null); | |
return useCallback((...args: any[]) => { | |
if (timeoutId) { | |
clearTimeout(timeoutId); | |
} | |
const newTimeoutId = setTimeout(() => { | |
callback(...args); | |
}, delay); | |
setTimeoutId(newTimeoutId); | |
}, [callback, delay, timeoutId]); | |
}; | |
export default function App() { | |
const [jobDescription, setJobDescription] = useState<string>(''); | |
const [resumeDocument, setResumeDocument] = useState<ResumeDocument>([]); | |
const [activeEditor, setActiveEditor] = useState<ActiveEditor | null>(null); | |
const [scoreData, setScoreData] = useState<ScoreResponse>({ score: 0, suggestions: [] }); | |
const [isGenerating, setIsGenerating] = useState(false); // For initial generation | |
const [isScoring, setIsScoring] = useState(false); // For real-time scoring | |
const [isRefining, setIsRefining] = useState(false); // For co-pilot actions | |
const [error, setError] = useState<string | null>(null); | |
const parseFullResumeText = (fullText: string): ResumeDocument => { | |
const sections: ResumeDocument = []; | |
const sectionTitles: ResumeSectionType[] = ["Professional Summary", "Skills Section", "Experience", "Education"]; | |
const sectionRegex = new RegExp(`##\\s*(${sectionTitles.join('|')})\\s*([\\s\\S]*?)(?=\\n##\\s*(?:${sectionTitles.join('|')})|$)`, 'g'); | |
let match; | |
while ((match = sectionRegex.exec(fullText)) !== null) { | |
const title = match[1].trim() as ResumeSectionType; | |
const content = match[2].trim(); | |
sections.push({ id: title, title, content }); | |
} | |
return sections; | |
}; | |
const handleInitialGenerate = async (input: InitialInput) => { | |
setIsGenerating(true); | |
setError(null); | |
setJobDescription(input.jobDescription || ''); | |
setResumeDocument([]); | |
setScoreData({ score: 0, suggestions: [] }); | |
try { | |
const response = await fetch('http://127.0.0.1:5000/api/generate', { | |
method: 'POST', | |
headers: { 'Content-Type': 'application/json' }, | |
body: JSON.stringify(input), | |
}); | |
if (!response.ok) throw new Error((await response.json()).error || 'Server error'); | |
const data = await response.json(); | |
const parsedSections = parseFullResumeText(data.resume); | |
setResumeDocument(parsedSections); | |
} catch (err) { | |
handleFetchError(err, "Could not generate resume."); | |
} finally { | |
setIsGenerating(false); | |
} | |
}; | |
const debouncedScoreRequest = useDebounce(async (doc: ResumeDocument, jd: string) => { | |
if (!jd || doc.length === 0) return; | |
setIsScoring(true); | |
try { | |
const fullText = doc.map(s => `## ${s.title}\n${s.content}`).join('\n\n'); | |
const response = await fetch('http://127.0.0.1:5000/api/score', { | |
method: 'POST', | |
headers: { 'Content-Type': 'application/json' }, | |
body: JSON.stringify({ resume: fullText, job_description: jd }), | |
}); | |
if (!response.ok) return; // Fail silently on scoring | |
const data: ScoreResponse = await response.json(); | |
setScoreData(data); | |
} catch (err) { | |
// Don't show scoring errors to user to avoid being noisy | |
console.error("Scoring error:", err); | |
} finally { | |
setIsScoring(false); | |
} | |
}, 1500); // 1.5-second debounce delay | |
useEffect(() => { | |
if (resumeDocument.length > 0 && jobDescription) { | |
debouncedScoreRequest(resumeDocument, jobDescription); | |
} | |
}, [resumeDocument, jobDescription, debouncedScoreRequest]); | |
const handleContentUpdate = (sectionId: ResumeSectionType, newContent: string) => { | |
setResumeDocument(prevDoc => | |
prevDoc.map(section => | |
section.id === sectionId ? { ...section, content: newContent } : section | |
) | |
); | |
}; | |
const handleRefineSection = async (instruction: string) => { | |
if (!activeEditor) return; | |
setIsRefining(true); | |
setError(null); | |
try { | |
const response = await fetch('http://127.0.0.1:5000/api/refine_section', { | |
method: 'POST', | |
headers: { 'Content-Type': 'application/json' }, | |
body: JSON.stringify({ | |
text_to_refine: activeEditor.content, | |
instruction: instruction | |
}), | |
}); | |
if (!response.ok) throw new Error((await response.json()).error || 'Server error during refinement.'); | |
const data = await response.json(); | |
handleContentUpdate(activeEditor.sectionId, data.refined_text); | |
} catch (err) { | |
handleFetchError(err, "Could not refine section."); | |
} finally { | |
setIsRefining(false); | |
} | |
}; | |
const handleFetchError = (err: any, context: string) => { | |
let message = err instanceof Error ? err.message : "An unknown error occurred."; | |
if (message.includes('Failed to fetch')) { | |
message = "Could not connect to the backend server. Is it running? Please start the Python server and try again."; | |
} | |
setError(`${context} ${message}`); | |
}; | |
const clearAll = () => { | |
setJobDescription(''); | |
setResumeDocument([]); | |
setActiveEditor(null); | |
setScoreData({ score: 0, suggestions: [] }); | |
setError(null); | |
}; | |
return ( | |
<div className="app-container"> | |
<header className="app-header"> | |
<h1>AI Resume Studio</h1> | |
<p>Your intelligent co-pilot for crafting the perfect resume.</p> | |
</header> | |
{isGenerating && <div className="global-loading-overlay"><LoadingSpinner /></div>} | |
{error && <div className="error-message global-error">{error}</div>} | |
<div className="app-content-grid"> | |
<aside className="control-column"> | |
<ControlPanel | |
onGenerate={handleInitialGenerate} | |
onClear={clearAll} | |
activeEditor={activeEditor} | |
onRefine={handleRefineSection} | |
isGenerating={isGenerating} | |
isRefining={isRefining} | |
/> | |
</aside> | |
<main className="resume-column"> | |
{resumeDocument.length > 0 ? ( | |
<ResumePreview | |
document={resumeDocument} | |
onContentChange={handleContentUpdate} | |
activeSectionId={activeEditor?.sectionId} | |
onSectionFocus={setActiveEditor} | |
/> | |
) : ( | |
<div className="placeholder-text"> | |
<p>Your AI-crafted resume will appear here. Fill in the "Initial Setup" form and click "Generate" to begin!</p> | |
</div> | |
)} | |
</main> | |
<aside className="dashboard-column"> | |
<JobMatchDashboard | |
score={scoreData.score} | |
suggestions={scoreData.suggestions} | |
isLoading={isScoring} | |
hasContent={resumeDocument.length > 0 && jobDescription.length > 0} | |
/> | |
</aside> | |
</div> | |
</div> | |
); | |
} | |