diff --git a/cv-engine/src/assets/templates/timeline.svg b/cv-engine/src/assets/templates/timeline.svg
new file mode 100644
index 0000000..c292bd6
--- /dev/null
+++ b/cv-engine/src/assets/templates/timeline.svg
@@ -0,0 +1,148 @@
+
\ No newline at end of file
diff --git a/cv-engine/src/assets/templates/timeline_old.svg b/cv-engine/src/assets/templates/timeline_old.svg
new file mode 100644
index 0000000..52c8972
--- /dev/null
+++ b/cv-engine/src/assets/templates/timeline_old.svg
@@ -0,0 +1,35 @@
+
\ No newline at end of file
diff --git a/cv-engine/src/editors/PersonalEditor.tsx b/cv-engine/src/editors/PersonalEditor.tsx
index e63100f..ef0230b 100644
--- a/cv-engine/src/editors/PersonalEditor.tsx
+++ b/cv-engine/src/editors/PersonalEditor.tsx
@@ -105,6 +105,38 @@ const PersonalEditor: React.FC = () => {
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();
@@ -373,6 +405,63 @@ const PersonalEditor: React.FC = () => {
))}
+
+ {/* Headline & Reference Section */}
+
+
Headline & Reference
+ {/* Headline */}
+
+
+ handleHeadlineChange(e.target.value)}
+ onBlur={() => updatePersonal(formData)}
+ className="w-full px-3 py-2 border border-gray-300 rounded-md"
+ />
+
+
+ {/* Reference Lines */}
+
+
Reference Lines (left sidebar)
+
+
+ {(formData.extras || []).slice(1).map((line, i) => (
+
+
+ handleReferenceChange(i + 1, e.target.value)}
+ onBlur={() => updatePersonal(formData)}
+ className="w-full px-3 py-2 border border-gray-300 rounded-md"
+ />
+
+
+
+ ))}
+
);
diff --git a/cv-engine/src/templates/TimelineTemplate.tsx b/cv-engine/src/templates/TimelineTemplate.tsx
new file mode 100644
index 0000000..45ea102
--- /dev/null
+++ b/cv-engine/src/templates/TimelineTemplate.tsx
@@ -0,0 +1,232 @@
+import React from 'react';
+import type { CV } from '../schema/cvSchema';
+import { escapeText, sanitizeHtml } from '../schema/cvSchema';
+
+interface TimelineTemplateProps { cv: CV; className?: string; }
+
+// Date formatter supporting YYYY-MM and ISO
+const formatDate = (dateStr: string): string => {
+ if (!dateStr) return '';
+ try {
+ if (/^\d{4}-\d{2}$/.test(dateStr)) {
+ const [year, month] = dateStr.split('-');
+ const d = new Date(parseInt(year), parseInt(month) - 1);
+ return d.toLocaleDateString('en-US', { year: 'numeric', month: 'short' });
+ }
+ const d = new Date(dateStr);
+ return d.toLocaleDateString('en-US', { year: 'numeric', month: 'short' });
+ } catch {
+ return dateStr;
+ }
+};
+
+
+
+const TimelineTemplate: React.FC = ({ cv, className = '' }) => {
+ const { personal, summary, work = [], education = [], skills = [], languages = [], certifications = [] } = cv;
+ const headline = (personal.extras && personal.extras[0]) ? personal.extras[0] : '';
+
+ return (
+
+ {/* Header */}
+
+ {escapeText(personal.firstName || '')} {escapeText(personal.lastName || '')}
+ {headline && {escapeText(headline)}
}
+
+
+
+ {/* Body: two columns with exact 30%/70% split */}
+
+ {/* Left sidebar - 30% width */}
+
+
+ {/* Right Column - Main Content */}
+
+ {/* Vertical Timeline Line */}
+
+
+ {/* Profile */}
+ {summary && (
+
+ )}
+
+ {/* Work Experience */}
+ {work.length > 0 && (
+
+
+
+ {work.map(job => (
+
+
+
+
{escapeText(job.company)}
+
{escapeText(job.title)}
+
+
+ {formatDate(job.startDate)} - {job.endDate ? formatDate(job.endDate) : 'Present'}
+
+
+ {job.bullets && job.bullets.length > 0 && (
+
+ {job.bullets.map((b, idx) => (
+
• {escapeText(b)}
+ ))}
+
+ )}
+ {job.location && (
+
{escapeText(job.location)}
+ )}
+
+ ))}
+
+
+ )}
+
+ {/* Education */}
+ {education.length > 0 && (
+
+
+
+ {education.map(ed => (
+
+
+
+
{escapeText(ed.degree)}
+
{escapeText(ed.school)}
+
+
+ {formatDate(ed.startDate)}{ed.endDate ? ` - ${formatDate(ed.endDate)}` : ''}
+
+
+ {ed.notes &&
{escapeText(ed.notes)}
}
+
+ ))}
+
+
+ )}
+
+
+
+ );
+};
+
+export default TimelineTemplate;
\ No newline at end of file
diff --git a/cv-engine/src/templates/registry.ts b/cv-engine/src/templates/registry.ts
index bb6246d..0021b78 100644
--- a/cv-engine/src/templates/registry.ts
+++ b/cv-engine/src/templates/registry.ts
@@ -2,10 +2,12 @@ import ATSTemplate from './ATSTemplate';
import ClassicTemplate from './ClassicTemplate';
import ModernTemplate from './ModernTemplate';
import MinimalTemplate from './MinimalTemplate';
+import TimelineTemplate from './TimelineTemplate';
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 timelineThumb from '../assets/templates/timeline.svg';
import type { CV } from '../schema/cvSchema';
import React from 'react';
@@ -25,6 +27,7 @@ export const templatesRegistry: TemplateMeta[] = [
{ id: 'classic', name: 'Classic', category: 'Visual', thumbnail: classicThumb, component: ClassicTemplate, supportsPhoto: false },
{ id: 'modern', name: 'Modern', category: 'Visual', thumbnail: modernThumb, component: ModernTemplate, supportsPhoto: true },
{ id: 'minimal', name: 'Minimal', category: 'Visual', thumbnail: minimalThumb, component: MinimalTemplate, supportsPhoto: false },
+ { id: 'timeline', name: 'Timeline', category: 'Visual', thumbnail: timelineThumb, component: TimelineTemplate, supportsPhoto: false },
];
export const templatesMap = Object.fromEntries(templatesRegistry.map(t => [t.id, t]));
\ No newline at end of file
diff --git a/shared-printable/buildPrintableHtml.js b/shared-printable/buildPrintableHtml.js
index d1c8d2f..be9c99f 100644
--- a/shared-printable/buildPrintableHtml.js
+++ b/shared-printable/buildPrintableHtml.js
@@ -63,7 +63,21 @@ export function buildPrintableHtml(cv, templateId) {
.chip { background: #f3f4f6; color: #374151; }
`;
- const stylesVariant = tid === 'classic' ? classicStyles : tid === 'modern' ? modernStyles : tid === 'minimal' ? minimalStyles : '';
+ const timelineStyles = `
+ @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; }
+ .grid { display: grid; grid-template-columns: 1fr 2fr; gap: 18px; }
+ .sidebar h2 { font-size: 12px; text-transform: uppercase; letter-spacing: 1.2px; color: #111827; margin: 0; }
+ .divider { border-top: 1px solid #e5e7eb; margin: 4px 0 8px; }
+ .contact-list { font-size: 12px; color: #111827; }
+ .timeline { position: relative; }
+ .tl-row { display: grid; grid-template-columns: 24px 1fr; gap: 12px; margin-bottom: 16px; }
+ .tl-dot { width: 10px; height: 10px; background: #2563eb; border-radius: 50%; margin-top: 3px; }
+ .tl-line { position: absolute; left: 4px; top: 0; bottom: 0; width: 2px; background: #e5e7eb; }
+ .small { font-size: 12px; color: #6b7280; }
+ `;
+
+ const stylesVariant = tid === 'classic' ? classicStyles : tid === 'modern' ? modernStyles : tid === 'minimal' ? minimalStyles : tid === 'timeline' ? timelineStyles : '';
const styles = `