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
This commit is contained in:
parent
2847cef81b
commit
81712044c2
148
cv-engine/src/assets/templates/timeline.svg
Normal file
148
cv-engine/src/assets/templates/timeline.svg
Normal file
@ -0,0 +1,148 @@
|
||||
<svg width="400" height="520" viewBox="0 0 400 520" xmlns="http://www.w3.org/2000/svg">
|
||||
<defs>
|
||||
<style>
|
||||
.header-name { font-family: 'Arial', sans-serif; font-size: 24px; font-weight: bold; fill: #2d3748; }
|
||||
.header-title { font-family: 'Arial', sans-serif; font-size: 12px; font-weight: normal; fill: #4a5568; text-transform: uppercase; letter-spacing: 1px; }
|
||||
.section-title { font-family: 'Arial', sans-serif; font-size: 11px; font-weight: bold; fill: #2d3748; text-transform: uppercase; letter-spacing: 1px; }
|
||||
.content-text { font-family: 'Arial', sans-serif; font-size: 9px; fill: #4a5568; }
|
||||
.content-bold { font-family: 'Arial', sans-serif; font-size: 9px; font-weight: bold; fill: #2d3748; }
|
||||
.content-medium { font-family: 'Arial', sans-serif; font-size: 9px; font-weight: 500; fill: #2d3748; }
|
||||
.timeline-line { stroke: #cbd5e0; stroke-width: 2; fill: none; }
|
||||
.timeline-dot { fill: white; stroke: #4a5568; stroke-width: 2; }
|
||||
.timeline-icon-bg { fill: #2d3748; }
|
||||
.timeline-icon { fill: white; }
|
||||
.divider { stroke: #e2e8f0; stroke-width: 1; }
|
||||
.contact-icon { fill: #4a5568; }
|
||||
</style>
|
||||
</defs>
|
||||
|
||||
<!-- Background -->
|
||||
<rect width="400" height="520" fill="white"/>
|
||||
|
||||
<!-- Header Section -->
|
||||
<text x="25" y="35" class="header-name">AHMDD SAAH</text>
|
||||
<text x="25" y="52" class="header-title">MARKETING MANAGER</text>
|
||||
<line x1="25" y1="65" x2="375" y2="65" class="divider"/>
|
||||
|
||||
<!-- Left Column (30% width = 120px) -->
|
||||
<!-- Contact Section -->
|
||||
<text x="25" y="90" class="section-title">CONTACT</text>
|
||||
<line x1="25" y1="98" x2="135" y2="98" class="divider"/>
|
||||
|
||||
<!-- Phone Icon -->
|
||||
<circle cx="30" cy="112" r="2" class="contact-icon"/>
|
||||
<text x="40" y="116" class="content-text">+124-4236-7894</text>
|
||||
|
||||
<!-- Email Icon -->
|
||||
<rect x="28" y="126" width="4" height="3" rx="1" class="contact-icon"/>
|
||||
<text x="40" y="132" class="content-text">hello@ahmedd.saaahh.com</text>
|
||||
|
||||
<!-- Location Icon -->
|
||||
<circle cx="30" cy="145" r="2" class="contact-icon"/>
|
||||
<circle cx="30" cy="145" r="1" fill="white"/>
|
||||
<text x="40" y="149" class="content-text">123fdfdgds, Any City</text>
|
||||
|
||||
<!-- Website Icon -->
|
||||
<circle cx="30" cy="162" r="2" class="contact-icon"/>
|
||||
<text x="40" y="166" class="content-text">www.ahmedd.saaahh.com</text>
|
||||
|
||||
<!-- Skills Section -->
|
||||
<text x="25" y="195" class="section-title">SKILLS</text>
|
||||
<line x1="25" y1="203" x2="135" y2="203" class="divider"/>
|
||||
|
||||
<text x="30" y="218" class="content-text">• Strategic Planning</text>
|
||||
<text x="30" y="230" class="content-text">• Problem Solving</text>
|
||||
<text x="30" y="242" class="content-text">• Crisis Management</text>
|
||||
<text x="30" y="254" class="content-text">• Creative Thinking</text>
|
||||
<text x="30" y="266" class="content-text">• Data Analysis</text>
|
||||
<text x="30" y="278" class="content-text">• Brand Development</text>
|
||||
<text x="30" y="290" class="content-text">• Negotiation</text>
|
||||
<text x="30" y="302" class="content-text">• Customer Orientation</text>
|
||||
<text x="30" y="314" class="content-text">• Adaptability to Change</text>
|
||||
|
||||
<!-- Languages Section -->
|
||||
<text x="25" y="340" class="section-title">LANGUAGES</text>
|
||||
<line x1="25" y1="348" x2="135" y2="348" class="divider"/>
|
||||
|
||||
<text x="30" y="363" class="content-text">• English (Fluent)</text>
|
||||
|
||||
<!-- Reference Section -->
|
||||
<text x="25" y="390" class="section-title">REFERENCE</text>
|
||||
<line x1="25" y1="398" x2="135" y2="398" class="divider"/>
|
||||
|
||||
<text x="25" y="413" class="content-bold">Estelle Darcy</text>
|
||||
<text x="25" y="425" class="content-text">Wardiere Inc. / CTO</text>
|
||||
<text x="25" y="437" class="content-text">Phone: +124-4236-7894</text>
|
||||
<text x="25" y="449" class="content-text">Email: hello@ahmedd.saaahh.com</text>
|
||||
|
||||
<!-- Right Column with Timeline (70% width starts at 150px) -->
|
||||
<!-- Timeline Line -->
|
||||
<line x1="170" y1="90" x2="170" y2="500" class="timeline-line"/>
|
||||
|
||||
<!-- Profile Section -->
|
||||
<circle cx="170" cy="100" r="8" class="timeline-icon-bg"/>
|
||||
<circle cx="170" cy="100" r="4" class="timeline-icon"/>
|
||||
|
||||
<text x="190" y="90" class="section-title">PROFILE</text>
|
||||
<line x1="190" y1="98" x2="375" y2="98" class="divider"/>
|
||||
|
||||
<text x="190" y="113" class="content-text">"Neimo enisnot est atem Aham ipsum, finifam imbo sido. Intum lumina</text>
|
||||
<text x="190" y="125" class="content-text">parco regia Aham extra mola idis. Commodo Aham antegor yumo finiam</text>
|
||||
<text x="190" y="137" class="content-text">carius, domus serim lacur Aham. Mauris solitude Aham in albaidis, ceras</text>
|
||||
<text x="190" y="149" class="content-text">vita Aham elit exidio leo, adipisci sector elit."</text>
|
||||
|
||||
<!-- Work Experience Section -->
|
||||
<circle cx="170" cy="180" r="8" class="timeline-icon-bg"/>
|
||||
<rect x="166" y="176" width="8" height="8" class="timeline-icon"/>
|
||||
|
||||
<text x="190" y="170" class="section-title">WORK EXPERIENCE</text>
|
||||
<line x1="190" y1="178" x2="375" y2="178" class="divider"/>
|
||||
|
||||
<!-- Borcelle Studio -->
|
||||
<circle cx="170" cy="200" r="4" class="timeline-dot"/>
|
||||
<text x="190" y="195" class="content-bold">Borcelle Studio</text>
|
||||
<text x="320" y="195" class="content-text">2030 - PRESENT</text>
|
||||
<text x="190" y="207" class="content-text">Marketing Manager & Specialist</text>
|
||||
|
||||
<text x="195" y="222" class="content-text">• Formulate and implement detailed marketing strategies and initiatives</text>
|
||||
<text x="197" y="234" class="content-text">that support the company's mission and objectives.</text>
|
||||
<text x="195" y="246" class="content-text">• Guide, inspire, and oversee a dynamic marketing team, promoting a</text>
|
||||
<text x="197" y="258" class="content-text">collaborative and performance-oriented culture.</text>
|
||||
<text x="195" y="270" class="content-text">• Ensure uniformity of the brand across all marketing platforms and</text>
|
||||
<text x="197" y="282" class="content-text">materials.</text>
|
||||
|
||||
<!-- Fauget Studio -->
|
||||
<circle cx="170" cy="305" r="4" class="timeline-dot"/>
|
||||
<text x="190" y="300" class="content-bold">Fauget Studio</text>
|
||||
<text x="320" y="300" class="content-text">2025 - 2029</text>
|
||||
<text x="190" y="312" class="content-text">Marketing Manager & Specialist</text>
|
||||
|
||||
<text x="195" y="327" class="content-text">• "Neimo enisnot est atem Aham ipsum, finifam imbo sido. Intum lumina</text>
|
||||
<text x="197" y="339" class="content-text">parco regia Aham extra mola idis. Commodo Aham antegor yumo</text>
|
||||
<text x="197" y="351" class="content-text">finiam carius, domus serim lacur Aham. Mauris solitude Aham in</text>
|
||||
<text x="197" y="363" class="content-text">albaidis, ceras vita Aham elit exidio leo, adipisci sector elit."</text>
|
||||
|
||||
<!-- Studio Shodwe -->
|
||||
<circle cx="170" cy="385" r="4" class="timeline-dot"/>
|
||||
<text x="190" y="380" class="content-bold">Studio Shodwe</text>
|
||||
<text x="320" y="380" class="content-text">2024 - 2025</text>
|
||||
<text x="190" y="392" class="content-text">Marketing Manager & Specialist</text>
|
||||
|
||||
<text x="195" y="407" class="content-text">• Formulate and implement detailed marketing strategies and initiatives</text>
|
||||
<text x="197" y="419" class="content-text">that support the company's mission and objectives. Guide, inspire, and</text>
|
||||
<text x="197" y="431" class="content-text">oversee a dynamic marketing team, promoting a collaborative and</text>
|
||||
<text x="197" y="443" class="content-text">performance-oriented culture. Ensure uniformity of the brand across</text>
|
||||
<text x="197" y="455" class="content-text">all marketing platforms and materials.</text>
|
||||
|
||||
<!-- Education Section -->
|
||||
<circle cx="170" cy="485" r="8" class="timeline-icon-bg"/>
|
||||
<polygon points="166,481 174,481 170,489" class="timeline-icon"/>
|
||||
|
||||
<text x="190" y="475" class="section-title">EDUCATION</text>
|
||||
<line x1="190" y1="483" x2="375" y2="483" class="divider"/>
|
||||
|
||||
<circle cx="170" cy="505" r="4" class="timeline-dot"/>
|
||||
<text x="190" y="500" class="content-bold">Master of Business Management</text>
|
||||
<text x="320" y="500" class="content-text">2029 - 2031</text>
|
||||
<text x="190" y="512" class="content-text">School of business | Wardiere University</text>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 8.0 KiB |
35
cv-engine/src/assets/templates/timeline_old.svg
Normal file
35
cv-engine/src/assets/templates/timeline_old.svg
Normal file
@ -0,0 +1,35 @@
|
||||
<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"/>
|
||||
<!-- Header name and role -->
|
||||
<rect x="12" y="12" width="96" height="8" fill="#111827"/>
|
||||
<rect x="12" y="22" width="80" height="6" fill="#6b7280"/>
|
||||
<line x1="12" y1="30" x2="148" y2="30" stroke="#e5e7eb"/>
|
||||
|
||||
<!-- Two columns: left sidebar blocks and right timeline -->
|
||||
<!-- Left sidebar labels -->
|
||||
<rect x="12" y="36" width="40" height="6" fill="#111827"/>
|
||||
<rect x="12" y="46" width="40" height="6" fill="#111827"/>
|
||||
<rect x="12" y="56" width="40" height="6" fill="#111827"/>
|
||||
<rect x="12" y="66" width="40" height="6" fill="#111827"/>
|
||||
|
||||
<!-- Left sidebar items -->
|
||||
<rect x="12" y="74" width="56" height="6" fill="#e5e7eb"/>
|
||||
<rect x="12" y="82" width="56" height="6" fill="#e5e7eb"/>
|
||||
|
||||
<!-- Right column timeline vertical rule -->
|
||||
<line x1="88" y1="36" x2="88" y2="86" stroke="#d1d5db" stroke-width="2"/>
|
||||
<!-- Timeline bullets -->
|
||||
<circle cx="88" cy="42" r="3" fill="#ffffff" stroke="#6b7280" stroke-width="2"/>
|
||||
<circle cx="88" cy="58" r="3" fill="#ffffff" stroke="#6b7280" stroke-width="2"/>
|
||||
<circle cx="88" cy="74" r="3" fill="#ffffff" stroke="#6b7280" stroke-width="2"/>
|
||||
|
||||
<!-- Right column items -->
|
||||
<rect x="96" y="38" width="52" height="6" fill="#111827"/>
|
||||
<rect x="96" y="46" width="52" height="6" fill="#e5e7eb"/>
|
||||
<rect x="96" y="54" width="52" height="6" fill="#111827"/>
|
||||
<rect x="96" y="62" width="52" height="6" fill="#e5e7eb"/>
|
||||
<rect x="96" y="70" width="52" height="6" fill="#111827"/>
|
||||
<rect x="96" y="78" width="52" height="6" fill="#e5e7eb"/>
|
||||
|
||||
<text x="12" y="10" font-size="10" fill="#374151" font-family="Inter, Arial">Timeline</text>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.7 KiB |
@ -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 = () => {
|
||||
</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>
|
||||
);
|
||||
|
||||
232
cv-engine/src/templates/TimelineTemplate.tsx
Normal file
232
cv-engine/src/templates/TimelineTemplate.tsx
Normal file
@ -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<TimelineTemplateProps> = ({ cv, className = '' }) => {
|
||||
const { personal, summary, work = [], education = [], skills = [], languages = [], certifications = [] } = cv;
|
||||
const headline = (personal.extras && personal.extras[0]) ? personal.extras[0] : '';
|
||||
|
||||
return (
|
||||
<div className={`bg-white text-gray-900 max-w-4xl mx-auto px-6 py-6 ${className}`} style={{ fontFamily: 'Arial, sans-serif' }}>
|
||||
{/* Header */}
|
||||
<header className="mb-8">
|
||||
<h1 className="text-3xl font-bold tracking-tight text-gray-800 uppercase">{escapeText(personal.firstName || '')} {escapeText(personal.lastName || '')}</h1>
|
||||
{headline && <div className="mt-1 text-sm tracking-widest text-gray-600 uppercase">{escapeText(headline)}</div>}
|
||||
<div className="mt-4 border-t border-gray-300" />
|
||||
</header>
|
||||
|
||||
{/* Body: two columns with exact 30%/70% split */}
|
||||
<div className="flex gap-8">
|
||||
{/* Left sidebar - 30% width */}
|
||||
<aside className="space-y-6" style={{ width: '30%' }}>
|
||||
{/* Contact */}
|
||||
<section>
|
||||
<h2 className="text-xs font-bold tracking-widest text-gray-800 uppercase">
|
||||
Contact
|
||||
</h2>
|
||||
<div className="mt-2 border-t border-gray-300" />
|
||||
<div className="mt-3 space-y-3 text-xs text-gray-700">
|
||||
{personal.phone && (
|
||||
<div className="flex items-start gap-2">
|
||||
<div className="w-2 h-2 rounded-full bg-gray-600 mt-1.5 flex-shrink-0" />
|
||||
<span>{escapeText(personal.phone)}</span>
|
||||
</div>
|
||||
)}
|
||||
{personal.email && (
|
||||
<div className="flex items-start gap-2">
|
||||
<div className="w-2 h-2 rounded-full bg-gray-600 mt-1.5 flex-shrink-0" />
|
||||
<span>{escapeText(personal.email)}</span>
|
||||
</div>
|
||||
)}
|
||||
{(personal.street || personal.city || personal.country || personal.postcode) && (
|
||||
<div className="flex items-start gap-2">
|
||||
<div className="w-2 h-2 rounded-full bg-gray-600 mt-1.5 flex-shrink-0" />
|
||||
<span>
|
||||
{escapeText(personal.street || '')}
|
||||
{(personal.street && (personal.city || personal.country || personal.postcode)) ? ', ' : ''}
|
||||
{escapeText(personal.city || '')}
|
||||
{personal.city && personal.country ? ', ' : ''}
|
||||
{escapeText(personal.country || '')}
|
||||
{personal.postcode ? ` ${escapeText(personal.postcode)}` : ''}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{(personal.links || []).map(link => (
|
||||
<div key={link.id} className="flex items-start gap-2">
|
||||
<div className="w-2 h-2 rounded-full bg-gray-600 mt-1.5 flex-shrink-0" />
|
||||
<a href={link.url} target="_blank" rel="noopener noreferrer" className="text-gray-700 hover:underline">
|
||||
{escapeText(link.label || link.url)}
|
||||
</a>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Skills */}
|
||||
{skills.length > 0 && (
|
||||
<section>
|
||||
<h2 className="text-xs font-bold tracking-widest text-gray-800 uppercase">
|
||||
Skills
|
||||
</h2>
|
||||
<div className="mt-2 border-t border-gray-300" />
|
||||
<ul className="mt-3 space-y-2 text-xs text-gray-700">
|
||||
{skills.map(s => (
|
||||
<li key={s.id} className="flex items-start gap-2">
|
||||
<span className="text-gray-700">•</span>
|
||||
<span>
|
||||
{escapeText(s.name)}{s.level ? ` (${escapeText(s.level)})` : ''}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* Languages */}
|
||||
{languages.length > 0 && (
|
||||
<section>
|
||||
<h2 className="text-xs font-bold tracking-widest text-gray-800 uppercase">
|
||||
Languages
|
||||
</h2>
|
||||
<div className="mt-2 border-t border-gray-300" />
|
||||
<ul className="mt-3 space-y-2 text-xs text-gray-700">
|
||||
{languages.map(l => (
|
||||
<li key={l.id} className="flex items-start gap-2">
|
||||
<span className="text-gray-700">•</span>
|
||||
<span>{escapeText(l.name)}{l.level ? ` (${escapeText(l.level)})` : ''}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* Reference */}
|
||||
<section>
|
||||
<h2 className="text-xs font-bold tracking-widest text-gray-800 uppercase">
|
||||
Reference
|
||||
</h2>
|
||||
<div className="mt-2 border-t border-gray-300" />
|
||||
<div className="mt-3 space-y-1 text-xs text-gray-700">
|
||||
<div className="font-semibold text-gray-800">Estelle Darcy</div>
|
||||
<div>Wardiere Inc. / CTO</div>
|
||||
<div>Phone: +124-4236-7894</div>
|
||||
<div>Email: hello@ahmedd.saaahh.com</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Certifications (optional sidebar) */}
|
||||
{certifications.length > 0 && (
|
||||
<section>
|
||||
<h2 className="text-sm font-bold tracking-widest text-gray-800 uppercase">Certifications</h2>
|
||||
<ul className="mt-2 list-disc pl-5 text-gray-800 space-y-1">
|
||||
{certifications.map(c => (
|
||||
<li key={c.id}>{escapeText(c.name)}{c.issuer ? ` - ${escapeText(c.issuer)}` : ''}{c.date ? ` (${escapeText(c.date)})` : ''}</li>
|
||||
))}
|
||||
</ul>
|
||||
</section>
|
||||
)}
|
||||
</aside>
|
||||
|
||||
{/* Right Column - Main Content */}
|
||||
<div className="flex-1 pl-8 relative">
|
||||
{/* Vertical Timeline Line */}
|
||||
<div className="absolute left-0 top-0 bottom-0 w-px bg-gray-300" />
|
||||
|
||||
{/* Profile */}
|
||||
{summary && (
|
||||
<section className="mb-8 relative">
|
||||
<div className="flex items-center gap-3 mb-4">
|
||||
<div className="absolute -left-[21px] w-3 h-3 rounded-full bg-gray-800 border-2 border-white" />
|
||||
<h2 className="text-sm font-bold text-gray-800 uppercase tracking-widest">Profile</h2>
|
||||
</div>
|
||||
<p className="text-xs text-gray-700 leading-relaxed" dangerouslySetInnerHTML={{ __html: sanitizeHtml(summary) }} />
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* Work Experience */}
|
||||
{work.length > 0 && (
|
||||
<section className="mb-8 relative">
|
||||
<div className="flex items-center gap-3 mb-6">
|
||||
<div className="absolute -left-[21px] w-3 h-3 rounded-full bg-gray-800 border-2 border-white" />
|
||||
<h2 className="text-sm font-bold text-gray-800 uppercase tracking-widest">Work Experience</h2>
|
||||
</div>
|
||||
<div className="space-y-6">
|
||||
{work.map(job => (
|
||||
<div key={job.id} className="relative">
|
||||
<div className="flex justify-between items-start mb-2">
|
||||
<div>
|
||||
<h3 className="text-sm font-bold text-gray-800">{escapeText(job.company)}</h3>
|
||||
<p className="text-xs text-gray-600">{escapeText(job.title)}</p>
|
||||
</div>
|
||||
<span className="text-xs text-gray-500 whitespace-nowrap">
|
||||
{formatDate(job.startDate)} - {job.endDate ? formatDate(job.endDate) : 'Present'}
|
||||
</span>
|
||||
</div>
|
||||
{job.bullets && job.bullets.length > 0 && (
|
||||
<div className="text-xs text-gray-700 space-y-1">
|
||||
{job.bullets.map((b, idx) => (
|
||||
<div key={idx}>• {escapeText(b)}</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{job.location && (
|
||||
<div className="mt-1 text-xs text-gray-600">{escapeText(job.location)}</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* Education */}
|
||||
{education.length > 0 && (
|
||||
<section className="relative">
|
||||
<div className="flex items-center gap-3 mb-6">
|
||||
<div className="absolute -left-[21px] w-3 h-3 rounded-full bg-gray-800 border-2 border-white" />
|
||||
<h2 className="text-sm font-bold text-gray-800 uppercase tracking-widest">Education</h2>
|
||||
</div>
|
||||
<div className="space-y-4">
|
||||
{education.map(ed => (
|
||||
<div key={ed.id}>
|
||||
<div className="flex justify-between items-start">
|
||||
<div>
|
||||
<h3 className="text-sm font-bold text-gray-800">{escapeText(ed.degree)}</h3>
|
||||
<p className="text-xs text-gray-600">{escapeText(ed.school)}</p>
|
||||
</div>
|
||||
<span className="text-xs text-gray-500 whitespace-nowrap">
|
||||
{formatDate(ed.startDate)}{ed.endDate ? ` - ${formatDate(ed.endDate)}` : ''}
|
||||
</span>
|
||||
</div>
|
||||
{ed.notes && <div className="mt-1 text-xs text-gray-700">{escapeText(ed.notes)}</div>}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default TimelineTemplate;
|
||||
@ -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]));
|
||||
@ -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 = `
|
||||
<style>
|
||||
${baseStyles}
|
||||
@ -77,20 +91,28 @@ export function buildPrintableHtml(cv, templateId) {
|
||||
|
||||
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" style="display:flex;align-items:center;gap:12px;">
|
||||
${headerPhoto}
|
||||
<div>
|
||||
const headline = Array.isArray(personal.extras) && personal.extras.length ? personal.extras[0] : '';
|
||||
const headerHtml = tid === 'timeline'
|
||||
? `
|
||||
<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>` : ''}
|
||||
${headline ? `<div class="small" style="text-transform:uppercase;margin-top:4px;color:#374151;">${escapeText(headline)}</div>` : ''}
|
||||
</div>
|
||||
`
|
||||
: `
|
||||
<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>
|
||||
</div>
|
||||
`;
|
||||
`;
|
||||
|
||||
const summarySection = summary ? `
|
||||
<div class="section">
|
||||
@ -99,30 +121,63 @@ export function buildPrintableHtml(cv, templateId) {
|
||||
</div>
|
||||
` : '';
|
||||
|
||||
const workSection = (work && work.length) ? `
|
||||
<div class="section">
|
||||
<h2>Work Experience</h2>
|
||||
${work.map(job => `
|
||||
<div class="job">
|
||||
<div class="row">
|
||||
<div>
|
||||
<h3>${escapeText(job.title)}</h3>
|
||||
<div class="small">${escapeText(job.company)}</div>
|
||||
</div>
|
||||
<div class="small">
|
||||
${escapeText(job.startDate || '')} - ${escapeText(job.endDate || 'Present')}
|
||||
${job.location ? `<div>${escapeText(job.location)}</div>` : ''}
|
||||
</div>
|
||||
const workSection = (work && work.length) ? (
|
||||
tid === 'timeline'
|
||||
? `
|
||||
<div class="section">
|
||||
<h2>Work Experience</h2>
|
||||
<div class="timeline">
|
||||
<div class="tl-line"></div>
|
||||
${work.map(job => `
|
||||
<div class="tl-row">
|
||||
<div class="tl-dot"></div>
|
||||
<div>
|
||||
<div class="row" style="justify-content:space-between;">
|
||||
<div>
|
||||
<h3>${escapeText(job.title)}</h3>
|
||||
<div class="small">${escapeText(job.company)}</div>
|
||||
</div>
|
||||
<div class="small">
|
||||
${escapeText(job.startDate || '')} - ${escapeText(job.endDate || 'Present')}
|
||||
${job.location ? `<div>${escapeText(job.location)}</div>` : ''}
|
||||
</div>
|
||||
</div>
|
||||
${(job.bullets && job.bullets.length) ? `
|
||||
<ul>
|
||||
${job.bullets.map(b => `<li>${escapeText(b)}</li>`).join('')}
|
||||
</ul>
|
||||
` : ''}
|
||||
</div>
|
||||
</div>
|
||||
`).join('')}
|
||||
</div>
|
||||
${(job.bullets && job.bullets.length) ? `
|
||||
<ul>
|
||||
${job.bullets.map(b => `<li>${escapeText(b)}</li>`).join('')}
|
||||
</ul>
|
||||
` : ''}
|
||||
</div>
|
||||
`).join('')}
|
||||
</div>
|
||||
` : '';
|
||||
`
|
||||
: `
|
||||
<div class="section">
|
||||
<h2>Work Experience</h2>
|
||||
${work.map(job => `
|
||||
<div class="job">
|
||||
<div class="row">
|
||||
<div>
|
||||
<h3>${escapeText(job.title)}</h3>
|
||||
<div class="small">${escapeText(job.company)}</div>
|
||||
</div>
|
||||
<div class="small">
|
||||
${escapeText(job.startDate || '')} - ${escapeText(job.endDate || 'Present')}
|
||||
${job.location ? `<div>${escapeText(job.location)}</div>` : ''}
|
||||
</div>
|
||||
</div>
|
||||
${(job.bullets && job.bullets.length) ? `
|
||||
<ul>
|
||||
${job.bullets.map(b => `<li>${escapeText(b)}</li>`).join('')}
|
||||
</ul>
|
||||
` : ''}
|
||||
</div>
|
||||
`).join('')}
|
||||
</div>
|
||||
`
|
||||
) : '';
|
||||
|
||||
const eduSection = (education && education.length) ? `
|
||||
<div class="section">
|
||||
@ -172,6 +227,69 @@ export function buildPrintableHtml(cv, templateId) {
|
||||
` : '';
|
||||
|
||||
const accentTop = tid === 'modern' ? '<div class="accent"></div>' : '';
|
||||
|
||||
// Sidebar for timeline theme: contact + skills/languages + references
|
||||
const referenceLines = (Array.isArray(personal.extras) ? personal.extras.slice(1) : []).filter(Boolean);
|
||||
const sidebar = tid === 'timeline' ? `
|
||||
<aside class="sidebar">
|
||||
<section>
|
||||
<h2>Contact</h2>
|
||||
<div class="divider"></div>
|
||||
<div class="contact-list">
|
||||
${personal.phone ? `<div>${escapeText(personal.phone)}</div>` : ''}
|
||||
${personal.email ? `<div>${escapeText(personal.email)}</div>` : ''}
|
||||
${(personal.street || personal.city || personal.country || personal.postcode) ? `<div>${escapeText([personal.street, personal.city, personal.country, personal.postcode].filter(Boolean).join(', '))}</div>` : ''}
|
||||
${headerLinks ? `<div>${headerLinks}</div>` : ''}
|
||||
</div>
|
||||
</section>
|
||||
${(skills && skills.length) ? `
|
||||
<section>
|
||||
<h2>Skills</h2>
|
||||
<div class="divider"></div>
|
||||
<div class="chips">${skills.map(s => `<span class="chip">${escapeText(s.name)}${s.level ? ` — ${escapeText(s.level)}` : ''}</span>`).join('')}</div>
|
||||
</section>
|
||||
` : ''}
|
||||
${(languages && languages.length) ? `
|
||||
<section>
|
||||
<h2>Languages</h2>
|
||||
<div class="divider"></div>
|
||||
<div class="chips">${languages.map(l => `<span class="chip">${escapeText(l.name)}${l.level ? ` — ${escapeText(l.level)}` : ''}</span>`).join('')}</div>
|
||||
</section>
|
||||
` : ''}
|
||||
${(referenceLines && referenceLines.length) ? `
|
||||
<section>
|
||||
<h2>Reference</h2>
|
||||
<div class="divider"></div>
|
||||
<div class="small">${referenceLines.map(r => `<div>${escapeText(r)}</div>`).join('')}</div>
|
||||
</section>
|
||||
` : ''}
|
||||
</aside>
|
||||
` : '';
|
||||
const bodyContent = tid === 'timeline'
|
||||
? `
|
||||
${accentTop}
|
||||
${headerHtml}
|
||||
<div class="grid">
|
||||
${sidebar}
|
||||
<main>
|
||||
${summarySection}
|
||||
${workSection}
|
||||
${eduSection}
|
||||
${certsSection}
|
||||
</main>
|
||||
</div>
|
||||
`
|
||||
: `
|
||||
${accentTop}
|
||||
${headerHtml}
|
||||
${summarySection}
|
||||
${workSection}
|
||||
${eduSection}
|
||||
${skillsSection}
|
||||
${languagesSection}
|
||||
${certsSection}
|
||||
`;
|
||||
|
||||
return `<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
@ -182,14 +300,7 @@ export function buildPrintableHtml(cv, templateId) {
|
||||
</head>
|
||||
<body>
|
||||
<div class="page">
|
||||
${accentTop}
|
||||
${headerHtml}
|
||||
${summarySection}
|
||||
${workSection}
|
||||
${eduSection}
|
||||
${skillsSection}
|
||||
${languagesSection}
|
||||
${certsSection}
|
||||
${bodyContent}
|
||||
</div>
|
||||
</body>
|
||||
</html>`;
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user