520 lines
20 KiB
Text
520 lines
20 KiB
Text
<!DOCTYPE html>
|
|
<html lang="en">
|
|
<%- include('../views/partials/head.html'); %>
|
|
|
|
<body class="resume-body">
|
|
<%
|
|
const normalizeSkill = (item) => {
|
|
if (typeof item === 'string') {
|
|
return { name: item, level: null, recent: false };
|
|
}
|
|
return {
|
|
name: item?.name || item?.title || item?.skill || '',
|
|
level: item?.level || item?.proficiency || null,
|
|
recent: Boolean(item?.recent)
|
|
};
|
|
};
|
|
const levelToPercent = (lvl) => {
|
|
if (!lvl) return 80;
|
|
const map = { beginner: 30, junior: 45, intermediate: 60, advanced: 75, senior: 85, expert: 95 };
|
|
const key = String(lvl).toLowerCase();
|
|
if (map[key]) return map[key];
|
|
const num = Number(lvl);
|
|
if (!Number.isNaN(num)) return Math.max(15, Math.min(100, num));
|
|
return 80;
|
|
};
|
|
%>
|
|
<div class="bg-gradient"></div>
|
|
|
|
<div id="resume-document">
|
|
<header class="topbar">
|
|
<div class="brand">
|
|
<div class="brand-avatar">
|
|
<img src="<%= data.picture %>" alt="<%= data.name %> portrait" />
|
|
</div>
|
|
<div>
|
|
<p class="eyebrow">Resume</p>
|
|
<span class="brand-name"><%= data.name %></span>
|
|
</div>
|
|
</div>
|
|
<nav class="top-nav">
|
|
<a href="#experience">Experience</a>
|
|
<% if (data.projects && data.projects.length) { %>
|
|
<a href="#projects">Projects</a>
|
|
<% } %>
|
|
<a href="#skills">Skills</a>
|
|
<a href="#education">Education</a>
|
|
<% if (data.enableTestimonials) { %>
|
|
<a href="#testimonials">Testimonials</a>
|
|
<% } %>
|
|
<a href="#interests">Interests</a>
|
|
</nav>
|
|
<div class="top-actions">
|
|
<% if (data.mail) { %>
|
|
<a class="ghost-btn" href="mailto:<%= data.mail %>">Contact</a>
|
|
<% } %>
|
|
<button class="ghost-btn" id="theme-toggle" type="button">Theme</button>
|
|
<button class="solid-btn" id="download-pdf" type="button">Download PDF</button>
|
|
</div>
|
|
</header>
|
|
|
|
<main class="page-shell">
|
|
<section class="hero" id="hero">
|
|
<div class="hero-card glass">
|
|
<div class="hero-photo">
|
|
<img src="<%= data.picture %>" alt="<%= data.name %> portrait" />
|
|
</div>
|
|
<div class="hero-copy">
|
|
<p class="eyebrow">Available for opportunities</p>
|
|
<h1><%= data.name %></h1>
|
|
<p class="role"><%= data.header %></p>
|
|
<p class="pdf-contact">
|
|
<% if (data.mail) { %><a href="mailto:<%= data.mail %>"><%= data.mail %></a><% } %>
|
|
<% if (data.mail && data.phone) { %><span> · </span><% } %>
|
|
<% if (data.phone) { %><a href="tel:<%= data.phone %>"><%= data.phone %></a><% } %>
|
|
</p>
|
|
<p class="about-text"><%= data.about %></p>
|
|
<div class="cta-row">
|
|
<% if (data.mail) { %>
|
|
<a class="solid-btn" href="mailto:<%= data.mail %>">Email me</a>
|
|
<% } %>
|
|
<button class="ghost-btn" type="button" data-download-pdf>Save as PDF</button>
|
|
</div>
|
|
<% if (data.links && data.links.length) { %>
|
|
<div class="social-row">
|
|
<% data.links.forEach(function(link, idx) { %>
|
|
<a class="chip" href="<%= link.link %>" target="_blank" rel="noopener">
|
|
<img src="<%= link.imagePath %>" alt="<%= link.label || ('Link ' + (idx + 1)) %>" />
|
|
</a>
|
|
<% }); %>
|
|
</div>
|
|
<% } %>
|
|
</div>
|
|
</div>
|
|
</section>
|
|
|
|
<section id="experience" class="section">
|
|
<div class="section-head">
|
|
<div>
|
|
<p class="eyebrow">Career</p>
|
|
<h2>Experience</h2>
|
|
</div>
|
|
<span class="section-sub">Impactful roles & highlights</span>
|
|
</div>
|
|
<% if (data.workexperiences && data.workexperiences.length) { %>
|
|
<ol class="timeline">
|
|
<% data.workexperiences.forEach(function(work) { %>
|
|
<li class="timeline-item glass">
|
|
<div class="timeline-meta">
|
|
<span class="pill"><%= work.date %></span>
|
|
</div>
|
|
<div class="timeline-body">
|
|
<p class="meta">
|
|
<%= work.title %>
|
|
<% if (work.location) { %> · <%= work.location %><% } %>
|
|
</p>
|
|
<h3>
|
|
<% if (work.link) { %>
|
|
<a href="<%= work.link %>" target="_blank" rel="noopener"><%= work.desc %></a>
|
|
<% } else { %>
|
|
<%= work.desc %>
|
|
<% } %>
|
|
</h3>
|
|
<% if (work.tech && work.tech.length) { %>
|
|
<div class="chips tight">
|
|
<% work.tech.forEach(function(t) { %>
|
|
<span class="chip chip-ghost"><%= t %></span>
|
|
<% }); %>
|
|
</div>
|
|
<% } %>
|
|
<% if (work.highlights && work.highlights.length) { %>
|
|
<ul class="bullets">
|
|
<% work.highlights.forEach(function(point) { %>
|
|
<li><%= point %></li>
|
|
<% }); %>
|
|
</ul>
|
|
<% } %>
|
|
<% if (work.metrics && work.metrics.length) { %>
|
|
<div class="chips tight metrics">
|
|
<% work.metrics.forEach(function(m) { %>
|
|
<span class="chip chip-accent"><%= m %></span>
|
|
<% }); %>
|
|
</div>
|
|
<% } %>
|
|
</div>
|
|
</li>
|
|
<% }); %>
|
|
</ol>
|
|
<% if (data.menus && data.menus.length) { %>
|
|
<aside class="pdf-core-stack" aria-label="Core capabilities">
|
|
<p class="pdf-core-label">Core capabilities</p>
|
|
<div class="pdf-core-grid">
|
|
<% data.menus.slice(0, 3).forEach(function(menu) { %>
|
|
<div class="pdf-core-card">
|
|
<strong><%= menu.title %></strong>
|
|
<span>
|
|
<%= (menu.subentries || []).slice(0, 4).map(function(item) { return normalizeSkill(item).name; }).filter(Boolean).join(' · ') %>
|
|
</span>
|
|
</div>
|
|
<% }); %>
|
|
</div>
|
|
</aside>
|
|
<% } %>
|
|
<% } else { %>
|
|
<p class="muted">No work experience available.</p>
|
|
<% } %>
|
|
</section>
|
|
|
|
<% if (data.projects && data.projects.length) { %>
|
|
<section id="projects" class="section">
|
|
<div class="section-head">
|
|
<div>
|
|
<p class="eyebrow">Showreel</p>
|
|
<h2>Projects</h2>
|
|
</div>
|
|
<span class="section-sub">Selected work with stack highlights</span>
|
|
</div>
|
|
<div class="projects-grid">
|
|
<% data.projects.forEach(function(project) { %>
|
|
<article class="project-card glass">
|
|
<% if (project.thumb) { %>
|
|
<div class="project-thumb">
|
|
<img src="<%= project.thumb %>" alt="<%= project.name %> thumbnail" />
|
|
</div>
|
|
<% } %>
|
|
<div class="project-body">
|
|
<div class="project-meta">
|
|
<% if (project.year) { %>
|
|
<span class="pill pill-ghost"><%= project.year %></span>
|
|
<% } %>
|
|
<% if (project.type) { %>
|
|
<span class="pill pill-ghost"><%= project.type %></span>
|
|
<% } %>
|
|
</div>
|
|
<h3><%= project.name %></h3>
|
|
<p class="muted"><%= project.summary %></p>
|
|
<% if (project.stack && project.stack.length) { %>
|
|
<div class="chips">
|
|
<% project.stack.forEach(function(tech) { %>
|
|
<span class="chip chip-ghost"><%= tech %></span>
|
|
<% }); %>
|
|
</div>
|
|
<% } %>
|
|
<div class="project-links">
|
|
<% if (project.link) { %>
|
|
<a class="ghost-btn small-btn" href="<%= project.link %>" target="_blank" rel="noopener">Live</a>
|
|
<% } %>
|
|
<% if (project.repo) { %>
|
|
<a class="ghost-btn small-btn" href="<%= project.repo %>" target="_blank" rel="noopener">Repo</a>
|
|
<% } %>
|
|
</div>
|
|
</div>
|
|
</article>
|
|
<% }); %>
|
|
</div>
|
|
</section>
|
|
<% } %>
|
|
|
|
<section id="skills" class="section">
|
|
<div class="section-head">
|
|
<div>
|
|
<p class="eyebrow">Strengths</p>
|
|
<h2>Skills</h2>
|
|
</div>
|
|
<span class="section-sub">Tools, stacks, specialties</span>
|
|
</div>
|
|
<% if (data.menus && data.menus.length) { %>
|
|
<div class="card-grid">
|
|
<% data.menus.forEach(function(menu) { %>
|
|
<div class="card glass skill-card">
|
|
<div class="card-header">
|
|
<span class="pill pill-ghost"><%= menu.title %></span>
|
|
</div>
|
|
<div class="skill-list">
|
|
<% (menu.subentries || []).forEach(function(detail) {
|
|
const skill = normalizeSkill(detail);
|
|
%>
|
|
<div class="skill-row <%= skill.recent ? 'skill-recent' : '' %>">
|
|
<div class="skill-label"><%= skill.name %></div>
|
|
<% if (skill.level) { %>
|
|
<div class="skill-level">
|
|
<div class="skill-level-fill" style="width:<%= levelToPercent(skill.level) %>%"></div>
|
|
</div>
|
|
<% } %>
|
|
</div>
|
|
<% }); %>
|
|
</div>
|
|
</div>
|
|
<% }); %>
|
|
</div>
|
|
<% } else { %>
|
|
<p class="muted">No skills available.</p>
|
|
<% } %>
|
|
</section>
|
|
|
|
<section id="education" class="section">
|
|
<div class="section-head">
|
|
<div>
|
|
<p class="eyebrow">Learning</p>
|
|
<h2>Education</h2>
|
|
</div>
|
|
</div>
|
|
<% if (data.educations && data.educations.length) { %>
|
|
<div class="card-grid two-col">
|
|
<% data.educations.forEach(function(education) { %>
|
|
<div class="card glass">
|
|
<span class="pill"><%= education.date %></span>
|
|
<h3><%= education.desc1 %></h3>
|
|
<p class="muted"><%= education.desc2 %></p>
|
|
</div>
|
|
<% }); %>
|
|
</div>
|
|
<% } else { %>
|
|
<p class="muted">No education available.</p>
|
|
<% } %>
|
|
</section>
|
|
|
|
<% if (data.enableTestimonials) { %>
|
|
<section id="testimonials" class="section">
|
|
<div class="section-head">
|
|
<div>
|
|
<p class="eyebrow">Feedback</p>
|
|
<h2>Testimonials</h2>
|
|
</div>
|
|
</div>
|
|
<% if (data.testimonials && data.testimonials.length) { %>
|
|
<div class="testimonial-slider" data-count="<%= data.testimonials.length %>" data-visible="2">
|
|
<div class="testimonial-track">
|
|
<% data.testimonials.forEach(function(testimonial) { %>
|
|
<div class="testimonial-card glass">
|
|
<% if (testimonial.avatar) { %>
|
|
<div class="testimonial-avatar">
|
|
<img src="<%= testimonial.avatar %>" alt="<%= testimonial.name %> avatar">
|
|
</div>
|
|
<% } %>
|
|
<h3><%= testimonial.name %></h3>
|
|
<p class="muted"><%= testimonial.title %></p>
|
|
<p class="quote">“<%= testimonial.text %>”</p>
|
|
<% if (testimonial.rating) { %>
|
|
<div class="rating">
|
|
<% for (let i = 0; i < Number(testimonial.rating || 0); i++) { %>
|
|
<span>★</span>
|
|
<% } %>
|
|
</div>
|
|
<% } %>
|
|
</div>
|
|
<% }); %>
|
|
</div>
|
|
<div class="testimonial-dots"></div>
|
|
</div>
|
|
<% } else { %>
|
|
<p class="muted">No testimonials available.</p>
|
|
<% } %>
|
|
</section>
|
|
<% } %>
|
|
|
|
<section id="interests" class="section">
|
|
<div class="section-head">
|
|
<div>
|
|
<p class="eyebrow">Beyond work</p>
|
|
<h2>Interests</h2>
|
|
</div>
|
|
</div>
|
|
<% if (data.interests && data.interests.length) { %>
|
|
<div class="chips interest-chips">
|
|
<% data.interests.forEach(function(interest) { %>
|
|
<div class="interest-block glass">
|
|
<span class="pill pill-ghost"><%= interest.title %></span>
|
|
<% if (interest.subentries && interest.subentries.length) { %>
|
|
<div class="subchips">
|
|
<% interest.subentries.forEach(function(item) { %>
|
|
<span class="chip chip-ghost"><%= item %></span>
|
|
<% }); %>
|
|
</div>
|
|
<% } %>
|
|
</div>
|
|
<% }); %>
|
|
</div>
|
|
<% } else { %>
|
|
<p class="muted">No interests available.</p>
|
|
<% } %>
|
|
</section>
|
|
</main>
|
|
|
|
<footer class="page-footer">
|
|
<div class="footer-actions">
|
|
<% if (data.mail) { %>
|
|
<a class="solid-btn" href="mailto:<%= data.mail %>">Let's talk</a>
|
|
<% } %>
|
|
<button class="ghost-btn" type="button" data-download-pdf>Download PDF</button>
|
|
<button class="ghost-btn" type="button" onclick="printPage()">Print</button>
|
|
</div>
|
|
<p class="muted small">Built with Express + EJS — content pulled from JSON.</p>
|
|
</footer>
|
|
</div>
|
|
|
|
<div class="contact-strip glass">
|
|
<% if (data.mail) { %>
|
|
<a href="mailto:<%= data.mail %>">Email</a>
|
|
<% } %>
|
|
<% if (data.phone) { %>
|
|
<a href="tel:<%= data.phone %>">Call</a>
|
|
<% } %>
|
|
<% if (data.calendly) { %>
|
|
<a href="<%= data.calendly %>" target="_blank" rel="noopener">Meet</a>
|
|
<% } %>
|
|
<button type="button" data-download-pdf>PDF</button>
|
|
</div>
|
|
|
|
<script src="/vendor/html2pdf/html2pdf.bundle.min.js"></script>
|
|
<script>
|
|
function printPage() {
|
|
window.print();
|
|
}
|
|
|
|
// Renders the visible resume in the browser. Resume data never leaves the device.
|
|
(function () {
|
|
const source = document.getElementById('resume-document');
|
|
const buttons = Array.from(document.querySelectorAll('[data-download-pdf], #download-pdf'));
|
|
const filename = '<%= data.name.replace(/[^a-z0-9]+/gi, '-').replace(/^-|-$/g, '') || 'resume' %>-resume.pdf';
|
|
|
|
const setBusy = (busy) => {
|
|
buttons.forEach((button) => {
|
|
if (!button.dataset.label) button.dataset.label = button.textContent;
|
|
button.disabled = busy;
|
|
button.textContent = busy ? 'Preparing PDF…' : button.dataset.label;
|
|
});
|
|
};
|
|
|
|
const downloadPdf = async () => {
|
|
if (!window.html2pdf || !source) {
|
|
window.print();
|
|
return;
|
|
}
|
|
|
|
setBusy(true);
|
|
const previewMode = new URLSearchParams(window.location.search).has('pdf-preview');
|
|
document.body.classList.add('pdf-export');
|
|
try {
|
|
if (document.fonts?.ready) await document.fonts.ready;
|
|
await new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve)));
|
|
|
|
await window.html2pdf()
|
|
.set({
|
|
margin: [7, 7, 7, 7],
|
|
filename,
|
|
image: { type: 'jpeg', quality: 0.98 },
|
|
html2canvas: { scale: 2, useCORS: true, backgroundColor: null, logging: false },
|
|
jsPDF: { unit: 'mm', format: 'a4', orientation: 'portrait' },
|
|
pagebreak: { mode: ['css', 'legacy'], avoid: ['.hero-card', '.timeline-item', '.card', '.project-card', '.interest-block', '.testimonial-card'] }
|
|
})
|
|
.from(source)
|
|
.save();
|
|
} catch (error) {
|
|
console.error('PDF generation failed:', error);
|
|
window.print();
|
|
} finally {
|
|
if (!previewMode) document.body.classList.remove('pdf-export');
|
|
setBusy(false);
|
|
}
|
|
};
|
|
|
|
buttons.forEach((button) => button.addEventListener('click', downloadPdf));
|
|
if (new URLSearchParams(window.location.search).has('pdf-preview')) {
|
|
document.body.classList.add('pdf-export');
|
|
}
|
|
})();
|
|
|
|
// Theme toggle with localStorage persistence
|
|
(function () {
|
|
const toggleBtn = document.getElementById('theme-toggle');
|
|
const saved = localStorage.getItem('resume-theme');
|
|
// Default to light unless user explicitly chose dark
|
|
if (saved !== 'dark') {
|
|
document.body.classList.add('theme-light');
|
|
}
|
|
const updateLabel = () => {
|
|
if (toggleBtn) toggleBtn.textContent = document.body.classList.contains('theme-light') ? 'Dark' : 'Light';
|
|
};
|
|
updateLabel();
|
|
if (toggleBtn) {
|
|
toggleBtn.addEventListener('click', () => {
|
|
document.body.classList.toggle('theme-light');
|
|
const mode = document.body.classList.contains('theme-light') ? 'light' : 'dark';
|
|
localStorage.setItem('resume-theme', mode);
|
|
updateLabel();
|
|
});
|
|
}
|
|
})();
|
|
|
|
// Testimonials slider (simple fade)
|
|
(() => {
|
|
const slider = document.querySelector('.testimonial-slider');
|
|
if (!slider) return;
|
|
const track = slider.querySelector('.testimonial-track');
|
|
const cards = Array.from(track?.children || []);
|
|
if (!cards.length) return;
|
|
const dotsWrap = slider.querySelector('.testimonial-dots');
|
|
const gap = parseFloat(getComputedStyle(track).gap || 0);
|
|
|
|
let visible = window.matchMedia('(min-width: 768px)').matches ? 2 : 1;
|
|
slider.dataset.visible = String(visible);
|
|
|
|
const buildDots = () => {
|
|
dotsWrap.innerHTML = '';
|
|
const slides = Math.ceil(cards.length / visible);
|
|
for (let i = 0; i < slides; i++) {
|
|
const btn = document.createElement('button');
|
|
btn.className = 'dot' + (i === 0 ? ' active' : '');
|
|
btn.dataset.index = String(i);
|
|
btn.setAttribute('aria-label', `Go to testimonial ${i + 1}`);
|
|
btn.addEventListener('click', () => slideTo(i));
|
|
dotsWrap.appendChild(btn);
|
|
}
|
|
};
|
|
|
|
let idx = 0;
|
|
const slideTo = (i) => {
|
|
const slides = Math.ceil(cards.length / visible);
|
|
idx = Math.max(0, Math.min(i, slides - 1));
|
|
const cardWidth = cards[0].getBoundingClientRect().width;
|
|
const offset = idx * (cardWidth + gap);
|
|
track.style.transform = `translateX(-${offset}px)`;
|
|
dotsWrap.querySelectorAll('.dot').forEach((d, n) => d.classList.toggle('active', n === idx));
|
|
};
|
|
|
|
buildDots();
|
|
slideTo(0);
|
|
|
|
let timer = null;
|
|
const start = () => {
|
|
const slides = Math.ceil(cards.length / visible);
|
|
if (slides <= 1) return;
|
|
timer = setInterval(() => {
|
|
const slidesNow = Math.ceil(cards.length / visible);
|
|
slideTo((idx + 1) % slidesNow);
|
|
}, 5500);
|
|
};
|
|
const stop = () => timer && clearInterval(timer);
|
|
|
|
start();
|
|
slider.addEventListener('mouseenter', stop);
|
|
slider.addEventListener('mouseleave', start);
|
|
|
|
window.addEventListener('resize', () => {
|
|
const nextVisible = window.matchMedia('(min-width: 768px)').matches ? 2 : 1;
|
|
if (nextVisible !== visible) {
|
|
visible = nextVisible;
|
|
slider.dataset.visible = String(visible);
|
|
buildDots();
|
|
slideTo(0);
|
|
stop();
|
|
start();
|
|
} else {
|
|
slideTo(idx);
|
|
}
|
|
});
|
|
})();
|
|
</script>
|
|
</body>
|
|
</html>
|