- Implement new timeline template with left sidebar and vertical timeline design - Add support for headline and reference lines in personal editor - Update printable HTML builder to support timeline template styling - Include timeline template thumbnail and registry entry
470 lines
17 KiB
TypeScript
470 lines
17 KiB
TypeScript
import React, { useState, useEffect } from 'react';
|
|
import { usePersonalData, useCvStore, useTemplateId } from '../store/cvStore';
|
|
import { templatesMap } from '../templates/registry';
|
|
import { normalizeUrl } from '../schema/cvSchema';
|
|
|
|
const PersonalEditor: React.FC = () => {
|
|
const personalData = usePersonalData();
|
|
const { updatePersonal } = useCvStore();
|
|
const templateId = useTemplateId();
|
|
const supportsPhoto = !!templatesMap[templateId]?.supportsPhoto;
|
|
const [formData, setFormData] = useState(personalData);
|
|
const [errors, setErrors] = useState<Record<string, string>>({});
|
|
|
|
useEffect(() => {
|
|
setFormData(personalData);
|
|
}, [personalData]);
|
|
|
|
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
|
const { name, value } = e.target;
|
|
setFormData(prev => ({
|
|
...prev,
|
|
[name]: value
|
|
}));
|
|
|
|
// Clear error when user starts typing
|
|
if (errors[name]) {
|
|
setErrors(prev => ({ ...prev, [name]: '' }));
|
|
}
|
|
};
|
|
|
|
const handleBlur = (e: React.FocusEvent<HTMLInputElement>) => {
|
|
const { name, value } = e.target;
|
|
|
|
// Validate on blur
|
|
if (name === 'firstName' && !value.trim()) {
|
|
setErrors(prev => ({ ...prev, firstName: 'First name is required' }));
|
|
} else if (name === 'lastName' && !value.trim()) {
|
|
setErrors(prev => ({ ...prev, lastName: 'Last name is required' }));
|
|
} else if (name === 'email') {
|
|
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
|
if (!value.trim()) {
|
|
setErrors(prev => ({ ...prev, email: 'Email is required' }));
|
|
} else if (!emailRegex.test(value)) {
|
|
setErrors(prev => ({ ...prev, email: 'Invalid email format' }));
|
|
}
|
|
} else if (name === 'photoUrl' && value.trim()) {
|
|
// Basic URL normalization and validation
|
|
const normalized = normalizeUrl(value.trim());
|
|
if (!/^https?:\/\//i.test(normalized)) {
|
|
setErrors(prev => ({ ...prev, photoUrl: 'URL must start with http:// or https://' }));
|
|
} else {
|
|
setFormData(prev => ({ ...prev, photoUrl: normalized }));
|
|
}
|
|
}
|
|
|
|
// Update store on blur if no errors
|
|
if (!Object.values(errors).some(error => error)) {
|
|
updatePersonal(formData);
|
|
}
|
|
};
|
|
|
|
const handleAddLink = () => {
|
|
const newLinks = [...formData.links, { id: Math.random().toString(36).substring(2, 9), label: '', url: '' }];
|
|
setFormData(prev => ({ ...prev, links: newLinks }));
|
|
};
|
|
|
|
const handleLinkChange = (id: string, field: 'label' | 'url', value: string) => {
|
|
const updatedLinks = formData.links.map(link =>
|
|
link.id === id ? { ...link, [field]: value } : link
|
|
);
|
|
setFormData(prev => ({ ...prev, links: updatedLinks }));
|
|
|
|
// Clear link errors
|
|
if (errors[`link-${id}-${field}`]) {
|
|
setErrors(prev => ({ ...prev, [`link-${id}-${field}`]: '' }));
|
|
}
|
|
};
|
|
|
|
const handleLinkBlur = (id: string, field: 'label' | 'url', value: string) => {
|
|
let hasError = false;
|
|
|
|
if (field === 'url' && value) {
|
|
try {
|
|
// Normalize and validate URL
|
|
const normalizedUrl = normalizeUrl(value);
|
|
const updatedLinks = formData.links.map(link =>
|
|
link.id === id ? { ...link, url: normalizedUrl } : link
|
|
);
|
|
setFormData(prev => ({ ...prev, links: updatedLinks }));
|
|
} catch {
|
|
setErrors(prev => ({ ...prev, [`link-${id}-url`]: 'Invalid URL format' }));
|
|
hasError = true;
|
|
}
|
|
}
|
|
|
|
if (!hasError) {
|
|
updatePersonal(formData);
|
|
}
|
|
};
|
|
|
|
const handleRemoveLink = (id: string) => {
|
|
const updatedLinks = formData.links.filter(link => link.id !== id);
|
|
const updatedFormData = { ...formData, links: updatedLinks };
|
|
setFormData(updatedFormData);
|
|
updatePersonal(updatedFormData);
|
|
};
|
|
|
|
// Extras: Headline (extras[0]) and Reference lines (extras[1..])
|
|
const handleHeadlineChange = (value: string) => {
|
|
const extras = Array.isArray(formData.extras) ? [...formData.extras] : [];
|
|
extras[0] = value;
|
|
const updatedFormData = { ...formData, extras };
|
|
setFormData(updatedFormData);
|
|
};
|
|
|
|
const handleReferenceChange = (index: number, value: string) => {
|
|
const extras = Array.isArray(formData.extras) ? [...formData.extras] : [];
|
|
extras[index] = value;
|
|
const updatedFormData = { ...formData, extras };
|
|
setFormData(updatedFormData);
|
|
};
|
|
|
|
const handleAddReference = () => {
|
|
const extras = Array.isArray(formData.extras) ? [...formData.extras] : [];
|
|
extras.push("");
|
|
const updatedFormData = { ...formData, extras };
|
|
setFormData(updatedFormData);
|
|
};
|
|
|
|
const handleRemoveReference = (index: number) => {
|
|
const extras = Array.isArray(formData.extras) ? [...formData.extras] : [];
|
|
if (index >= 0 && index < extras.length) {
|
|
extras.splice(index, 1);
|
|
}
|
|
const updatedFormData = { ...formData, extras };
|
|
setFormData(updatedFormData);
|
|
updatePersonal(updatedFormData);
|
|
};
|
|
|
|
const handleSubmit = (e: React.FormEvent) => {
|
|
e.preventDefault();
|
|
|
|
// Validate all fields
|
|
const newErrors: Record<string, string> = {};
|
|
if (!formData.firstName.trim()) newErrors.firstName = 'First name is required';
|
|
if (!formData.lastName.trim()) newErrors.lastName = 'Last name is required';
|
|
|
|
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
|
if (!formData.email.trim()) {
|
|
newErrors.email = 'Email is required';
|
|
} else if (!emailRegex.test(formData.email)) {
|
|
newErrors.email = 'Invalid email format';
|
|
}
|
|
|
|
// Validate links
|
|
formData.links.forEach(link => {
|
|
if (link.url && !link.url.startsWith('http')) {
|
|
newErrors[`link-${link.id}-url`] = 'URL must start with http:// or https://';
|
|
}
|
|
});
|
|
|
|
setErrors(newErrors);
|
|
|
|
// Update store if no errors
|
|
if (Object.keys(newErrors).length === 0) {
|
|
updatePersonal(formData);
|
|
return true;
|
|
}
|
|
|
|
return false;
|
|
};
|
|
|
|
return (
|
|
<div className="max-w-2xl mx-auto p-6 bg-white rounded-lg shadow-sm">
|
|
<h2 className="text-2xl font-bold mb-6">Personal Information</h2>
|
|
|
|
<form onSubmit={handleSubmit} className="space-y-6">
|
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
|
{/* First Name */}
|
|
<div>
|
|
<label htmlFor="firstName" className="block text-sm font-medium text-gray-700 mb-1">
|
|
First Name *
|
|
</label>
|
|
<input
|
|
type="text"
|
|
id="firstName"
|
|
name="firstName"
|
|
value={formData.firstName}
|
|
onChange={handleChange}
|
|
onBlur={handleBlur}
|
|
className={`w-full px-3 py-2 border rounded-md ${errors.firstName ? 'border-red-500' : 'border-gray-300'}`}
|
|
aria-describedby={errors.firstName ? "firstName-error" : undefined}
|
|
/>
|
|
{errors.firstName && (
|
|
<p id="firstName-error" className="mt-1 text-sm text-red-600">
|
|
{errors.firstName}
|
|
</p>
|
|
)}
|
|
</div>
|
|
|
|
{/* Last Name */}
|
|
<div>
|
|
<label htmlFor="lastName" className="block text-sm font-medium text-gray-700 mb-1">
|
|
Last Name *
|
|
</label>
|
|
<input
|
|
type="text"
|
|
id="lastName"
|
|
name="lastName"
|
|
value={formData.lastName}
|
|
onChange={handleChange}
|
|
onBlur={handleBlur}
|
|
className={`w-full px-3 py-2 border rounded-md ${errors.lastName ? 'border-red-500' : 'border-gray-300'}`}
|
|
aria-describedby={errors.lastName ? "lastName-error" : undefined}
|
|
/>
|
|
{errors.lastName && (
|
|
<p id="lastName-error" className="mt-1 text-sm text-red-600">
|
|
{errors.lastName}
|
|
</p>
|
|
)}
|
|
</div>
|
|
|
|
{/* Email */}
|
|
<div>
|
|
<label htmlFor="email" className="block text-sm font-medium text-gray-700 mb-1">
|
|
Email *
|
|
</label>
|
|
<input
|
|
type="email"
|
|
id="email"
|
|
name="email"
|
|
value={formData.email}
|
|
onChange={handleChange}
|
|
onBlur={handleBlur}
|
|
className={`w-full px-3 py-2 border rounded-md ${errors.email ? 'border-red-500' : 'border-gray-300'}`}
|
|
aria-describedby={errors.email ? "email-error" : undefined}
|
|
/>
|
|
{errors.email && (
|
|
<p id="email-error" className="mt-1 text-sm text-red-600">
|
|
{errors.email}
|
|
</p>
|
|
)}
|
|
</div>
|
|
|
|
{/* Phone */}
|
|
<div>
|
|
<label htmlFor="phone" className="block text-sm font-medium text-gray-700 mb-1">
|
|
Phone
|
|
</label>
|
|
<input
|
|
type="tel"
|
|
id="phone"
|
|
name="phone"
|
|
value={formData.phone || ''}
|
|
onChange={handleChange}
|
|
onBlur={handleBlur}
|
|
className="w-full px-3 py-2 border border-gray-300 rounded-md"
|
|
/>
|
|
</div>
|
|
|
|
{/* Photo URL (only for templates that support photos) */}
|
|
{supportsPhoto && (
|
|
<div>
|
|
<label htmlFor="photoUrl" className="block text-sm font-medium text-gray-700 mb-1">
|
|
Photo URL
|
|
</label>
|
|
<input
|
|
type="url"
|
|
id="photoUrl"
|
|
name="photoUrl"
|
|
placeholder="https://example.com/photo.jpg"
|
|
value={formData.photoUrl || ''}
|
|
onChange={handleChange}
|
|
onBlur={handleBlur}
|
|
className={`w-full px-3 py-2 border rounded-md ${errors.photoUrl ? 'border-red-500' : 'border-gray-300'}`}
|
|
aria-describedby={errors.photoUrl ? 'photoUrl-error' : undefined}
|
|
/>
|
|
{errors.photoUrl && (
|
|
<p id="photoUrl-error" className="mt-1 text-sm text-red-600">{errors.photoUrl}</p>
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* Address Fields */}
|
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
|
<div>
|
|
<label htmlFor="street" className="block text-sm font-medium text-gray-700 mb-1">
|
|
Street Address
|
|
</label>
|
|
<input
|
|
type="text"
|
|
id="street"
|
|
name="street"
|
|
value={formData.street || ''}
|
|
onChange={handleChange}
|
|
onBlur={handleBlur}
|
|
className="w-full px-3 py-2 border border-gray-300 rounded-md"
|
|
/>
|
|
</div>
|
|
|
|
<div>
|
|
<label htmlFor="city" className="block text-sm font-medium text-gray-700 mb-1">
|
|
City
|
|
</label>
|
|
<input
|
|
type="text"
|
|
id="city"
|
|
name="city"
|
|
value={formData.city || ''}
|
|
onChange={handleChange}
|
|
onBlur={handleBlur}
|
|
className="w-full px-3 py-2 border border-gray-300 rounded-md"
|
|
/>
|
|
</div>
|
|
|
|
<div>
|
|
<label htmlFor="country" className="block text-sm font-medium text-gray-700 mb-1">
|
|
Country
|
|
</label>
|
|
<input
|
|
type="text"
|
|
id="country"
|
|
name="country"
|
|
value={formData.country || ''}
|
|
onChange={handleChange}
|
|
onBlur={handleBlur}
|
|
className="w-full px-3 py-2 border border-gray-300 rounded-md"
|
|
/>
|
|
</div>
|
|
|
|
<div>
|
|
<label htmlFor="postcode" className="block text-sm font-medium text-gray-700 mb-1">
|
|
Postal Code
|
|
</label>
|
|
<input
|
|
type="text"
|
|
id="postcode"
|
|
name="postcode"
|
|
value={formData.postcode || ''}
|
|
onChange={handleChange}
|
|
onBlur={handleBlur}
|
|
className="w-full px-3 py-2 border border-gray-300 rounded-md"
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Links Section */}
|
|
<div className="mt-6">
|
|
<div className="flex justify-between items-center mb-2">
|
|
<h3 className="text-lg font-medium">Links</h3>
|
|
<button
|
|
type="button"
|
|
onClick={handleAddLink}
|
|
className="px-3 py-1 bg-blue-50 text-blue-600 rounded-md text-sm hover:bg-blue-100"
|
|
>
|
|
Add Link
|
|
</button>
|
|
</div>
|
|
|
|
{formData.links.map((link) => (
|
|
<div key={link.id} className="flex items-start space-x-2 mb-3">
|
|
<div className="flex-1">
|
|
<input
|
|
type="text"
|
|
placeholder="Label (e.g. LinkedIn)"
|
|
value={link.label}
|
|
onChange={(e) => handleLinkChange(link.id, 'label', e.target.value)}
|
|
onBlur={(e) => handleLinkBlur(link.id, 'label', e.target.value)}
|
|
className={`w-full px-3 py-2 border rounded-md ${
|
|
errors[`link-${link.id}-label`] ? 'border-red-500' : 'border-gray-300'
|
|
}`}
|
|
/>
|
|
{errors[`link-${link.id}-label`] && (
|
|
<p className="mt-1 text-sm text-red-600">{errors[`link-${link.id}-label`]}</p>
|
|
)}
|
|
</div>
|
|
|
|
<div className="flex-1">
|
|
<input
|
|
type="text"
|
|
placeholder="URL (e.g. https://linkedin.com/in/...)"
|
|
value={link.url}
|
|
onChange={(e) => handleLinkChange(link.id, 'url', e.target.value)}
|
|
onBlur={(e) => handleLinkBlur(link.id, 'url', e.target.value)}
|
|
className={`w-full px-3 py-2 border rounded-md ${
|
|
errors[`link-${link.id}-url`] ? 'border-red-500' : 'border-gray-300'
|
|
}`}
|
|
/>
|
|
{errors[`link-${link.id}-url`] && (
|
|
<p className="mt-1 text-sm text-red-600">{errors[`link-${link.id}-url`]}</p>
|
|
)}
|
|
</div>
|
|
|
|
<button
|
|
type="button"
|
|
onClick={() => handleRemoveLink(link.id)}
|
|
className="mt-2 text-red-500 hover:text-red-700"
|
|
aria-label="Remove link"
|
|
>
|
|
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
|
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
|
|
</svg>
|
|
</button>
|
|
</div>
|
|
))}
|
|
</div>
|
|
|
|
{/* Headline & Reference Section */}
|
|
<div className="mt-6">
|
|
<h3 className="text-lg font-medium mb-2">Headline & Reference</h3>
|
|
{/* Headline */}
|
|
<div className="mb-4">
|
|
<label htmlFor="headline" className="block text-sm font-medium text-gray-700 mb-1">
|
|
Headline (under your name)
|
|
</label>
|
|
<input
|
|
type="text"
|
|
id="headline"
|
|
name="headline"
|
|
placeholder="e.g., Marketing Manager"
|
|
value={(formData.extras && formData.extras[0]) ? formData.extras[0] : ''}
|
|
onChange={(e) => handleHeadlineChange(e.target.value)}
|
|
onBlur={() => updatePersonal(formData)}
|
|
className="w-full px-3 py-2 border border-gray-300 rounded-md"
|
|
/>
|
|
</div>
|
|
|
|
{/* Reference Lines */}
|
|
<div className="flex justify-between items-center mb-2">
|
|
<h4 className="text-sm font-medium">Reference Lines (left sidebar)</h4>
|
|
<button
|
|
type="button"
|
|
onClick={handleAddReference}
|
|
className="px-3 py-1 bg-blue-50 text-blue-600 rounded-md text-sm hover:bg-blue-100"
|
|
>
|
|
Add Reference Line
|
|
</button>
|
|
</div>
|
|
{(formData.extras || []).slice(1).map((line, i) => (
|
|
<div key={`ref-${i}`} className="flex items-start space-x-2 mb-3">
|
|
<div className="flex-1">
|
|
<input
|
|
type="text"
|
|
placeholder={`Reference line ${i + 1}`}
|
|
value={line}
|
|
onChange={(e) => handleReferenceChange(i + 1, e.target.value)}
|
|
onBlur={() => updatePersonal(formData)}
|
|
className="w-full px-3 py-2 border border-gray-300 rounded-md"
|
|
/>
|
|
</div>
|
|
<button
|
|
type="button"
|
|
onClick={() => handleRemoveReference(i + 1)}
|
|
className="mt-2 text-red-500 hover:text-red-700"
|
|
aria-label="Remove reference line"
|
|
>
|
|
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
|
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
|
|
</svg>
|
|
</button>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</form>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
export default PersonalEditor; |