399 lines
18 KiB
PHP
399 lines
18 KiB
PHP
<?php
|
||
|
||
/**
|
||
* Content config:
|
||
*/
|
||
$cvData = json_decode(file_get_contents(__DIR__ . '/cv.json'), true);
|
||
|
||
$name = $cvData['name'] ?? '';
|
||
$headline = $cvData['headline'] ?? '';
|
||
$role_summary = $cvData['role_summary'] ?? '';
|
||
$location = $cvData['location'] ?? '';
|
||
$photo_file = $cvData['photo'] ?? '';
|
||
|
||
$nav_links = [
|
||
'Blog' => 'https://wp.dejvino.com/',
|
||
'Projects' => 'https://projects.dejvino.com/',
|
||
'Code' => 'https://git.dejvino.cz/',
|
||
];
|
||
$highlights = $cvData['highlights'] ?? [];
|
||
|
||
$site_url = "https://www.dejvino.cz/";
|
||
$site_title = $name;
|
||
$site_desc = "$name - $headline";
|
||
|
||
$mailto_link = $cvData['contact_links']['Email'] ?? '';
|
||
if (preg_match('/mailto:([^@]+)/', $mailto_link, $m)) {
|
||
$email_user = $m[1];
|
||
} else {
|
||
$email_user = $cvData['email'] ?? '';
|
||
}
|
||
|
||
$extra_contact_links = $cvData['contact_links'] ?? [];
|
||
|
||
/**
|
||
* Icon SVGs for link pills
|
||
*/
|
||
$pill_icons = [
|
||
'Website' => '<svg class="pill-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><line x1="2" y1="12" x2="22" y2="12"/><path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z"/></svg>',
|
||
'Email' => '<svg class="pill-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4 4h16c1.1 0 2 .9 2 2v12c0 1.1-.9 2-2 2H4c-1.1 0-2-.9-2-2V6c0-1.1.9-2 2-2z"/><polyline points="22,6 12,13 2,6"/></svg>',
|
||
'GitLab' => '<svg class="pill-icon" viewBox="0 0 24 24" fill="currentColor"><path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm-1 17.93c-3.95-.49-7-3.85-7-7.93 0-.62.08-1.21.21-1.79L9 15v1c0 1.1.9 2 2 2v1.93zm6.9-2.54c-.26-.81-1-1.39-1.9-1.39h-1v-3c0-.55-.45-1-1-1H8v-2h2c.55 0 1-.45 1-1V7h2c1.1 0 2-.9 2-2v-.41c2.93 1.19 5 4.06 5 7.41 0 2.08-.8 3.97-2.1 5.39z"/></svg>',
|
||
'GitHub' => '<svg class="pill-icon" viewBox="0 0 24 24" fill="currentColor"><path d="M12 2C6.477 2 2 6.477 2 12c0 4.42 2.865 8.166 6.839 9.489.5.092.682-.217.682-.482 0-.237-.008-.866-.013-1.7-2.782.604-3.369-1.34-3.369-1.34-.454-1.156-1.11-1.463-1.11-1.463-.908-.62.069-.608.069-.608 1.003.07 1.531 1.03 1.531 1.03.892 1.529 2.341 1.087 2.91.832.092-.647.35-1.088.636-1.338-2.22-.253-4.555-1.11-4.555-4.943 0-1.091.39-1.984 1.029-2.683-.103-.253-.446-1.27.098-2.647 0 0 .84-.269 2.75 1.025A9.578 9.578 0 0 1 12 6.836a9.59 9.59 0 0 1 2.504.337c1.909-1.294 2.747-1.025 2.747-1.025.546 1.377.203 2.394.1 2.647.64.699 1.028 1.592 1.028 2.683 0 3.842-2.339 4.687-4.566 4.935.359.309.678.919.678 1.852 0 1.336-.012 2.415-.012 2.743 0 .267.18.578.688.48C19.138 20.163 22 16.418 22 12c0-5.523-4.477-10-10-10z"/></svg>',
|
||
'CV' => '<svg class="pill-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/><line x1="16" y1="13" x2="8" y2="13"/><line x1="16" y1="17" x2="8" y2="17"/></svg>',
|
||
'PDF' => '<svg class="pill-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/><path d="M9 15v-2h2a1.5 1.5 0 0 1 0 3H9v-1c0-.55.45-1 1-1h1"/><line x1="9" y1="13" x2="11" y2="13"/></svg>',
|
||
'Blog' => '<svg class="pill-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"/><path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"/></svg>',
|
||
'Projects' => '<svg class="pill-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"/></svg>',
|
||
'Code' => '<svg class="pill-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="16 18 22 12 16 6"/><polyline points="8 6 2 12 8 18"/></svg>',
|
||
];
|
||
|
||
/**
|
||
* Extract a readable URL from a href (strip protocol, trailing slash)
|
||
*/
|
||
function pillUrl(string $href): string {
|
||
$url = $href;
|
||
if (preg_match('/^[a-z]+:\/\//i', $url)) {
|
||
$url = preg_replace('/^[a-z]+:\/\//i', '', $url);
|
||
}
|
||
$url = rtrim($url, '/');
|
||
return strlen($url) > 30 ? mb_substr($url, 0, 27) . '…' : $url;
|
||
}
|
||
|
||
|
||
/**
|
||
* CV Data
|
||
*/
|
||
// Map experience to expected structure
|
||
$cv_experience = array_map(function ($e) {
|
||
return [
|
||
'title' => $e['title'] ?? '',
|
||
'org' => $e['organization'] ?? '',
|
||
'date' => $e['date'] ?? '',
|
||
'desc' => $e['description'] ?? '',
|
||
];
|
||
}, $cvData['experience'] ?? []);
|
||
|
||
// Helper to calculate years between start and end dates
|
||
function calculateYears($dateStr) {
|
||
// Extract start and end years, support 'present' or 'current'
|
||
$dateStr = str_replace(['–', '—', '−', '-'], '-', $dateStr);
|
||
$parts = array_map('trim', explode('-', $dateStr, 2));
|
||
if (count($parts) < 2) {
|
||
return 0;
|
||
}
|
||
$start = (int)$parts[0];
|
||
$endRaw = strtolower($parts[1]);
|
||
$end = ($endRaw === 'present' || $endRaw === 'current') ? (int)date('Y') : (int)$endRaw;
|
||
$years = $end - $start;
|
||
if ($years <= 1) {
|
||
return "1 year";
|
||
}
|
||
return "$years years";
|
||
}
|
||
|
||
// Map education to expected structure
|
||
$cv_education = array_map(function ($e) {
|
||
return [
|
||
'title' => $e['degree'] ?? '',
|
||
'org' => $e['institution'] ?? '',
|
||
'date' => $e['date'] ?? '',
|
||
'desc' => $e['thesis'] ?? '',
|
||
];
|
||
}, $cvData['education'] ?? []);
|
||
|
||
// Keep skills categories as provided
|
||
$cv_skills = $cvData['skills'] ?? [];
|
||
|
||
/**
|
||
* Page design:
|
||
* - clean and simple, human-maintainable
|
||
* - no dependencies
|
||
* - single-file website
|
||
*/
|
||
?>
|
||
<!DOCTYPE html>
|
||
<html lang="en">
|
||
|
||
<head>
|
||
<meta charset="utf-8">
|
||
<title><?= $site_title ?></title>
|
||
<link rel="canonical" href="<?= $site_url ?>">
|
||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||
<meta name="description" content="<?= $site_desc ?>">
|
||
<meta name="author" content="<?= $name ?>">
|
||
|
||
<!-- Open Graph / Social Media -->
|
||
<meta property="og:type" content="website">
|
||
<meta property="og:url" content="<?= $site_url ?>">
|
||
<meta property="og:title" content="<?= $name ?>">
|
||
<meta property="og:description" content="<?= $headline ?>">
|
||
|
||
<style>
|
||
<?php
|
||
include 'style.css';
|
||
?>
|
||
</style>
|
||
<link rel="shortcut icon" href="favicon.ico">
|
||
</head>
|
||
|
||
<body class="preload">
|
||
|
||
<main class="bento-grid">
|
||
|
||
<section class="card hero">
|
||
<div class="hero-row hero-identity">
|
||
<?php
|
||
if (file_exists($photo_file)) {
|
||
$avatarBase64 = base64_encode(file_get_contents($photo_file));
|
||
echo '<img src="data:image/png;base64,' . $avatarBase64 . '" alt="' . $name . '" class="avatar" width="80" height="106" />';
|
||
} else {
|
||
echo '<div class="avatar-placeholder"></div>';
|
||
}
|
||
?>
|
||
<div class="hero-name">
|
||
<h1><?= $name ?></h1>
|
||
<?php if ($headline): ?>
|
||
<p class="headline"><?= $headline ?></p>
|
||
<?php endif; ?>
|
||
<?php if ($location): ?>
|
||
<p class="location"><?= $location ?></p>
|
||
<?php endif; ?>
|
||
</div>
|
||
</div>
|
||
|
||
<?php if ($role_summary): ?>
|
||
<div class="hero-row hero-description">
|
||
<p><?= $role_summary ?></p>
|
||
</div>
|
||
<?php endif; ?>
|
||
|
||
<div class="hero-row hero-contacts">
|
||
<?php foreach ($extra_contact_links as $label => $href): ?>
|
||
<a href="<?= $href ?>" target="_blank" class="pill contact-pill">
|
||
<?= $pill_icons[$label] ?? '' ?>
|
||
<span class="pill-label"><?= $label ?></span>
|
||
<span class="pill-url"><?= pillUrl($href) ?></span>
|
||
</a>
|
||
<?php endforeach; ?>
|
||
</div>
|
||
|
||
<div class="hero-row hero-nav">
|
||
<?php foreach ($nav_links as $label => $href): ?>
|
||
<a href="<?= trim($href) ?>" target="_blank" class="pill nav-pill">
|
||
<?= $pill_icons[$label] ?? '' ?>
|
||
<span class="pill-label"><?= $label ?></span>
|
||
<span class="pill-url"><?= pillUrl($href) ?></span>
|
||
</a>
|
||
<?php endforeach; ?>
|
||
</div>
|
||
</section>
|
||
|
||
|
||
<?php if ($highlights): ?>
|
||
<section class="card selected-work">
|
||
<h2>Selected Work</h2>
|
||
<?php foreach ($highlights as $h): ?>
|
||
<article class="highlight-item">
|
||
<h3><?= $h['title'] ?? '' ?></h3>
|
||
<?php if ($h['subtitle'] ?? ''): ?>
|
||
<p class="highlight-subtitle"><?= $h['subtitle'] ?></p>
|
||
<?php endif; ?>
|
||
<?php if ($h['description'] ?? ''): ?>
|
||
<p class="highlight-description"><?= $h['description'] ?></p>
|
||
<?php endif; ?>
|
||
</article>
|
||
<?php endforeach; ?>
|
||
</section>
|
||
<?php endif; ?>
|
||
|
||
<?php if ($cvData['outside_work'] ?? []): ?>
|
||
<section class="card personal">
|
||
<h2>Outside Work</h2>
|
||
<div class="personal-tags">
|
||
<?php foreach ($cvData['outside_work'] as $tag): ?>
|
||
<span class="personal-tag"><?= $tag ?></span>
|
||
<?php endforeach; ?>
|
||
</div>
|
||
</section>
|
||
<?php endif; ?>
|
||
|
||
<section class="card cv-container">
|
||
<label class="cv-header" for="cv-toggle">
|
||
<div class="cv-toggle-container">
|
||
<span id="toggle-label-text">Looking to hire?</span>
|
||
<span class="switch">
|
||
<input type="checkbox" id="cv-toggle">
|
||
<span class="slider"></span>
|
||
</span>
|
||
</div>
|
||
</label>
|
||
|
||
<div class="cv-content">
|
||
<?php $file_mod_time = filemtime(__FILE__); ?>
|
||
<p class="cv-modified hidden hidden-toggle">Modified: <?= date("Y-m-d", $file_mod_time) ?></p>
|
||
<?php
|
||
$firstJob = $cv_experience[0] ?? null;
|
||
if ($firstJob):
|
||
?>
|
||
<section class="cv-section first-experience hidden hidden-toggle">
|
||
<h2>Latest Experience</h2>
|
||
<article class="cv-item">
|
||
<header class="cv-item-header">
|
||
<h3 class="cv-title"><?= $firstJob['title'] ?></h3>
|
||
<span class="cv-date"><?= $firstJob['date'] ?> (<?= calculateYears($firstJob['date']) ?>)</span>
|
||
</header>
|
||
<p class="cv-org"><?= $firstJob['org'] ?></p>
|
||
<p class="cv-desc"><?= $firstJob['desc'] ?></p>
|
||
</article>
|
||
</section>
|
||
<?php endif; ?>
|
||
<section class="cv-section skills hidden hidden-toggle">
|
||
<h2>Skills</h2>
|
||
<div class="skill-categories">
|
||
<?php
|
||
$global_i = 0;
|
||
foreach ($cv_skills as $category => $skills): ?>
|
||
<div class="skill-group">
|
||
<h3><?= $category ?></h3>
|
||
<div class="skill-tags">
|
||
<?php foreach ($skills as $skill):
|
||
$fontSize = 0.85 + (($global_i * 3) % 5) * 0.2;
|
||
$opacity = 0.6 + (($global_i * 2) % 4) * 0.1;
|
||
?>
|
||
<span class="tag" style="transition-delay: <?= 0.6 + ($global_i * 0.05) ?>s; font-size: <?= $fontSize ?>rem; opacity: <?= $opacity ?>;"><?= $skill ?></span>
|
||
<?php $global_i++;
|
||
endforeach; ?>
|
||
</div>
|
||
</div>
|
||
<?php endforeach; ?>
|
||
</div>
|
||
</section>
|
||
|
||
<section class="cv-section experience hidden hidden-toggle">
|
||
<h2>Past Experience</h2>
|
||
<?php foreach (array_slice($cv_experience, 1) as $job): ?>
|
||
<article class="cv-item">
|
||
<header class="cv-item-header">
|
||
<h3 class="cv-title"><?= $job['title'] ?></h3>
|
||
<span class="cv-date"><?= $job['date'] ?> (<?= calculateYears($job['date']) ?>)</span>
|
||
</header>
|
||
<p class="cv-org"><?= $job['org'] ?></p>
|
||
<p class="cv-desc"><?= $job['desc'] ?></p>
|
||
</article>
|
||
<?php endforeach; ?>
|
||
</section>
|
||
|
||
<section class="cv-section education hidden hidden-toggle">
|
||
<h2>Education</h2>
|
||
<?php foreach ($cv_education as $edu): ?>
|
||
<article class="cv-item">
|
||
<header class="cv-item-header">
|
||
<h3 class="cv-title"><?= $edu['title'] ?></h3>
|
||
<time class="cv-date"><?= $edu['date'] ?></time>
|
||
</header>
|
||
<p class="cv-org"><?= $edu['org'] ?></p>
|
||
<?php if ($edu['desc']): ?>
|
||
<p class="cv-desc"><?= $edu['desc'] ?></p>
|
||
<?php endif; ?>
|
||
</article>
|
||
<?php endforeach; ?>
|
||
</section>
|
||
</div>
|
||
</section>
|
||
</main>
|
||
|
||
<script>
|
||
((u, d, t) => {
|
||
const addr = (atob(u) + '@' + d + '.' + t).trim();
|
||
const el = document.getElementById("contact-mail");
|
||
if (el) el.innerHTML = `<a href="mailto:${addr}">${addr}</a>`;
|
||
})('<?= base64_encode($email_user) ?>', 'dejvino', 'cz');
|
||
|
||
const animationDuration = 500; // Matches CSS animation duration (0.5s)
|
||
const staggerDelay = 200; // Delay between each panel unrolling (ms)
|
||
|
||
const toggleCV = (show, updateHash = true, animate = true) => {
|
||
const grid = document.querySelector('.bento-grid');
|
||
const labelText = document.getElementById('toggle-label-text');
|
||
|
||
if (labelText) labelText.innerText = show ? 'Here is my Resume' : 'Looking to hire?';
|
||
|
||
const cvPanels = Array.from(document.querySelectorAll('.hidden-toggle')).filter(el => el);
|
||
|
||
if (show) {
|
||
grid.classList.add('cv-mode');
|
||
|
||
cvPanels.forEach((el, index) => {
|
||
const reveal = () => {
|
||
el.classList.remove('hidden', 'fade-out', 'reveal');
|
||
void el.offsetWidth; // Force reflow
|
||
el.classList.add('reveal');
|
||
};
|
||
if (animate) setTimeout(reveal, index * staggerDelay);
|
||
else reveal();
|
||
});
|
||
} else {
|
||
const panels = [...cvPanels].reverse();
|
||
panels.forEach((el, index) => {
|
||
const hide = () => {
|
||
el.classList.remove('reveal');
|
||
if (animate) {
|
||
el.classList.add('fade-out');
|
||
setTimeout(() => {
|
||
el.classList.add('hidden');
|
||
el.classList.remove('fade-out');
|
||
}, animationDuration);
|
||
} else {
|
||
el.classList.add('hidden');
|
||
}
|
||
};
|
||
if (animate) setTimeout(hide, index * staggerDelay);
|
||
else hide();
|
||
});
|
||
|
||
if (animate) {
|
||
// Remove cv-mode only after the longest-running shrink animation finishes
|
||
const totalRollupTime = (cvPanels.length * staggerDelay) + animationDuration;
|
||
setTimeout(() => grid.classList.remove('cv-mode'), totalRollupTime);
|
||
} else {
|
||
grid.classList.remove('cv-mode');
|
||
}
|
||
}
|
||
|
||
if (updateHash) {
|
||
window.location.hash = show ? 'CV' : '';
|
||
}
|
||
|
||
// Keep toggle in sync
|
||
const toggle = document.getElementById('cv-toggle');
|
||
if (toggle) toggle.checked = show;
|
||
};
|
||
|
||
// Initialize state from URL
|
||
const checkHash = (animate = false) => toggleCV(window.location.hash === '#CV', false, animate);
|
||
|
||
window.addEventListener('load', () => {
|
||
checkHash(false);
|
||
// Small delay to ensure initial styles are applied before enabling transitions
|
||
setTimeout(() => document.body.classList.remove('preload'), 20);
|
||
});
|
||
window.addEventListener('hashchange', () => checkHash(true));
|
||
|
||
const cvToggleInput = document.getElementById('cv-toggle');
|
||
cvToggleInput?.addEventListener('change', (e) => {
|
||
toggleCV(e.target.checked);
|
||
|
||
// Only scroll to top if we are collapsing the CV and the user is scrolled down
|
||
if (!e.target.checked && window.scrollY > 200) {
|
||
window.scrollTo({
|
||
top: document.querySelector('.cv-container').offsetTop - 20,
|
||
behavior: 'smooth'
|
||
});
|
||
}
|
||
});
|
||
</script>
|
||
<!-- Cloudflare Web Analytics -->
|
||
<script defer src='https://static.cloudflareinsights.com/beacon.min.js' data-cf-beacon='{"token": "90827cb740cd4b1db611be059f64daa9"}'></script><!-- End Cloudflare Web Analytics -->
|
||
</body>
|
||
|
||
</html>
|