feat(templates): add multiple CV templates with photo support and template selection
- Add 4 new CV templates (ATS-Friendly, Classic, Modern, Minimal) with thumbnails - Implement template registry and gallery component for template selection - Add photoUrl field to personal info with URL validation - Update buildPrintableHtml to support template-specific styling - Modify ExportControls to use template registry - Add template preview thumbnails in SVG format
This commit is contained in:
parent
44fdc1ce52
commit
d35700bc10
@ -234,6 +234,13 @@ Adapt paths to your chosen structure. This repo currently contains planning docu
|
||||
- At least two templates with instant switching by M6
|
||||
- Accessibility AA and test coverage by M7
|
||||
|
||||
## User Journey
|
||||
- User starts by selecting a template from the gallery.
|
||||
- They then navigate through the stepper to fill out their personal information, work experience, education, skills, and summary.
|
||||
- Photo upload should only show if the selected template has a placeholder for a profile photo.
|
||||
- After completing the steps, they can preview their CV in real-time with the selected template.
|
||||
- If they are satisfied with the preview, they can export their CV as a PDF file.
|
||||
|
||||
## Glossary
|
||||
- ATS: Applicant Tracking System; favors semantic, minimal styling.
|
||||
- Parity: Inline preview and exported PDF display equivalent content/layout.
|
||||
|
||||
10
cv-engine/src/assets/templates/ats.svg
Normal file
10
cv-engine/src/assets/templates/ats.svg
Normal file
@ -0,0 +1,10 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="160" height="96" viewBox="0 0 160 96">
|
||||
<rect x="1" y="1" width="158" height="94" rx="6" fill="#ffffff" stroke="#e5e7eb"/>
|
||||
<rect x="12" y="14" width="96" height="10" fill="#d1d5db"/>
|
||||
<rect x="12" y="30" width="136" height="8" fill="#e5e7eb"/>
|
||||
<rect x="12" y="42" width="136" height="8" fill="#e5e7eb"/>
|
||||
<rect x="12" y="54" width="120" height="8" fill="#e5e7eb"/>
|
||||
<rect x="12" y="66" width="120" height="8" fill="#e5e7eb"/>
|
||||
<rect x="12" y="78" width="104" height="8" fill="#e5e7eb"/>
|
||||
<text x="12" y="12" font-size="10" fill="#6b7280" font-family="Inter, Arial">ATS-Friendly</text>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 648 B |
10
cv-engine/src/assets/templates/classic.svg
Normal file
10
cv-engine/src/assets/templates/classic.svg
Normal file
@ -0,0 +1,10 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="160" height="96" viewBox="0 0 160 96">
|
||||
<rect x="1" y="1" width="158" height="94" rx="6" fill="#ffffff" stroke="#e5e7eb"/>
|
||||
<rect x="12" y="16" width="90" height="8" fill="#cbd5e1"/>
|
||||
<line x1="12" y1="28" x2="148" y2="28" stroke="#e5e7eb"/>
|
||||
<rect x="12" y="36" width="136" height="8" fill="#e5e7eb"/>
|
||||
<rect x="12" y="48" width="136" height="8" fill="#e5e7eb"/>
|
||||
<rect x="12" y="60" width="120" height="8" fill="#e5e7eb"/>
|
||||
<rect x="12" y="72" width="104" height="8" fill="#e5e7eb"/>
|
||||
<text x="12" y="12" font-size="10" fill="#6b7280" font-family="Georgia, Times">Classic</text>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 642 B |
9
cv-engine/src/assets/templates/minimal.svg
Normal file
9
cv-engine/src/assets/templates/minimal.svg
Normal file
@ -0,0 +1,9 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="160" height="96" viewBox="0 0 160 96">
|
||||
<rect x="1" y="1" width="158" height="94" rx="6" fill="#ffffff" stroke="#e5e7eb"/>
|
||||
<rect x="12" y="16" width="90" height="8" fill="#e5e7eb"/>
|
||||
<rect x="12" y="32" width="136" height="6" fill="#f3f4f6"/>
|
||||
<rect x="12" y="42" width="136" height="6" fill="#f3f4f6"/>
|
||||
<rect x="12" y="52" width="120" height="6" fill="#f3f4f6"/>
|
||||
<rect x="12" y="62" width="104" height="6" fill="#f3f4f6"/>
|
||||
<text x="12" y="12" font-size="10" fill="#374151" font-family="Source Serif Pro, Georgia">Minimal</text>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 593 B |
11
cv-engine/src/assets/templates/modern.svg
Normal file
11
cv-engine/src/assets/templates/modern.svg
Normal file
@ -0,0 +1,11 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="160" height="96" viewBox="0 0 160 96">
|
||||
<rect x="1" y="1" width="158" height="94" rx="6" fill="#ffffff" stroke="#e5e7eb"/>
|
||||
<rect x="1" y="1" width="158" height="6" fill="#0ea5e9"/>
|
||||
<rect x="12" y="16" width="100" height="10" fill="#0ea5e9"/>
|
||||
<rect x="12" y="32" width="136" height="8" fill="#e0f2fe"/>
|
||||
<rect x="12" y="44" width="136" height="8" fill="#e0f2fe"/>
|
||||
<rect x="12" y="56" width="120" height="8" fill="#e0f2fe"/>
|
||||
<rect x="12" y="68" width="120" height="8" fill="#e0f2fe"/>
|
||||
<rect x="12" y="80" width="104" height="8" fill="#e0f2fe"/>
|
||||
<text x="12" y="12" font-size="10" fill="#0c4a6e" font-family="Inter, Arial">Modern</text>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 703 B |
@ -1,5 +1,6 @@
|
||||
import React, { useRef, useState } from 'react';
|
||||
import { useCvStore } from '../store/cvStore';
|
||||
import { templatesRegistry } from '../templates/registry';
|
||||
import { exportPdf, requestExportJob, pollJobStatus, downloadJob, cancelJob } from '../api/export';
|
||||
|
||||
interface ExportControlsProps {
|
||||
@ -87,8 +88,9 @@ const ExportControls: React.FC<ExportControlsProps> = ({ endpoint = 'http://loca
|
||||
onChange={e => updateTemplateId(e.target.value)}
|
||||
disabled={status === 'exporting'}
|
||||
>
|
||||
<option value="ats">ATS-Friendly</option>
|
||||
<option value="classic">Classic</option>
|
||||
{templatesRegistry.map(t => (
|
||||
<option key={t.id} value={t.id}>{t.name}</option>
|
||||
))}
|
||||
</select>
|
||||
|
||||
<label className="ml-2 text-xs text-gray-600">Async</label>
|
||||
|
||||
@ -1,9 +1,9 @@
|
||||
import React, { useMemo, useState } from 'react';
|
||||
import { useCvStore } from '../store/cvStore';
|
||||
import ATSTemplate from '../templates/ATSTemplate';
|
||||
import ClassicTemplate from '../templates/ClassicTemplate';
|
||||
import { templatesMap } from '../templates/registry';
|
||||
import { buildPrintableHtml } from '../utils/printable';
|
||||
import ExportControls from './ExportControls';
|
||||
import TemplateGallery from './TemplateGallery';
|
||||
|
||||
interface PreviewPanelProps {
|
||||
className?: string;
|
||||
@ -13,7 +13,7 @@ const PreviewPanel: React.FC<PreviewPanelProps> = ({ className = '' }) => {
|
||||
const cv = useCvStore(state => state.cv);
|
||||
const templateId = useCvStore(state => state.cv.templateId);
|
||||
const [mode, setMode] = useState<'inline' | 'printable'>('inline');
|
||||
const printableHtml = useMemo(() => buildPrintableHtml(cv), [cv]);
|
||||
const printableHtml = useMemo(() => buildPrintableHtml(cv, templateId), [cv, templateId]);
|
||||
|
||||
return (
|
||||
<div className={`bg-gray-100 rounded-lg shadow-inner overflow-auto ${className}`}>
|
||||
@ -21,7 +21,7 @@ const PreviewPanel: React.FC<PreviewPanelProps> = ({ className = '' }) => {
|
||||
<h2 className="text-lg font-medium text-gray-700">Preview</h2>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="text-sm text-gray-500">
|
||||
Template: <span className="font-medium">{templateId === 'ats' ? 'ATS-Friendly' : templateId}</span>
|
||||
Template: <span className="font-medium">{templatesMap[templateId]?.name || templateId}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<button
|
||||
@ -43,13 +43,15 @@ const PreviewPanel: React.FC<PreviewPanelProps> = ({ className = '' }) => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="p-4">
|
||||
<div className="p-4 space-y-4">
|
||||
<TemplateGallery />
|
||||
{mode === 'inline' && (
|
||||
<>
|
||||
{/* Render the appropriate template based on templateId */}
|
||||
{templateId === 'ats' && <ATSTemplate cv={cv} />}
|
||||
{templateId === 'classic' && <ClassicTemplate cv={cv} />}
|
||||
{/* Add more template options here as they are implemented */}
|
||||
{/* Render component via registry */}
|
||||
{(() => {
|
||||
const Comp = templatesMap[templateId]?.component;
|
||||
return Comp ? <Comp cv={cv} /> : null;
|
||||
})()}
|
||||
</>
|
||||
)}
|
||||
|
||||
|
||||
58
cv-engine/src/components/TemplateGallery.tsx
Normal file
58
cv-engine/src/components/TemplateGallery.tsx
Normal file
@ -0,0 +1,58 @@
|
||||
import React, { useMemo, useState } from 'react';
|
||||
import { templatesRegistry } from '../templates/registry';
|
||||
import { useCvStore } from '../store/cvStore';
|
||||
|
||||
interface TemplateGalleryProps {
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const TemplateGallery: React.FC<TemplateGalleryProps> = ({ className = '' }) => {
|
||||
const templateId = useCvStore(state => state.cv.templateId);
|
||||
const updateTemplateId = useCvStore(state => state.updateTemplateId);
|
||||
const [filter, setFilter] = useState<'All' | 'ATS' | 'Visual'>('All');
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
return templatesRegistry.filter(t => filter === 'All' ? true : t.category === filter);
|
||||
}, [filter]);
|
||||
|
||||
return (
|
||||
<div className={`bg-white rounded-md border p-3 ${className}`}>
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<h3 className="text-sm font-medium text-gray-700">Templates</h3>
|
||||
<div className="flex items-center gap-2">
|
||||
{(['All', 'ATS', 'Visual'] as const).map(k => (
|
||||
<button
|
||||
key={k}
|
||||
type="button"
|
||||
onClick={() => setFilter(k)}
|
||||
className={`px-2 py-1 rounded text-xs border ${filter === k ? 'bg-blue-600 text-white border-blue-600' : 'bg-gray-100 text-gray-800 border-gray-300'}`}
|
||||
>
|
||||
{k}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 gap-3">
|
||||
{filtered.map(t => (
|
||||
<button
|
||||
key={t.id}
|
||||
type="button"
|
||||
onClick={() => updateTemplateId(t.id)}
|
||||
className={`group border rounded-md p-2 text-left ${templateId === t.id ? 'ring-2 ring-blue-600 border-blue-600' : 'border-gray-300'}`}
|
||||
>
|
||||
<div className="w-full h-24 bg-gray-50 flex items-center justify-center overflow-hidden rounded">
|
||||
<img src={t.thumbnail} alt={`${t.name} thumbnail`} className="max-h-full" />
|
||||
</div>
|
||||
<div className="mt-2">
|
||||
<div className="text-sm font-medium text-gray-800">{t.name}</div>
|
||||
<div className="text-xs text-gray-500">{t.category}</div>
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default TemplateGallery;
|
||||
@ -40,6 +40,14 @@ const PersonalEditor: React.FC = () => {
|
||||
} 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
|
||||
@ -199,23 +207,44 @@ const PersonalEditor: React.FC = () => {
|
||||
)}
|
||||
</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>
|
||||
{/* 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 */}
|
||||
<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>
|
||||
|
||||
@ -26,6 +26,7 @@ export const CvSchema = z.object({
|
||||
lastName: z.string().min(1, "Last name is required"),
|
||||
email: z.string().email("Invalid email address"),
|
||||
phone: z.string().optional(),
|
||||
photoUrl: z.string().optional().default(""),
|
||||
street: z.string().optional(),
|
||||
city: z.string().optional(),
|
||||
country: z.string().optional(),
|
||||
@ -99,6 +100,7 @@ export const createEmptyCV = (): CV => ({
|
||||
lastName: "",
|
||||
email: "",
|
||||
phone: "",
|
||||
photoUrl: "",
|
||||
street: "",
|
||||
city: "",
|
||||
country: "",
|
||||
|
||||
122
cv-engine/src/templates/MinimalTemplate.tsx
Normal file
122
cv-engine/src/templates/MinimalTemplate.tsx
Normal file
@ -0,0 +1,122 @@
|
||||
import React from 'react';
|
||||
import type { CV } from '../schema/cvSchema';
|
||||
|
||||
interface MinimalTemplateProps { cv: CV; className?: string; }
|
||||
|
||||
// Minimal clean template with generous whitespace and serif headings
|
||||
const MinimalTemplate: React.FC<MinimalTemplateProps> = ({ cv, className = '' }) => {
|
||||
const { personal, summary, work = [], education = [], skills = [], languages = [], certifications = [] } = cv;
|
||||
|
||||
return (
|
||||
<div className={`bg-white text-gray-900 max-w-3xl mx-auto px-8 py-6 ${className}`} style={{ fontFamily: 'ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, Helvetica, Arial' }}>
|
||||
<header className="mb-6">
|
||||
<h1 className="text-3xl font-serif tracking-tight">{(personal?.firstName || '')} {(personal?.lastName || '')}</h1>
|
||||
<div className="mt-2 text-sm text-gray-600 flex flex-wrap gap-2">
|
||||
{personal?.email && <span>{personal.email}</span>}
|
||||
{personal?.phone && <span>{personal.phone}</span>}
|
||||
{(personal?.city || personal?.country) && (
|
||||
<span>
|
||||
{personal.city}{personal.city && personal.country ? ', ' : ''}{personal.country}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{summary && (
|
||||
<section className="mb-6">
|
||||
<h2 className="text-xl font-serif mb-2">Summary</h2>
|
||||
<div className="prose max-w-none" dangerouslySetInnerHTML={{ __html: summary }} />
|
||||
</section>
|
||||
)}
|
||||
|
||||
{work.length > 0 && (
|
||||
<section className="mb-6">
|
||||
<h2 className="text-xl font-serif mb-2">Experience</h2>
|
||||
<div className="space-y-4">
|
||||
{work.map((job) => (
|
||||
<article key={job.id}>
|
||||
<div className="flex justify-between items-start">
|
||||
<div>
|
||||
<h3 className="text-base font-semibold">{job.title}</h3>
|
||||
<div className="text-sm text-gray-600">{job.company}</div>
|
||||
</div>
|
||||
<div className="text-sm text-gray-600 text-right">
|
||||
{job.startDate} - {job.endDate || 'Present'}
|
||||
{job.location && <div>{job.location}</div>}
|
||||
</div>
|
||||
</div>
|
||||
{job.bullets && job.bullets.length > 0 && (
|
||||
<ul className="list-disc pl-6 mt-2">
|
||||
{job.bullets.map((b, i) => (
|
||||
<li key={i}>{b}</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{education.length > 0 && (
|
||||
<section className="mb-6">
|
||||
<h2 className="text-xl font-serif mb-2">Education</h2>
|
||||
<div className="space-y-3">
|
||||
{education.map((ed) => (
|
||||
<article key={ed.id}>
|
||||
<div className="flex justify-between items-start">
|
||||
<div>
|
||||
<h3 className="text-base font-semibold">{ed.degree}</h3>
|
||||
<div className="text-sm text-gray-600">{ed.school}</div>
|
||||
</div>
|
||||
<div className="text-sm text-gray-600">{ed.startDate}{ed.endDate && <> - {ed.endDate}</>}</div>
|
||||
</div>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{(skills.length > 0 || languages.length > 0 || certifications.length > 0) && (
|
||||
<section className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
{skills.length > 0 && (
|
||||
<div>
|
||||
<h3 className="text-base font-serif mb-2">Skills</h3>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{skills.map((s) => (
|
||||
<span key={s.id} className="px-2 py-1 rounded bg-gray-100 text-gray-800 text-sm">
|
||||
{s.name}{s.level ? ` — ${s.level}` : ''}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{languages.length > 0 && (
|
||||
<div>
|
||||
<h3 className="text-base font-serif mb-2">Languages</h3>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{languages.map((l) => (
|
||||
<span key={l.id} className="px-2 py-1 rounded bg-gray-100 text-gray-800 text-sm">
|
||||
{l.name}{l.level ? ` — ${l.level}` : ''}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{certifications.length > 0 && (
|
||||
<div>
|
||||
<h3 className="text-base font-serif mb-2">Certifications</h3>
|
||||
<ul className="list-disc pl-6">
|
||||
{certifications.map((c) => (
|
||||
<li key={c.id}>{c.name}{c.issuer ? ` - ${c.issuer}` : ''}{c.date ? ` (${c.date})` : ''}</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default MinimalTemplate;
|
||||
136
cv-engine/src/templates/ModernTemplate.tsx
Normal file
136
cv-engine/src/templates/ModernTemplate.tsx
Normal file
@ -0,0 +1,136 @@
|
||||
import React from 'react';
|
||||
import type { CV } from '../schema/cvSchema';
|
||||
|
||||
interface ModernTemplateProps { cv: CV; className?: string; }
|
||||
|
||||
// Modern visual template with accent header and two-column sections
|
||||
const ModernTemplate: React.FC<ModernTemplateProps> = ({ cv, className = '' }) => {
|
||||
const { personal, summary, work = [], education = [], skills = [], languages = [], certifications = [] } = cv;
|
||||
|
||||
return (
|
||||
<div className={`bg-white text-gray-800 max-w-4xl mx-auto ${className}`}>
|
||||
<div className="bg-blue-600 text-white p-6 flex items-center gap-4">
|
||||
{personal?.photoUrl && (
|
||||
<img
|
||||
src={personal.photoUrl}
|
||||
alt={`${personal.firstName || ''} ${personal.lastName || ''} photo`}
|
||||
className="w-16 h-16 rounded-full object-cover border-2 border-white/70"
|
||||
referrerPolicy="no-referrer"
|
||||
/>
|
||||
)}
|
||||
<h1 className="text-3xl font-bold">
|
||||
{(personal?.firstName || '')} {(personal?.lastName || '')}
|
||||
</h1>
|
||||
<div className="mt-2 text-sm opacity-90 flex flex-wrap gap-3">
|
||||
{personal.email && <span>{personal.email}</span>}
|
||||
{personal.phone && <span>{personal.phone}</span>}
|
||||
{(personal.city || personal.country) && (
|
||||
<span>
|
||||
{personal.city}{personal.city && personal.country ? ', ' : ''}{personal.country}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="p-6 grid grid-cols-1 md:grid-cols-3 gap-6">
|
||||
<div className="md:col-span-2">
|
||||
{summary && (
|
||||
<section className="mb-6">
|
||||
<h2 className="text-xl font-semibold text-gray-700 mb-2">Professional Summary</h2>
|
||||
<div className="prose max-w-none" dangerouslySetInnerHTML={{ __html: summary }} />
|
||||
</section>
|
||||
)}
|
||||
|
||||
{work.length > 0 && (
|
||||
<section className="mb-6">
|
||||
<h2 className="text-xl font-semibold text-gray-700 mb-3">Work Experience</h2>
|
||||
<div className="space-y-4">
|
||||
{work.map((job) => (
|
||||
<article key={job.id} className="">
|
||||
<div className="flex justify-between items-start">
|
||||
<div>
|
||||
<h3 className="text-base font-semibold">{job.title}</h3>
|
||||
<div className="text-sm text-gray-600">{job.company}</div>
|
||||
</div>
|
||||
<div className="text-sm text-gray-600 text-right">
|
||||
{job.startDate} - {job.endDate || 'Present'}
|
||||
{job.location && <div>{job.location}</div>}
|
||||
</div>
|
||||
</div>
|
||||
{job.bullets && job.bullets.length > 0 && (
|
||||
<ul className="list-disc pl-6 mt-2">
|
||||
{job.bullets.map((b, i) => (
|
||||
<li key={i}>{b}</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{education.length > 0 && (
|
||||
<section className="mb-6">
|
||||
<h2 className="text-xl font-semibold text-gray-700 mb-3">Education</h2>
|
||||
<div className="space-y-3">
|
||||
{education.map((ed) => (
|
||||
<article key={ed.id}>
|
||||
<div className="flex justify-between items-start">
|
||||
<div>
|
||||
<h3 className="text-base font-semibold">{ed.degree}</h3>
|
||||
<div className="text-sm text-gray-600">{ed.school}</div>
|
||||
</div>
|
||||
<div className="text-sm text-gray-600">{ed.startDate}{ed.endDate && <> - {ed.endDate}</>}</div>
|
||||
</div>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
{skills.length > 0 && (
|
||||
<section className="mb-6">
|
||||
<h2 className="text-lg font-semibold text-gray-700 mb-2">Skills</h2>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{skills.map((s) => (
|
||||
<span key={s.id} className="px-2 py-1 rounded-full bg-blue-50 text-blue-700 text-sm">
|
||||
{s.name}{s.level ? ` — ${s.level}` : ''}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{languages.length > 0 && (
|
||||
<section className="mb-6">
|
||||
<h2 className="text-lg font-semibold text-gray-700 mb-2">Languages</h2>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{languages.map((l) => (
|
||||
<span key={l.id} className="px-2 py-1 rounded-full bg-green-50 text-green-700 text-sm">
|
||||
{l.name}{l.level ? ` — ${l.level}` : ''}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{certifications.length > 0 && (
|
||||
<section>
|
||||
<h2 className="text-lg font-semibold text-gray-700 mb-2">Certifications</h2>
|
||||
<ul className="list-disc pl-6">
|
||||
{certifications.map((c) => (
|
||||
<li key={c.id}>{c.name}{c.issuer ? ` - ${c.issuer}` : ''}{c.date ? ` (${c.date})` : ''}</li>
|
||||
))}
|
||||
</ul>
|
||||
</section>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ModernTemplate;
|
||||
29
cv-engine/src/templates/registry.ts
Normal file
29
cv-engine/src/templates/registry.ts
Normal file
@ -0,0 +1,29 @@
|
||||
import ATSTemplate from './ATSTemplate';
|
||||
import ClassicTemplate from './ClassicTemplate';
|
||||
import ModernTemplate from './ModernTemplate';
|
||||
import MinimalTemplate from './MinimalTemplate';
|
||||
import atsThumb from '../assets/templates/ats.svg';
|
||||
import classicThumb from '../assets/templates/classic.svg';
|
||||
import modernThumb from '../assets/templates/modern.svg';
|
||||
import minimalThumb from '../assets/templates/minimal.svg';
|
||||
import type { CV } from '../schema/cvSchema';
|
||||
import React from 'react';
|
||||
|
||||
export type TemplateCategory = 'ATS' | 'Visual';
|
||||
|
||||
export interface TemplateMeta {
|
||||
id: string;
|
||||
name: string;
|
||||
category: TemplateCategory;
|
||||
thumbnail: string; // path to asset
|
||||
component: React.FC<{ cv: CV }>;
|
||||
}
|
||||
|
||||
export const templatesRegistry: TemplateMeta[] = [
|
||||
{ id: 'ats', name: 'ATS-Friendly', category: 'ATS', thumbnail: atsThumb, component: ATSTemplate },
|
||||
{ id: 'classic', name: 'Classic', category: 'Visual', thumbnail: classicThumb, component: ClassicTemplate },
|
||||
{ id: 'modern', name: 'Modern', category: 'Visual', thumbnail: modernThumb, component: ModernTemplate },
|
||||
{ id: 'minimal', name: 'Minimal', category: 'Visual', thumbnail: minimalThumb, component: MinimalTemplate },
|
||||
];
|
||||
|
||||
export const templatesMap = Object.fromEntries(templatesRegistry.map(t => [t.id, t]));
|
||||
@ -3,10 +3,10 @@ import { sanitizeHtml } from '../schema/cvSchema';
|
||||
import { buildPrintableHtml as sharedBuild } from '../../../shared-printable/buildPrintableHtml.js';
|
||||
|
||||
// Thin wrapper to ensure client-side sanitization and shared rendering parity
|
||||
export const buildPrintableHtml = (cv: CV): string => {
|
||||
export const buildPrintableHtml = (cv: CV, templateId?: string): string => {
|
||||
const cleaned: CV = {
|
||||
...cv,
|
||||
summary: cv.summary ? sanitizeHtml(cv.summary) : ''
|
||||
};
|
||||
return sharedBuild(cleaned);
|
||||
return sharedBuild(cleaned, templateId);
|
||||
};
|
||||
@ -219,7 +219,7 @@ app.post('/export/pdf', async (req, res) => {
|
||||
try {
|
||||
const current = jobs.get(jobId);
|
||||
if (current?.status === 'canceled') return; // honor cancel
|
||||
const html = sharedBuild(sanitizedCv);
|
||||
const html = sharedBuild(sanitizedCv, templateId);
|
||||
const browser = await puppeteer.launch({ args: ['--no-sandbox', '--disable-setuid-sandbox'] });
|
||||
const page = await browser.newPage();
|
||||
await page.setContent(html, { waitUntil: 'networkidle0' });
|
||||
@ -235,7 +235,7 @@ app.post('/export/pdf', async (req, res) => {
|
||||
}
|
||||
|
||||
// Synchronous path
|
||||
const html = sharedBuild(sanitizedCv);
|
||||
const html = sharedBuild(sanitizedCv, templateId);
|
||||
const browser = await puppeteer.launch({ args: ['--no-sandbox', '--disable-setuid-sandbox'] });
|
||||
const page = await browser.newPage();
|
||||
await page.setContent(html, { waitUntil: 'networkidle0' });
|
||||
|
||||
2
shared-printable/buildPrintableHtml.d.ts
vendored
2
shared-printable/buildPrintableHtml.d.ts
vendored
@ -1,2 +1,2 @@
|
||||
export type CV = import('../cv-engine/src/schema/cvSchema').CV;
|
||||
export function buildPrintableHtml(cv: CV): string;
|
||||
export function buildPrintableHtml(cv: CV, templateId?: string): string;
|
||||
@ -11,26 +11,63 @@ function escapeText(str) {
|
||||
.replace(/'/g, ''');
|
||||
}
|
||||
|
||||
export function buildPrintableHtml(cv) {
|
||||
const { personal = {}, summary = '', work = [], education = [], skills = [], languages = [], certifications = [] } = cv || {};
|
||||
export function buildPrintableHtml(cv, templateId) {
|
||||
const { personal = {}, summary = '', work = [], education = [], skills = [], languages = [], certifications = [], templateId: cvTemplateId } = cv || {};
|
||||
const tid = templateId || cvTemplateId || 'ats';
|
||||
|
||||
const baseStyles = `
|
||||
@page { size: A4; margin: 20mm; }
|
||||
body { font-family: ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, Helvetica, Arial, Noto Sans; color: #1f2937; }
|
||||
.page { width: 210mm; min-height: 297mm; margin: 0 auto; background: white; }
|
||||
h1 { font-size: 24px; margin: 0 0 6px 0; }
|
||||
h2 { font-size: 16px; margin: 16px 0 8px 0; padding-bottom: 4px; border-bottom: 1px solid #e5e7eb; color: #374151; }
|
||||
h3 { font-size: 14px; margin: 0; }
|
||||
.header { border-bottom: 1px solid #e5e7eb; padding-bottom: 12px; margin-bottom: 16px; }
|
||||
.meta { display: flex; flex-wrap: wrap; gap: 8px; font-size: 12px; color: #6b7280; }
|
||||
.section { margin-bottom: 16px; }
|
||||
.job, .edu { margin-bottom: 12px; }
|
||||
.row { display: flex; justify-content: space-between; align-items: flex-start; }
|
||||
.small { font-size: 12px; color: #6b7280; }
|
||||
ul { padding-left: 20px; }
|
||||
li { margin: 4px 0; }
|
||||
.chips { display: flex; flex-wrap: wrap; gap: 6px; }
|
||||
.chip { background: #f3f4f6; color: #1f2937; padding: 2px 8px; border-radius: 12px; font-size: 12px; }
|
||||
`;
|
||||
|
||||
const classicStyles = `
|
||||
@page { size: A4; margin: 22mm; }
|
||||
body { font-family: Georgia, Cambria, 'Times New Roman', Times, serif; color: #111827; }
|
||||
h1 { font-size: 26px; }
|
||||
h2 { font-size: 16px; margin: 18px 0 6px 0; padding-bottom: 0; border-bottom: none; color: #374151; letter-spacing: 0.2px; }
|
||||
.header { border-bottom: none; padding-bottom: 0; margin-bottom: 10px; }
|
||||
.chip { background: #f9fafb; color: #111827; }
|
||||
`;
|
||||
|
||||
const modernStyles = `
|
||||
@page { size: A4; margin: 18mm; }
|
||||
body { font-family: Inter, ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, Helvetica, Arial, Noto Sans; color: #0f172a; }
|
||||
.accent { height: 6px; background: #0ea5e9; margin-bottom: 12px; }
|
||||
h1 { font-weight: 700; letter-spacing: 0.3px; }
|
||||
h2 { color: #0ea5e9; border-color: #bae6fd; }
|
||||
.header { border-left: 6px solid #0ea5e9; padding-left: 12px; }
|
||||
.chip { background: #e0f2fe; color: #0c4a6e; }
|
||||
`;
|
||||
|
||||
const minimalStyles = `
|
||||
@page { size: A4; margin: 25mm; }
|
||||
body { font-family: 'Source Serif Pro', Georgia, Cambria, 'Times New Roman', Times, serif; color: #1f2937; }
|
||||
h1 { font-size: 24px; font-weight: 600; }
|
||||
h2 { font-size: 15px; margin: 22px 0 6px 0; border-bottom: none; color: #374151; }
|
||||
.header { border-bottom: none; margin-bottom: 8px; }
|
||||
.section { margin-bottom: 22px; }
|
||||
.chip { background: #f3f4f6; color: #374151; }
|
||||
`;
|
||||
|
||||
const stylesVariant = tid === 'classic' ? classicStyles : tid === 'modern' ? modernStyles : tid === 'minimal' ? minimalStyles : '';
|
||||
const styles = `
|
||||
<style>
|
||||
@page { size: A4; margin: 20mm; }
|
||||
body { font-family: ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, Helvetica, Arial, Noto Sans; color: #1f2937; }
|
||||
.page { width: 210mm; min-height: 297mm; margin: 0 auto; background: white; }
|
||||
h1 { font-size: 24px; margin: 0 0 6px 0; }
|
||||
h2 { font-size: 16px; margin: 16px 0 8px 0; padding-bottom: 4px; border-bottom: 1px solid #e5e7eb; color: #374151; }
|
||||
h3 { font-size: 14px; margin: 0; }
|
||||
.header { border-bottom: 1px solid #e5e7eb; padding-bottom: 12px; margin-bottom: 16px; }
|
||||
.meta { display: flex; flex-wrap: wrap; gap: 8px; font-size: 12px; color: #6b7280; }
|
||||
.section { margin-bottom: 16px; }
|
||||
.job, .edu { margin-bottom: 12px; }
|
||||
.row { display: flex; justify-content: space-between; align-items: flex-start; }
|
||||
.small { font-size: 12px; color: #6b7280; }
|
||||
ul { padding-left: 20px; }
|
||||
li { margin: 4px 0; }
|
||||
.chips { display: flex; flex-wrap: wrap; gap: 6px; }
|
||||
.chip { background: #f3f4f6; color: #1f2937; padding: 2px 8px; border-radius: 12px; font-size: 12px; }
|
||||
${baseStyles}
|
||||
${stylesVariant}
|
||||
</style>
|
||||
`;
|
||||
|
||||
@ -38,14 +75,19 @@ export function buildPrintableHtml(cv) {
|
||||
.map(l => `<a href="${escapeText(l.url)}" target="_blank" rel="noopener noreferrer">${escapeText(l.label || l.url)}</a>`)
|
||||
.join(' · ');
|
||||
|
||||
const safePhotoUrl = typeof personal.photoUrl === 'string' && /^(https?:\/\/)/i.test(personal.photoUrl) ? personal.photoUrl : '';
|
||||
const headerPhoto = tid === 'modern' && safePhotoUrl ? `<img src="${escapeText(safePhotoUrl)}" alt="${escapeText((personal.firstName || '') + ' ' + (personal.lastName || ''))} photo" style="width:64px;height:64px;border-radius:50%;object-fit:cover;border:2px solid rgba(255,255,255,0.7);margin-right:12px;" referrerpolicy="no-referrer" />` : '';
|
||||
const headerHtml = `
|
||||
<div class="header">
|
||||
<h1>${escapeText(personal.firstName || '')} ${escapeText(personal.lastName || '')}</h1>
|
||||
<div class="meta">
|
||||
${personal.email ? `<span>${escapeText(personal.email)}</span>` : ''}
|
||||
${personal.phone ? `<span>${escapeText(personal.phone)}</span>` : ''}
|
||||
${(personal.city || personal.country) ? `<span>${personal.city ? escapeText(personal.city) : ''}${personal.city && personal.country ? ', ' : ''}${personal.country ? escapeText(personal.country) : ''}</span>` : ''}
|
||||
${headerLinks ? `<span>${headerLinks}</span>` : ''}
|
||||
<div class="header" style="display:flex;align-items:center;gap:12px;">
|
||||
${headerPhoto}
|
||||
<div>
|
||||
<h1>${escapeText(personal.firstName || '')} ${escapeText(personal.lastName || '')}</h1>
|
||||
<div class="meta">
|
||||
${personal.email ? `<span>${escapeText(personal.email)}</span>` : ''}
|
||||
${personal.phone ? `<span>${escapeText(personal.phone)}</span>` : ''}
|
||||
${(personal.city || personal.country) ? `<span>${personal.city ? escapeText(personal.city) : ''}${personal.city && personal.country ? ', ' : ''}${personal.country ? escapeText(personal.country) : ''}</span>` : ''}
|
||||
${headerLinks ? `<span>${headerLinks}</span>` : ''}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
@ -129,6 +171,7 @@ export function buildPrintableHtml(cv) {
|
||||
</div>
|
||||
` : '';
|
||||
|
||||
const accentTop = tid === 'modern' ? '<div class="accent"></div>' : '';
|
||||
return `<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
@ -139,6 +182,7 @@ export function buildPrintableHtml(cv) {
|
||||
</head>
|
||||
<body>
|
||||
<div class="page">
|
||||
${accentTop}
|
||||
${headerHtml}
|
||||
${summarySection}
|
||||
${workSection}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user