CV-Engine/cv-engine/src/editors/PersonalEditor.tsx
geulah 81712044c2 feat(templates): add timeline template with sidebar layout
- 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
2025-10-12 19:51:28 +01:00

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;