Spaces:
Running
Running
Upload 11 files
Browse files- components/AiCoPilotPanel.tsx +52 -0
- components/ContentContainer.tsx +0 -0
- components/ControlPanel.tsx +69 -0
- components/DesignDisplay.tsx +0 -0
- components/EditableSection.tsx +52 -0
- components/ExampleGallery.tsx +0 -0
- components/FashionInputForm.tsx +0 -0
- components/InitialInputForm.tsx +136 -0
- components/JobMatchDashboard.tsx +59 -0
- components/LoadingSpinner.tsx +14 -0
- components/ResumePreview.tsx +56 -0
components/AiCoPilotPanel.tsx
ADDED
@@ -0,0 +1,52 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
/**
|
2 |
+
* @license
|
3 |
+
* SPDX-License-Identifier: Apache-2.0
|
4 |
+
*/
|
5 |
+
import React from 'react';
|
6 |
+
import type { ActiveEditor } from '../lib/types';
|
7 |
+
|
8 |
+
interface AiCoPilotPanelProps {
|
9 |
+
activeEditor: ActiveEditor | null;
|
10 |
+
onRefine: (instruction: string) => void;
|
11 |
+
isLoading: boolean;
|
12 |
+
}
|
13 |
+
|
14 |
+
export default function AiCoPilotPanel({ activeEditor, onRefine, isLoading }: AiCoPilotPanelProps) {
|
15 |
+
if (!activeEditor) {
|
16 |
+
return (
|
17 |
+
<div className="ai-copilot-panel">
|
18 |
+
<div className="copilot-placeholder">
|
19 |
+
<i className="ph-cursor-text" style={{fontSize: '2rem', marginBottom: '0.5rem'}}></i>
|
20 |
+
Click on a section in your resume to activate the AI Co-pilot.
|
21 |
+
</div>
|
22 |
+
</div>
|
23 |
+
);
|
24 |
+
}
|
25 |
+
|
26 |
+
const refinementActions = [
|
27 |
+
{ instruction: "Rewrite this section to be more impactful.", label: "Rewrite for Impact", icon: "ph-shooting-star" },
|
28 |
+
{ instruction: "Make this section more concise.", label: "Make More Concise", icon: "ph-arrows-in-simple" },
|
29 |
+
{ instruction: "Rephrase this using stronger, professional action verbs.", label: "Use Stronger Verbs", icon: "ph-rocket-launch" },
|
30 |
+
{ instruction: "Generate 3 alternative bullet points based on this content.", label: "Suggest Bullet Points", icon: "ph-list-bullets" },
|
31 |
+
];
|
32 |
+
|
33 |
+
return (
|
34 |
+
<div className="ai-copilot-panel">
|
35 |
+
<h3>AI Co-pilot: <span style={{color: 'var(--color-accent)'}}>{activeEditor.sectionId}</span></h3>
|
36 |
+
<div className="copilot-actions">
|
37 |
+
{refinementActions.map(({ instruction, label, icon }) => (
|
38 |
+
<button
|
39 |
+
key={instruction}
|
40 |
+
className="copilot-button"
|
41 |
+
onClick={() => onRefine(instruction)}
|
42 |
+
disabled={isLoading}
|
43 |
+
>
|
44 |
+
<i className={icon}></i>
|
45 |
+
<span>{label}</span>
|
46 |
+
{isLoading && <small>(...)</small>}
|
47 |
+
</button>
|
48 |
+
))}
|
49 |
+
</div>
|
50 |
+
</div>
|
51 |
+
);
|
52 |
+
}
|
components/ContentContainer.tsx
ADDED
File without changes
|
components/ControlPanel.tsx
ADDED
@@ -0,0 +1,69 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
/**
|
2 |
+
* @license
|
3 |
+
* SPDX-License-Identifier: Apache-2.0
|
4 |
+
*/
|
5 |
+
import React, { useState } from 'react';
|
6 |
+
import InitialInputForm from './InitialInputForm';
|
7 |
+
import AiCoPilotPanel from './AiCoPilotPanel';
|
8 |
+
import type { InitialInput, ActiveEditor } from '../lib/types';
|
9 |
+
|
10 |
+
interface ControlPanelProps {
|
11 |
+
onGenerate: (formData: InitialInput) => void;
|
12 |
+
onClear: () => void;
|
13 |
+
activeEditor: ActiveEditor | null;
|
14 |
+
onRefine: (instruction: string) => void;
|
15 |
+
isGenerating: boolean;
|
16 |
+
isRefining: boolean;
|
17 |
+
}
|
18 |
+
|
19 |
+
type Tab = 'setup' | 'copilot';
|
20 |
+
|
21 |
+
export default function ControlPanel({
|
22 |
+
onGenerate,
|
23 |
+
onClear,
|
24 |
+
activeEditor,
|
25 |
+
onRefine,
|
26 |
+
isGenerating,
|
27 |
+
isRefining
|
28 |
+
}: ControlPanelProps) {
|
29 |
+
const [activeTab, setActiveTab] = useState<Tab>('setup');
|
30 |
+
|
31 |
+
return (
|
32 |
+
<div className="control-panel">
|
33 |
+
<div className="control-panel-tabs">
|
34 |
+
<button
|
35 |
+
className={`control-panel-tab ${activeTab === 'setup' ? 'active' : ''}`}
|
36 |
+
onClick={() => setActiveTab('setup')}
|
37 |
+
>
|
38 |
+
<i className="ph-note-pencil"></i> Initial Setup
|
39 |
+
</button>
|
40 |
+
<button
|
41 |
+
className={`control-panel-tab ${activeTab === 'copilot' ? 'active' : ''}`}
|
42 |
+
onClick={() => setActiveTab('copilot')}
|
43 |
+
disabled={!activeEditor}
|
44 |
+
aria-disabled={!activeEditor}
|
45 |
+
>
|
46 |
+
<i className="ph-magic-wand"></i> AI Co-pilot
|
47 |
+
</button>
|
48 |
+
</div>
|
49 |
+
|
50 |
+
<div className="tab-content">
|
51 |
+
{activeTab === 'setup' && (
|
52 |
+
<InitialInputForm
|
53 |
+
onSubmit={onGenerate}
|
54 |
+
onClear={onClear}
|
55 |
+
disabled={isGenerating}
|
56 |
+
isLoading={isGenerating}
|
57 |
+
/>
|
58 |
+
)}
|
59 |
+
{activeTab === 'copilot' && (
|
60 |
+
<AiCoPilotPanel
|
61 |
+
activeEditor={activeEditor}
|
62 |
+
onRefine={onRefine}
|
63 |
+
isLoading={isRefining}
|
64 |
+
/>
|
65 |
+
)}
|
66 |
+
</div>
|
67 |
+
</div>
|
68 |
+
);
|
69 |
+
}
|
components/DesignDisplay.tsx
ADDED
File without changes
|
components/EditableSection.tsx
ADDED
@@ -0,0 +1,52 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
/**
|
2 |
+
* @license
|
3 |
+
* SPDX-License-Identifier: Apache-2.0
|
4 |
+
*/
|
5 |
+
import React, { useRef, useEffect } from 'react';
|
6 |
+
import type { ResumeSection, ResumeSectionType } from '../lib/types';
|
7 |
+
|
8 |
+
interface EditableSectionProps {
|
9 |
+
section: ResumeSection;
|
10 |
+
onContentChange: (sectionId: ResumeSectionType, newContent: string) => void;
|
11 |
+
isActive: boolean;
|
12 |
+
onFocus: (sectionId: ResumeSectionType, content: string) => void;
|
13 |
+
onBlur: () => void;
|
14 |
+
}
|
15 |
+
|
16 |
+
export default function EditableSection({
|
17 |
+
section,
|
18 |
+
onContentChange,
|
19 |
+
isActive,
|
20 |
+
onFocus,
|
21 |
+
onBlur,
|
22 |
+
}: EditableSectionProps) {
|
23 |
+
const contentRef = useRef<HTMLDivElement>(null);
|
24 |
+
|
25 |
+
useEffect(() => {
|
26 |
+
if (contentRef.current && section.content !== contentRef.current.innerText) {
|
27 |
+
contentRef.current.innerText = section.content;
|
28 |
+
}
|
29 |
+
}, [section.content]);
|
30 |
+
|
31 |
+
const handleInput = (e: React.FormEvent<HTMLDivElement>) => {
|
32 |
+
onContentChange(section.id, e.currentTarget.innerText);
|
33 |
+
};
|
34 |
+
|
35 |
+
return (
|
36 |
+
<div
|
37 |
+
className={`editable-section ${isActive ? 'active' : ''}`}
|
38 |
+
onFocus={() => onFocus(section.id, contentRef.current?.innerText || '')}
|
39 |
+
onBlur={onBlur}
|
40 |
+
>
|
41 |
+
<h2 className="section-title">{section.title}</h2>
|
42 |
+
<div
|
43 |
+
ref={contentRef}
|
44 |
+
className="section-content"
|
45 |
+
contentEditable
|
46 |
+
suppressContentEditableWarning={true}
|
47 |
+
onInput={handleInput}
|
48 |
+
aria-label={`${section.title} content`}
|
49 |
+
/>
|
50 |
+
</div>
|
51 |
+
);
|
52 |
+
}
|
components/ExampleGallery.tsx
ADDED
File without changes
|
components/FashionInputForm.tsx
ADDED
File without changes
|
components/InitialInputForm.tsx
ADDED
@@ -0,0 +1,136 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
/**
|
2 |
+
* @license
|
3 |
+
* SPDX-License-Identifier: Apache-2.0
|
4 |
+
*/
|
5 |
+
import React, { useState, ChangeEvent } from 'react';
|
6 |
+
import type { InitialInput } from '../lib/types';
|
7 |
+
import { getDocument, GlobalWorkerOptions } from 'pdfjs-dist';
|
8 |
+
import mammoth from 'mammoth';
|
9 |
+
|
10 |
+
// Set workerSrc for pdf.js. This is crucial for it to work.
|
11 |
+
GlobalWorkerOptions.workerSrc = 'pdfjs-dist/build/pdf.worker.min.js';
|
12 |
+
|
13 |
+
interface InitialInputFormProps {
|
14 |
+
onSubmit: (formData: InitialInput) => void;
|
15 |
+
onClear: () => void;
|
16 |
+
disabled: boolean;
|
17 |
+
isLoading: boolean;
|
18 |
+
}
|
19 |
+
|
20 |
+
const initialFormState: InitialInput = {
|
21 |
+
fullName: '',
|
22 |
+
jobTitle: '',
|
23 |
+
careerGoal: '',
|
24 |
+
jobDescription: '',
|
25 |
+
uploadedResumeContent: '',
|
26 |
+
yearsOfExperience: '2-4 Years',
|
27 |
+
keySkills: '',
|
28 |
+
previousRoles: '',
|
29 |
+
education: '',
|
30 |
+
};
|
31 |
+
|
32 |
+
export default function InitialInputForm({ onSubmit, onClear, disabled, isLoading }: InitialInputFormProps) {
|
33 |
+
const [formData, setFormData] = useState<InitialInput>(initialFormState);
|
34 |
+
const [selectedFileName, setSelectedFileName] = useState<string>('');
|
35 |
+
const [fileError, setFileError] = useState<string | null>(null);
|
36 |
+
|
37 |
+
const handleChange = (e: React.ChangeEvent<HTMLInputElement | HTMLSelectElement | HTMLTextAreaElement>) => {
|
38 |
+
const { name, value } = e.target;
|
39 |
+
setFormData(prev => ({ ...prev, [name]: value }));
|
40 |
+
};
|
41 |
+
|
42 |
+
const handleFileChange = async (e: ChangeEvent<HTMLInputElement>) => {
|
43 |
+
const file = e.target.files?.[0];
|
44 |
+
setFileError(null);
|
45 |
+
setSelectedFileName('');
|
46 |
+
setFormData(prev => ({ ...prev, uploadedResumeContent: '' }));
|
47 |
+
|
48 |
+
if (file) {
|
49 |
+
setSelectedFileName(file.name);
|
50 |
+
try {
|
51 |
+
let text = '';
|
52 |
+
if (file.name.toLowerCase().endsWith('.txt')) {
|
53 |
+
text = await file.text();
|
54 |
+
} else if (file.name.toLowerCase().endsWith('.pdf')) {
|
55 |
+
const arrayBuffer = await file.arrayBuffer();
|
56 |
+
const pdf = await getDocument({ data: arrayBuffer }).promise;
|
57 |
+
let pdfText = '';
|
58 |
+
for (let i = 1; i <= pdf.numPages; i++) {
|
59 |
+
const page = await pdf.getPage(i);
|
60 |
+
const textContent = await page.getTextContent();
|
61 |
+
pdfText += textContent.items.map((item: any) => item.str).join(' ') + '\n';
|
62 |
+
}
|
63 |
+
text = pdfText;
|
64 |
+
} else if (file.name.toLowerCase().endsWith('.docx')) {
|
65 |
+
const arrayBuffer = await file.arrayBuffer();
|
66 |
+
const result = await mammoth.extractRawText({ arrayBuffer });
|
67 |
+
text = result.value;
|
68 |
+
} else {
|
69 |
+
setFileError("Unsupported file type. Please use .txt, .pdf, or .docx.");
|
70 |
+
return;
|
71 |
+
}
|
72 |
+
setFormData(prev => ({ ...prev, uploadedResumeContent: text }));
|
73 |
+
} catch (error) {
|
74 |
+
setFileError(`Error processing file: ${error instanceof Error ? error.message : "Unknown error"}.`);
|
75 |
+
}
|
76 |
+
}
|
77 |
+
};
|
78 |
+
|
79 |
+
const handleSubmit = (e: React.FormEvent) => {
|
80 |
+
e.preventDefault();
|
81 |
+
if (fileError) {
|
82 |
+
alert(`Cannot submit: Please resolve the file error first. ${fileError}`);
|
83 |
+
return;
|
84 |
+
}
|
85 |
+
onSubmit(formData);
|
86 |
+
};
|
87 |
+
|
88 |
+
const handleClear = () => {
|
89 |
+
setFormData(initialFormState);
|
90 |
+
setSelectedFileName('');
|
91 |
+
setFileError(null);
|
92 |
+
const fileInput = document.getElementById('resumeUpload') as HTMLInputElement;
|
93 |
+
if (fileInput) fileInput.value = '';
|
94 |
+
onClear();
|
95 |
+
};
|
96 |
+
|
97 |
+
return (
|
98 |
+
<form onSubmit={handleSubmit} className="initial-input-form">
|
99 |
+
<h2>Initial Setup</h2>
|
100 |
+
|
101 |
+
{/* Form groups for all inputs */}
|
102 |
+
<div className="form-group">
|
103 |
+
<label htmlFor="fullName">Full Name</label>
|
104 |
+
<input type="text" id="fullName" name="fullName" value={formData.fullName} onChange={handleChange} placeholder="e.g., Jane Doe" required disabled={disabled}/>
|
105 |
+
</div>
|
106 |
+
<div className="form-group">
|
107 |
+
<label htmlFor="jobTitle">Current or Target Job Title</label>
|
108 |
+
<input type="text" id="jobTitle" name="jobTitle" value={formData.jobTitle} onChange={handleChange} placeholder="e.g., Senior Software Engineer" required disabled={disabled}/>
|
109 |
+
</div>
|
110 |
+
<div className="form-group">
|
111 |
+
<label htmlFor="jobDescription">Target Job Description (Crucial for Scoring)</label>
|
112 |
+
<textarea id="jobDescription" name="jobDescription" value={formData.jobDescription} onChange={handleChange} placeholder="Paste the job description here..." rows={6} disabled={disabled}/>
|
113 |
+
</div>
|
114 |
+
<div className="form-group">
|
115 |
+
<label htmlFor="resumeUpload">Upload Existing Resume (Optional)</label>
|
116 |
+
<input type="file" id="resumeUpload" name="resumeUpload" accept=".txt,.pdf,.docx" onChange={handleFileChange} disabled={disabled}/>
|
117 |
+
<small className="form-text text-muted">{selectedFileName ? `Selected: ${selectedFileName}` : "Upload for richer context."}</small>
|
118 |
+
{fileError && <p role="alert" style={{color:'var(--color-error)',fontSize:'0.9em',marginTop:'0.5em'}}>{fileError}</p>}
|
119 |
+
</div>
|
120 |
+
<div className="form-group">
|
121 |
+
<label htmlFor="keySkills">Key Skills (to supplement resume)</label>
|
122 |
+
<textarea id="keySkills" name="keySkills" value={formData.keySkills} onChange={handleChange} placeholder="List skills not detailed in your resume..." rows={3} disabled={disabled}/>
|
123 |
+
</div>
|
124 |
+
{/* Additional fields like careerGoal, yearsOfExperience etc. can be added here if needed */}
|
125 |
+
|
126 |
+
<div className="form-actions" style={{marginTop: '1.5rem', display: 'flex', gap: '1rem'}}>
|
127 |
+
<button type="submit" className={`submit-button ${isLoading ? 'is-loading' : ''}`} disabled={disabled}>
|
128 |
+
<span>{isLoading ? 'Generating...' : 'Generate Resume'}</span>
|
129 |
+
</button>
|
130 |
+
<button type="button" className="clear-button" onClick={handleClear} disabled={disabled}>
|
131 |
+
Clear All
|
132 |
+
</button>
|
133 |
+
</div>
|
134 |
+
</form>
|
135 |
+
);
|
136 |
+
}
|
components/JobMatchDashboard.tsx
ADDED
@@ -0,0 +1,59 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
/**
|
2 |
+
* @license
|
3 |
+
* SPDX-License-Identifier: Apache-2.0
|
4 |
+
*/
|
5 |
+
import React from 'react';
|
6 |
+
import LoadingSpinner from './LoadingSpinner';
|
7 |
+
|
8 |
+
interface JobMatchDashboardProps {
|
9 |
+
score: number;
|
10 |
+
suggestions: string[];
|
11 |
+
isLoading: boolean;
|
12 |
+
hasContent: boolean;
|
13 |
+
}
|
14 |
+
|
15 |
+
export default function JobMatchDashboard({ score, suggestions, isLoading, hasContent }: JobMatchDashboardProps) {
|
16 |
+
const gaugeRotation = (score / 100) * 180; // 0 score = 0deg, 100 score = 180deg
|
17 |
+
|
18 |
+
if (!hasContent) {
|
19 |
+
return (
|
20 |
+
<div className="dashboard">
|
21 |
+
<div className="copilot-placeholder">
|
22 |
+
<i className="ph-lightbulb" style={{fontSize: '2rem', marginBottom: '0.5rem'}}></i>
|
23 |
+
Your Job Match Score and AI Insights will appear here once a resume is generated.
|
24 |
+
</div>
|
25 |
+
</div>
|
26 |
+
)
|
27 |
+
}
|
28 |
+
|
29 |
+
return (
|
30 |
+
<div className="dashboard">
|
31 |
+
<h2>Live Analysis</h2>
|
32 |
+
|
33 |
+
<div className="score-gauge">
|
34 |
+
<div className="score-gauge-bg"></div>
|
35 |
+
<div
|
36 |
+
className="score-gauge-fill"
|
37 |
+
style={{ '--gauge-rotation': `${gaugeRotation}deg` } as React.CSSProperties}
|
38 |
+
></div>
|
39 |
+
<div className="score-text">{score}<span>/100</span></div>
|
40 |
+
</div>
|
41 |
+
|
42 |
+
<div className="suggestions-list">
|
43 |
+
<h3>AI Suggestions {isLoading && <small>(Updating...)</small>}</h3>
|
44 |
+
{suggestions.length > 0 ? (
|
45 |
+
<ul>
|
46 |
+
{suggestions.map((suggestion, index) => (
|
47 |
+
<li key={index} className="suggestion-item">
|
48 |
+
<i className="ph-check-circle"></i>
|
49 |
+
<span>{suggestion}</span>
|
50 |
+
</li>
|
51 |
+
))}
|
52 |
+
</ul>
|
53 |
+
) : (
|
54 |
+
<p>No suggestions at the moment. Looks good!</p>
|
55 |
+
)}
|
56 |
+
</div>
|
57 |
+
</div>
|
58 |
+
);
|
59 |
+
}
|
components/LoadingSpinner.tsx
ADDED
@@ -0,0 +1,14 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
/**
|
2 |
+
* @license
|
3 |
+
* SPDX-License-Identifier: Apache-2.0
|
4 |
+
*/
|
5 |
+
import React from 'react';
|
6 |
+
|
7 |
+
export default function LoadingSpinner() {
|
8 |
+
return (
|
9 |
+
<div className="loading-spinner-overlay" role="status" aria-live="polite" aria-label="Loading content">
|
10 |
+
<div className="spinner-animation"></div>
|
11 |
+
<p>Crafting your content...</p>
|
12 |
+
</div>
|
13 |
+
);
|
14 |
+
}
|
components/ResumePreview.tsx
ADDED
@@ -0,0 +1,56 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
/**
|
2 |
+
* @license
|
3 |
+
* SPDX-License-Identifier: Apache-2.0
|
4 |
+
*/
|
5 |
+
import React from 'react';
|
6 |
+
import type { ResumeDocument, ResumeSectionType, ActiveEditor } from '../lib/types';
|
7 |
+
import EditableSection from './EditableSection';
|
8 |
+
|
9 |
+
interface ResumePreviewProps {
|
10 |
+
document: ResumeDocument;
|
11 |
+
onContentChange: (sectionId: ResumeSectionType, newContent: string) => void;
|
12 |
+
activeSectionId: ResumeSectionType | undefined;
|
13 |
+
onSectionFocus: (editorState: ActiveEditor | null) => void;
|
14 |
+
}
|
15 |
+
|
16 |
+
const ORDERED_SECTION_KEYS: ResumeSectionType[] = ["Professional Summary", "Skills Section", "Experience", "Education"];
|
17 |
+
|
18 |
+
export default function ResumePreview({
|
19 |
+
document,
|
20 |
+
onContentChange,
|
21 |
+
activeSectionId,
|
22 |
+
onSectionFocus
|
23 |
+
}: ResumePreviewProps) {
|
24 |
+
|
25 |
+
const handleFocus = (sectionId: ResumeSectionType, content: string) => {
|
26 |
+
onSectionFocus({ sectionId, content });
|
27 |
+
};
|
28 |
+
|
29 |
+
const handleBlur = () => {
|
30 |
+
onSectionFocus(null);
|
31 |
+
};
|
32 |
+
|
33 |
+
// Create a map for quick lookup
|
34 |
+
const docMap = new Map(document.map(section => [section.id, section]));
|
35 |
+
|
36 |
+
return (
|
37 |
+
<div className="resume-preview-paper" aria-label="Live Resume Preview" tabIndex={-1}>
|
38 |
+
{ORDERED_SECTION_KEYS.map((key) => {
|
39 |
+
const section = docMap.get(key);
|
40 |
+
if (section) {
|
41 |
+
return (
|
42 |
+
<EditableSection
|
43 |
+
key={section.id}
|
44 |
+
section={section}
|
45 |
+
onContentChange={onContentChange}
|
46 |
+
isActive={activeSectionId === section.id}
|
47 |
+
onFocus={handleFocus}
|
48 |
+
onBlur={handleBlur}
|
49 |
+
/>
|
50 |
+
);
|
51 |
+
}
|
52 |
+
return null; // Don't render a section if it's not in the document
|
53 |
+
})}
|
54 |
+
</div>
|
55 |
+
);
|
56 |
+
}
|