feat(editors): implement work, education and skills editors
Add new editor components for work experience, education and skills sections Add validation logic for these sections in the store Replace placeholder content with actual editor implementations
This commit is contained in:
parent
1bede93cd1
commit
46e1f245f8
@ -1,6 +1,9 @@
|
||||
import React from 'react';
|
||||
import Stepper from './components/Stepper';
|
||||
import PersonalEditor from './editors/PersonalEditor';
|
||||
import WorkEditor from './editors/WorkEditor';
|
||||
import EducationEditor from './editors/EducationEditor';
|
||||
import SkillsEditor from './editors/SkillsEditor';
|
||||
import PreviewPanel from './components/PreviewPanel';
|
||||
import { useActiveStep } from './store/cvStore';
|
||||
|
||||
@ -13,11 +16,11 @@ const App: React.FC = () => {
|
||||
case 'personal':
|
||||
return <PersonalEditor />;
|
||||
case 'work':
|
||||
return <div className="p-6 bg-white rounded-lg shadow-sm">Work Experience editor will be implemented in M2</div>;
|
||||
return <WorkEditor />;
|
||||
case 'education':
|
||||
return <div className="p-6 bg-white rounded-lg shadow-sm">Education editor will be implemented in M2</div>;
|
||||
return <EducationEditor />;
|
||||
case 'skills':
|
||||
return <div className="p-6 bg-white rounded-lg shadow-sm">Skills editor will be implemented in M2</div>;
|
||||
return <SkillsEditor />;
|
||||
case 'summary':
|
||||
return <div className="p-6 bg-white rounded-lg shadow-sm">Summary editor will be implemented in M3</div>;
|
||||
case 'finalize':
|
||||
|
||||
130
cv-engine/src/editors/EducationEditor.tsx
Normal file
130
cv-engine/src/editors/EducationEditor.tsx
Normal file
@ -0,0 +1,130 @@
|
||||
import React from 'react';
|
||||
import { useEducationData, useCvStore } from '../store/cvStore';
|
||||
|
||||
const EducationEditor: React.FC = () => {
|
||||
const education = useEducationData();
|
||||
const { addEducationItem, updateEducationItem, removeEducationItem, reorderEducationItems } = useCvStore();
|
||||
|
||||
const handleFieldChange = (id: string, field: string, value: string) => {
|
||||
updateEducationItem(id, { [field]: value } as any);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="p-6 bg-white rounded-lg shadow-sm">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h2 className="text-xl font-semibold">Education</h2>
|
||||
<button
|
||||
type="button"
|
||||
onClick={addEducationItem}
|
||||
className="px-3 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700"
|
||||
>
|
||||
Add Education
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{education.length === 0 && (
|
||||
<p className="text-gray-600">No education items yet. Click "Add Education" to start.</p>
|
||||
)}
|
||||
|
||||
<div className="space-y-6">
|
||||
{education.map((ed, idx) => {
|
||||
const requiredError = {
|
||||
degree: !ed.degree,
|
||||
school: !ed.school,
|
||||
startDate: !ed.startDate,
|
||||
};
|
||||
return (
|
||||
<div key={ed.id} className="border rounded-md p-4">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<div className="text-sm text-gray-500">Education #{idx + 1}</div>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => reorderEducationItems(idx, Math.max(0, idx - 1))}
|
||||
disabled={idx === 0}
|
||||
className={`px-2 py-1 rounded-md text-sm ${idx === 0 ? 'bg-gray-200 text-gray-400' : 'bg-gray-100 text-gray-700 hover:bg-gray-200'}`}
|
||||
>
|
||||
Up
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => reorderEducationItems(idx, Math.min(education.length - 1, idx + 1))}
|
||||
disabled={idx === education.length - 1}
|
||||
className={`px-2 py-1 rounded-md text-sm ${idx === education.length - 1 ? 'bg-gray-200 text-gray-400' : 'bg-gray-100 text-gray-700 hover:bg-gray-200'}`}
|
||||
>
|
||||
Down
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => removeEducationItem(ed.id)}
|
||||
className="px-2 py-1 rounded-md text-sm bg-red-600 text-white hover:bg-red-700"
|
||||
>
|
||||
Remove
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700">Degree</label>
|
||||
<input
|
||||
type="text"
|
||||
value={ed.degree}
|
||||
onChange={(e) => handleFieldChange(ed.id, 'degree', e.target.value)}
|
||||
className={`w-full px-3 py-2 border rounded-md ${requiredError.degree ? 'border-red-500' : 'border-gray-300'}`}
|
||||
/>
|
||||
{requiredError.degree && <p className="mt-1 text-sm text-red-600">Degree is required</p>}
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700">School</label>
|
||||
<input
|
||||
type="text"
|
||||
value={ed.school}
|
||||
onChange={(e) => handleFieldChange(ed.id, 'school', e.target.value)}
|
||||
className={`w-full px-3 py-2 border rounded-md ${requiredError.school ? 'border-red-500' : 'border-gray-300'}`}
|
||||
/>
|
||||
{requiredError.school && <p className="mt-1 text-sm text-red-600">School name is required</p>}
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700">Start Date</label>
|
||||
<input
|
||||
type="month"
|
||||
value={ed.startDate || ''}
|
||||
onChange={(e) => handleFieldChange(ed.id, 'startDate', e.target.value)}
|
||||
className={`w-full px-3 py-2 border rounded-md ${requiredError.startDate ? 'border-red-500' : 'border-gray-300'}`}
|
||||
/>
|
||||
{requiredError.startDate && <p className="mt-1 text-sm text-red-600">Start date is required</p>}
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700">End Date</label>
|
||||
<input
|
||||
type="month"
|
||||
value={ed.endDate || ''}
|
||||
onChange={(e) => handleFieldChange(ed.id, 'endDate', e.target.value)}
|
||||
className="w-full px-3 py-2 border rounded-md border-gray-300"
|
||||
/>
|
||||
<p className="mt-1 text-xs text-gray-500">Leave empty if ongoing</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-4">
|
||||
<label className="block text-sm font-medium text-gray-700">Notes</label>
|
||||
<textarea
|
||||
value={ed.notes || ''}
|
||||
onChange={(e) => handleFieldChange(ed.id, 'notes', e.target.value)}
|
||||
rows={2}
|
||||
className="w-full px-3 py-2 border rounded-md border-gray-300"
|
||||
placeholder="Honors, GPA, relevant coursework"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default EducationEditor;
|
||||
92
cv-engine/src/editors/SkillsEditor.tsx
Normal file
92
cv-engine/src/editors/SkillsEditor.tsx
Normal file
@ -0,0 +1,92 @@
|
||||
import React, { useState } from 'react';
|
||||
import { useSkillsData, useCvStore } from '../store/cvStore';
|
||||
|
||||
const SkillsEditor: React.FC = () => {
|
||||
const skills = useSkillsData();
|
||||
const { addSkill, updateSkill, removeSkill } = useCvStore();
|
||||
const [newSkillName, setNewSkillName] = useState('');
|
||||
const [newSkillLevel, setNewSkillLevel] = useState<'Beginner' | 'Intermediate' | 'Advanced' | undefined>(undefined);
|
||||
|
||||
const handleAddSkill = () => {
|
||||
const name = newSkillName.trim();
|
||||
if (!name) return;
|
||||
addSkill(name, newSkillLevel);
|
||||
setNewSkillName('');
|
||||
setNewSkillLevel(undefined);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="p-6 bg-white rounded-lg shadow-sm">
|
||||
<h2 className="text-xl font-semibold mb-4">Skills</h2>
|
||||
|
||||
<div className="flex flex-col sm:flex-row gap-2 sm:items-end mb-4">
|
||||
<div className="flex-1">
|
||||
<label className="block text-sm font-medium text-gray-700">Skill name</label>
|
||||
<input
|
||||
type="text"
|
||||
value={newSkillName}
|
||||
onChange={(e) => setNewSkillName(e.target.value)}
|
||||
placeholder="e.g. React, TypeScript, SQL"
|
||||
className="w-full px-3 py-2 border rounded-md border-gray-300"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700">Level (optional)</label>
|
||||
<select
|
||||
value={newSkillLevel || ''}
|
||||
onChange={(e) => setNewSkillLevel((e.target.value as any) || undefined)}
|
||||
className="px-3 py-2 border rounded-md border-gray-300"
|
||||
>
|
||||
<option value="">None</option>
|
||||
<option value="Beginner">Beginner</option>
|
||||
<option value="Intermediate">Intermediate</option>
|
||||
<option value="Advanced">Advanced</option>
|
||||
</select>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleAddSkill}
|
||||
className="px-3 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700"
|
||||
>
|
||||
Add Skill
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{skills.length === 0 && (
|
||||
<p className="text-gray-600">No skills added yet. Add skills to showcase your strengths.</p>
|
||||
)}
|
||||
|
||||
<div className="space-y-2">
|
||||
{skills.map((skill) => (
|
||||
<div key={skill.id} className="flex items-center gap-2">
|
||||
<input
|
||||
type="text"
|
||||
value={skill.name}
|
||||
onChange={(e) => updateSkill(skill.id, e.target.value, skill.level)}
|
||||
className="flex-1 px-3 py-2 border rounded-md border-gray-300"
|
||||
/>
|
||||
<select
|
||||
value={skill.level || ''}
|
||||
onChange={(e) => updateSkill(skill.id, skill.name, (e.target.value as any) || undefined)}
|
||||
className="px-3 py-2 border rounded-md border-gray-300"
|
||||
>
|
||||
<option value="">None</option>
|
||||
<option value="Beginner">Beginner</option>
|
||||
<option value="Intermediate">Intermediate</option>
|
||||
<option value="Advanced">Advanced</option>
|
||||
</select>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => removeSkill(skill.id)}
|
||||
className="px-2 py-1 rounded-md text-sm bg-red-600 text-white hover:bg-red-700"
|
||||
>
|
||||
Remove
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default SkillsEditor;
|
||||
198
cv-engine/src/editors/WorkEditor.tsx
Normal file
198
cv-engine/src/editors/WorkEditor.tsx
Normal file
@ -0,0 +1,198 @@
|
||||
import React from 'react';
|
||||
import { useWorkData, useCvStore } from '../store/cvStore';
|
||||
|
||||
const WorkEditor: React.FC = () => {
|
||||
const work = useWorkData();
|
||||
const {
|
||||
addWorkItem,
|
||||
updateWorkItem,
|
||||
removeWorkItem,
|
||||
reorderWorkItems,
|
||||
} = useCvStore();
|
||||
|
||||
const handleFieldChange = (id: string, field: string, value: string) => {
|
||||
updateWorkItem(id, { [field]: value } as any);
|
||||
};
|
||||
|
||||
const handleAddBullet = (id: string) => {
|
||||
const item = work.find(w => w.id === id);
|
||||
if (!item) return;
|
||||
if ((item.bullets || []).length >= 6) return;
|
||||
const bullets = [...(item.bullets || []), ''];
|
||||
updateWorkItem(id, { bullets });
|
||||
};
|
||||
|
||||
const handleBulletChange = (id: string, index: number, value: string) => {
|
||||
const item = work.find(w => w.id === id);
|
||||
if (!item) return;
|
||||
const bullets = [...(item.bullets || [])];
|
||||
bullets[index] = value;
|
||||
updateWorkItem(id, { bullets });
|
||||
};
|
||||
|
||||
const handleRemoveBullet = (id: string, index: number) => {
|
||||
const item = work.find(w => w.id === id);
|
||||
if (!item) return;
|
||||
const bullets = [...(item.bullets || [])];
|
||||
bullets.splice(index, 1);
|
||||
updateWorkItem(id, { bullets });
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="p-6 bg-white rounded-lg shadow-sm">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h2 className="text-xl font-semibold">Work Experience</h2>
|
||||
<button
|
||||
type="button"
|
||||
onClick={addWorkItem}
|
||||
className="px-3 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700"
|
||||
>
|
||||
Add Job
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{work.length === 0 && (
|
||||
<p className="text-gray-600">No jobs added yet. Click "Add Job" to start.</p>
|
||||
)}
|
||||
|
||||
<div className="space-y-6">
|
||||
{work.map((job, idx) => {
|
||||
const requiredError = {
|
||||
title: !job.title,
|
||||
company: !job.company,
|
||||
startDate: !job.startDate,
|
||||
};
|
||||
return (
|
||||
<div key={job.id} className="border rounded-md p-4">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<div className="text-sm text-gray-500">Job #{idx + 1}</div>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => reorderWorkItems(idx, Math.max(0, idx - 1))}
|
||||
disabled={idx === 0}
|
||||
className={`px-2 py-1 rounded-md text-sm ${idx === 0 ? 'bg-gray-200 text-gray-400' : 'bg-gray-100 text-gray-700 hover:bg-gray-200'}`}
|
||||
>
|
||||
Up
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => reorderWorkItems(idx, Math.min(work.length - 1, idx + 1))}
|
||||
disabled={idx === work.length - 1}
|
||||
className={`px-2 py-1 rounded-md text-sm ${idx === work.length - 1 ? 'bg-gray-200 text-gray-400' : 'bg-gray-100 text-gray-700 hover:bg-gray-200'}`}
|
||||
>
|
||||
Down
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => removeWorkItem(job.id)}
|
||||
className="px-2 py-1 rounded-md text-sm bg-red-600 text-white hover:bg-red-700"
|
||||
>
|
||||
Remove
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700">Job Title</label>
|
||||
<input
|
||||
type="text"
|
||||
value={job.title}
|
||||
onChange={(e) => handleFieldChange(job.id, 'title', e.target.value)}
|
||||
className={`w-full px-3 py-2 border rounded-md ${requiredError.title ? 'border-red-500' : 'border-gray-300'}`}
|
||||
/>
|
||||
{requiredError.title && <p className="mt-1 text-sm text-red-600">Job title is required</p>}
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700">Company</label>
|
||||
<input
|
||||
type="text"
|
||||
value={job.company}
|
||||
onChange={(e) => handleFieldChange(job.id, 'company', e.target.value)}
|
||||
className={`w-full px-3 py-2 border rounded-md ${requiredError.company ? 'border-red-500' : 'border-gray-300'}`}
|
||||
/>
|
||||
{requiredError.company && <p className="mt-1 text-sm text-red-600">Company name is required</p>}
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700">Location</label>
|
||||
<input
|
||||
type="text"
|
||||
value={job.location || ''}
|
||||
onChange={(e) => handleFieldChange(job.id, 'location', e.target.value)}
|
||||
className="w-full px-3 py-2 border rounded-md border-gray-300"
|
||||
/>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700">Start Date</label>
|
||||
<input
|
||||
type="month"
|
||||
value={job.startDate || ''}
|
||||
onChange={(e) => handleFieldChange(job.id, 'startDate', e.target.value)}
|
||||
className={`w-full px-3 py-2 border rounded-md ${requiredError.startDate ? 'border-red-500' : 'border-gray-300'}`}
|
||||
/>
|
||||
{requiredError.startDate && <p className="mt-1 text-sm text-red-600">Start date is required</p>}
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700">End Date</label>
|
||||
<input
|
||||
type="month"
|
||||
value={job.endDate || ''}
|
||||
onChange={(e) => handleFieldChange(job.id, 'endDate', e.target.value)}
|
||||
className="w-full px-3 py-2 border rounded-md border-gray-300"
|
||||
/>
|
||||
<p className="mt-1 text-xs text-gray-500">Leave empty if current</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-4">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<label className="text-sm font-medium text-gray-700">Bullets</label>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleAddBullet(job.id)}
|
||||
disabled={(job.bullets || []).length >= 6}
|
||||
className={`px-2 py-1 rounded-md text-sm ${((job.bullets || []).length >= 6) ? 'bg-gray-200 text-gray-400' : 'bg-gray-100 text-gray-700 hover:bg-gray-200'}`}
|
||||
>
|
||||
Add Bullet
|
||||
</button>
|
||||
</div>
|
||||
<p className="text-xs text-gray-500 mb-2">Max 6 bullets. Aim for concise, impactful achievements (≤160 chars).</p>
|
||||
<div className="space-y-2">
|
||||
{(job.bullets || []).map((b, i) => {
|
||||
const tooLong = (b || '').length > 160;
|
||||
return (
|
||||
<div key={i} className="flex items-start gap-2">
|
||||
<textarea
|
||||
value={b}
|
||||
onChange={(e) => handleBulletChange(job.id, i, e.target.value)}
|
||||
rows={2}
|
||||
className={`flex-1 px-3 py-2 border rounded-md ${tooLong ? 'border-red-500' : 'border-gray-300'}`}
|
||||
placeholder="Led X to achieve Y by doing Z"
|
||||
/>
|
||||
<div className="flex flex-col items-end gap-1">
|
||||
<span className={`text-xs ${tooLong ? 'text-red-600' : 'text-gray-500'}`}>{(b || '').length}/160</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleRemoveBullet(job.id, i)}
|
||||
className="px-2 py-1 rounded-md text-sm bg-red-600 text-white hover:bg-red-700"
|
||||
>
|
||||
Remove
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default WorkEditor;
|
||||
@ -285,8 +285,48 @@ export const useCvStore = create<CvState>((set, get) => ({
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
// Add validation for other sections as needed
|
||||
if (activeStep === 'work') {
|
||||
const result = validateSection('work', cv.work);
|
||||
isValid = result.success;
|
||||
if (!result.success) {
|
||||
set((state) => ({
|
||||
errors: {
|
||||
...state.errors,
|
||||
work: result.error.issues.map(i => i.message)
|
||||
}
|
||||
}));
|
||||
} else {
|
||||
set((state) => ({ errors: { ...state.errors, work: [] } }));
|
||||
}
|
||||
}
|
||||
if (activeStep === 'education') {
|
||||
const result = validateSection('education', cv.education);
|
||||
isValid = result.success;
|
||||
if (!result.success) {
|
||||
set((state) => ({
|
||||
errors: {
|
||||
...state.errors,
|
||||
education: result.error.issues.map(i => i.message)
|
||||
}
|
||||
}));
|
||||
} else {
|
||||
set((state) => ({ errors: { ...state.errors, education: [] } }));
|
||||
}
|
||||
}
|
||||
if (activeStep === 'skills') {
|
||||
const result = validateSection('skills', cv.skills);
|
||||
isValid = result.success;
|
||||
if (!result.success) {
|
||||
set((state) => ({
|
||||
errors: {
|
||||
...state.errors,
|
||||
skills: result.error.issues.map(i => i.message)
|
||||
}
|
||||
}));
|
||||
} else {
|
||||
set((state) => ({ errors: { ...state.errors, skills: [] } }));
|
||||
}
|
||||
}
|
||||
|
||||
return isValid;
|
||||
},
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user