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:
geulah 2025-10-06 08:29:33 +01:00
parent 44fdc1ce52
commit d35700bc10
17 changed files with 527 additions and 56 deletions

View File

@ -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.

View 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

View 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

View 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

View 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

View File

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

View File

@ -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;
})()}
</>
)}

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

View File

@ -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
@ -214,6 +222,27 @@ const PersonalEditor: React.FC = () => {
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 */}

View File

@ -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: "",

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

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

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

View File

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

View File

@ -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' });

View File

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

View File

@ -11,10 +11,11 @@ function escapeText(str) {
.replace(/'/g, '&#39;');
}
export function buildPrintableHtml(cv) {
const { personal = {}, summary = '', work = [], education = [], skills = [], languages = [], certifications = [] } = cv || {};
const styles = `
<style>
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; }
@ -31,6 +32,42 @@ export function buildPrintableHtml(cv) {
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>
${baseStyles}
${stylesVariant}
</style>
`;
@ -38,8 +75,12 @@ 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">
<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>` : ''}
@ -48,6 +89,7 @@ export function buildPrintableHtml(cv) {
${headerLinks ? `<span>${headerLinks}</span>` : ''}
</div>
</div>
</div>
`;
const summarySection = summary ? `
@ -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}