File size: 7,433 Bytes
d5db0b0
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
/**
 * @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>
  );
}