myResume2/views/cv.ejs
Blade34242 b0a1c19349
Some checks failed
Node CI / lint (push) Waiting to run
Create Forgejo release / package-and-release (push) Has been cancelled
feat: add client-side PDF and Forgejo release flow
2026-07-27 17:37:24 +07:00

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>