From 81712044c2099f4421917c8794c50af04382e2f5 Mon Sep 17 00:00:00 2001 From: geulah Date: Sun, 12 Oct 2025 19:51:28 +0100 Subject: [PATCH] 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 --- cv-engine/src/assets/templates/timeline.svg | 148 +++++++++++ .../src/assets/templates/timeline_old.svg | 35 +++ cv-engine/src/editors/PersonalEditor.tsx | 89 +++++++ cv-engine/src/templates/TimelineTemplate.tsx | 232 ++++++++++++++++++ cv-engine/src/templates/registry.ts | 3 + shared-printable/buildPrintableHtml.js | 195 +++++++++++---- 6 files changed, 660 insertions(+), 42 deletions(-) create mode 100644 cv-engine/src/assets/templates/timeline.svg create mode 100644 cv-engine/src/assets/templates/timeline_old.svg create mode 100644 cv-engine/src/templates/TimelineTemplate.tsx 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 @@ + + + + + + + + + + AHMDD SAAH + MARKETING MANAGER + + + + + CONTACT + + + + + +124-4236-7894 + + + + hello@ahmedd.saaahh.com + + + + + 123fdfdgds, Any City + + + + www.ahmedd.saaahh.com + + + SKILLS + + + • Strategic Planning + • Problem Solving + • Crisis Management + • Creative Thinking + • Data Analysis + • Brand Development + • Negotiation + • Customer Orientation + • Adaptability to Change + + + LANGUAGES + + + • English (Fluent) + + + REFERENCE + + + Estelle Darcy + Wardiere Inc. / CTO + Phone: +124-4236-7894 + Email: hello@ahmedd.saaahh.com + + + + + + + + + + PROFILE + + + "Neimo enisnot est atem Aham ipsum, finifam imbo sido. Intum lumina + parco regia Aham extra mola idis. Commodo Aham antegor yumo finiam + carius, domus serim lacur Aham. Mauris solitude Aham in albaidis, ceras + vita Aham elit exidio leo, adipisci sector elit." + + + + + + WORK EXPERIENCE + + + + + Borcelle Studio + 2030 - PRESENT + Marketing Manager & Specialist + + • Formulate and implement detailed marketing strategies and initiatives + that support the company's mission and objectives. + • Guide, inspire, and oversee a dynamic marketing team, promoting a + collaborative and performance-oriented culture. + • Ensure uniformity of the brand across all marketing platforms and + materials. + + + + Fauget Studio + 2025 - 2029 + Marketing Manager & Specialist + + • "Neimo enisnot est atem Aham ipsum, finifam imbo sido. Intum lumina + parco regia Aham extra mola idis. Commodo Aham antegor yumo + finiam carius, domus serim lacur Aham. Mauris solitude Aham in + albaidis, ceras vita Aham elit exidio leo, adipisci sector elit." + + + + Studio Shodwe + 2024 - 2025 + Marketing Manager & Specialist + + • Formulate and implement detailed marketing strategies and initiatives + that support the company's mission and objectives. Guide, inspire, and + oversee a dynamic marketing team, promoting a collaborative and + performance-oriented culture. Ensure uniformity of the brand across + all marketing platforms and materials. + + + + + + EDUCATION + + + + Master of Business Management + 2029 - 2031 + School of business | Wardiere University + \ 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 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Timeline + \ 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 && ( +
+
+
+

Profile

+
+

+

+ )} + + {/* Work Experience */} + {work.length > 0 && ( +
+
+
+

Work Experience

+
+
+ {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

+
+
+ {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 = `