Add a helper function to calculate the number of years for each experience entry based on the date range. Display the calculated duration next to the date in the experience section.
365 lines
15 KiB
PHP
365 lines
15 KiB
PHP
<?php
|
||
|
||
/**
|
||
* Content config:
|
||
*/
|
||
$cvData = json_decode(file_get_contents(__DIR__ . '/cv.json'), true);
|
||
|
||
$name = $cvData['name'] ?? '';
|
||
$site_title = $cvData['subtitle'] ?? '';
|
||
$site_url = "https://www.dejvino.cz/";
|
||
$site_desc = "$name - " . ($cvData['subtitle'] ?? '');
|
||
$subtitle = $cvData['subtitle'] ?? '';
|
||
$photo_file = $cvData['photo'] ?? '';
|
||
|
||
$mailto_link = $cvData['contact_links']['Email'] ?? '';
|
||
if (preg_match('/mailto:([^@]+)/', $mailto_link, $m)) {
|
||
$email_user = $m[1];
|
||
} else {
|
||
$email_user = $cvData['email'] ?? '';
|
||
}
|
||
|
||
$about_text = $cvData['about'] ?? [];
|
||
|
||
$nav_links = [
|
||
"~/blog" => "https://wp.dejvino.com/",
|
||
"~/projects" => "https://projects.dejvino.com/",
|
||
"~/code" => "https://git.dejvino.cz/",
|
||
];
|
||
|
||
$hardware_info = $cvData['hardware_info'] ?? '';
|
||
|
||
$extra_contact_links = $cvData['contact_links'] ?? [];
|
||
|
||
/**
|
||
* 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'
|
||
$matches = [];
|
||
$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-maintaninable
|
||
* - 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="<?= $subtitle ?>">
|
||
|
||
<style>
|
||
<?php
|
||
include 'style.css';
|
||
?>
|
||
</style>
|
||
<link rel="shortcut icon" href="favicon.ico">
|
||
</head>
|
||
|
||
<body class="preload">
|
||
|
||
<main class="bento-grid">
|
||
<section class="card profile">
|
||
<?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="120" height="160" />';
|
||
} else {
|
||
echo '<div class="avatar-placeholder"></div>';
|
||
}
|
||
?>
|
||
<header class="header-text">
|
||
<h1><?= $name ?></h1>
|
||
<p class="subtitle"><?= $subtitle ?></p>
|
||
</header>
|
||
</section>
|
||
|
||
<section class="card about">
|
||
<h2>Bio</h2>
|
||
<?php foreach ($about_text as $para): ?>
|
||
<p><?= $para ?></p>
|
||
<?php endforeach; ?>
|
||
</section>
|
||
|
||
<section class="card interests">
|
||
<h2>Likes</h2>
|
||
<p><?= $hardware_info ?></p>
|
||
</section>
|
||
|
||
<section class="card contact">
|
||
<h2>Remote_Access</h2>
|
||
<address class="mono-addr">
|
||
Email: <span id="contact-mail">uncovering...</span>
|
||
</address>
|
||
<address class="mono-addr">
|
||
Socials:
|
||
<?php foreach ($extra_contact_links as $label => $url): ?>
|
||
<br><a href="<?= $url ?>" target="_blank"><?= $label ?></a>
|
||
<?php endforeach; ?>
|
||
</address>
|
||
</section>
|
||
|
||
<section class="card links">
|
||
<h2>Navigation</h2>
|
||
<ul class="link-grid">
|
||
<?php foreach ($nav_links as $label => $url): ?>
|
||
<li><a href="<?= trim($url) ?>" target="_blank"><span><?= $label ?></span></a></li>
|
||
<?php endforeach; ?>
|
||
</ul>
|
||
</section>
|
||
|
||
<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; ?>
|
||
<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>
|
||
|
||
</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 ? 'SYSTEM ACCESS: CV_DATA' : '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>
|