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:
geulah 2025-10-05 23:26:33 +01:00
parent 1bede93cd1
commit 46e1f245f8
5 changed files with 468 additions and 5 deletions

View File

@ -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':

View 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;

View 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;

View 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;

View File

@ -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;
},