Files
cv-site/templates/index.html
T
juanatsap 59b95c4448 feat: add hamburger navigation menu with smooth scrolling to CV sections
Implemented a complete navigation system with the following features:

**Navigation Menu:**
- Hamburger button in top-left of action bar
- Slide-out navigation menu with all CV sections
- Smooth close on click outside or after selection
- Mobile-responsive design (280px desktop, 240px mobile)

**Section Anchors:**
- Added ID anchors to all CV sections:
  - #education (Training/Formación)
  - #skills (Skills/Competencias)
  - #experience (Experience/Experiencia)
  - #awards (Awards/Premios y Reconocimientos)
  - #courses (Courses/Cursos Realizados)
  - #languages (Languages/Idiomas)
  - #references (References/Referencias)
  - #other (Other/Otros)

**Section Icons:**
- Added descriptive icons to all section titles
- Icons match their purpose (school for education/courses, trophy for awards, etc.)
- Consistent 24x24 size for section titles, 20x20 for menu items

**Smooth Scrolling:**
- Implemented smooth scroll behavior with proper offset calculation
- Accounts for fixed header height
- Added scroll-padding-top for better anchor positioning

**Accessibility:**
- Proper ARIA labels and roles
- aria-expanded attribute for hamburger button
- Keyboard navigation support
- Screen reader friendly

**Styling:**
- Clean white menu background with shadow
- Blue hover states matching CV accent color
- Left border indicator on hover
- Smooth transitions (0.3s ease-in-out)
- Print-friendly (menu hidden in print mode)

**Bilingual Support:**
- Menu items automatically translate based on language
- Works seamlessly with English/Spanish switching
2025-11-07 19:11:21 +00:00

454 lines
20 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!DOCTYPE html>
<html lang="{{if eq .Lang "es"}}es{{else}}en{{end}}">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<!-- Primary Meta Tags -->
<title>{{.CV.Personal.Name}} - {{if eq .Lang "es"}}Curriculum Vitae{{else}}Curriculum Vitae{{end}}</title>
<meta name="title" content="{{.CV.Personal.Name}} - {{if eq .Lang "es"}}CV Profesional{{else}}Professional CV{{end}}">
<meta name="description" content="{{.CV.Personal.Title}} | {{if eq .Lang "es"}}18 años de experiencia en desarrollo web, SAP CDC, React, Node.js, Go, HTMX y desarrollo asistido por IA{{else}}18 years of experience in web development, SAP CDC, React, Node.js, Go, HTMX and AI-assisted development{{end}}">
<meta name="keywords" content="{{if eq .Lang "es"}}CV, Curriculum Vitae, {{.CV.Personal.Name}}, Desarrollador FullStack, SAP CDC, React, Node.js, Go, HTMX, IA, Desarrollo Web, Consultor Técnico{{else}}CV, Resume, {{.CV.Personal.Name}}, FullStack Developer, SAP CDC, React, Node.js, Go, HTMX, AI, Web Development, Technical Consultant{{end}}">
<meta name="author" content="{{.CV.Personal.Name}}">
<meta name="robots" content="index, follow">
<link rel="canonical" href="{{.CV.Personal.Website}}">
<!-- Open Graph / Facebook -->
<meta property="og:type" content="profile">
<meta property="og:url" content="{{.CV.Personal.Website}}">
<meta property="og:title" content="{{.CV.Personal.Name}} - {{if eq .Lang "es"}}CV Profesional{{else}}Professional CV{{end}}">
<meta property="og:description" content="{{.CV.Personal.Title}} | {{if eq .Lang "es"}}Consultor Técnico Senior con 18 años de experiencia{{else}}Senior Technical Consultant with 18 years of experience{{end}}">
<meta property="og:image" content="{{.CV.Personal.Website}}/static/images/profile.jpg">
<meta property="og:locale" content="{{if eq .Lang "es"}}es_ES{{else}}en_US{{end}}">
<meta property="og:site_name" content="{{.CV.Personal.Name}}">
<meta property="profile:first_name" content="Juan Andrés">
<meta property="profile:last_name" content="Moreno Rubio">
<meta property="profile:username" content="txeo">
<!-- Social Media Card (Generic) -->
<meta name="twitter:card" content="summary">
<meta name="twitter:title" content="{{.CV.Personal.Name}} - {{if eq .Lang "es"}}CV Profesional{{else}}Professional CV{{end}}">
<meta name="twitter:description" content="{{.CV.Personal.Title}}">
<meta name="twitter:image" content="{{.CV.Personal.Website}}/static/images/profile.jpg">
<!-- HTMX Configuration -->
<meta name="htmx-config" content='{"timeout":5000,"defaultSwapStyle":"innerHTML","defaultSwapDelay":0,"defaultSettleDelay":20}'>
<!-- HTMX with SRI (Subresource Integrity) -->
<script src="https://unpkg.com/htmx.org@1.9.10"
integrity="sha384-D1Kt99CQMDuVetoL1lrYwg5t+9QdHe7NLX/SoJYkXDFfX37iInKRy5xLSi8nO7UC"
crossorigin="anonymous"></script>
<!-- Iconify - Load synchronously for immediate rendering -->
<script src="https://code.iconify.design/iconify-icon/1.0.7/iconify-icon.min.js"></script>
<!-- CSS -->
<link rel="stylesheet" href="/static/css/main.css">
<link rel="stylesheet" href="/static/css/logo-toggle.css">
<link rel="stylesheet" href="/static/css/print.css" media="print">
<!-- Fonts with Preload -->
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link rel="dns-prefetch" href="https://fonts.googleapis.com">
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap" rel="stylesheet">
<!-- Structured Data (JSON-LD) -->
<script type="application/ld+json">
{
"@context": "https://schema.org",
"@type": "Person",
"name": "{{.CV.Personal.Name}}",
"jobTitle": "{{.CV.Personal.Title}}",
"url": "{{.CV.Personal.Website}}",
"email": "{{.CV.Personal.Email}}",
"telephone": "{{.CV.Personal.Phone}}",
"address": {
"@type": "PostalAddress",
"addressLocality": "{{.CV.Personal.Location}}"
},
"sameAs": [
"{{.CV.Personal.LinkedIn}}",
"{{.CV.Personal.GitHub}}",
"{{.CV.Personal.Behance}}"
],
"alumniOf": {
"@type": "EducationalOrganization",
"name": "Universidad de Extremadura"
},
"knowsAbout": [
"Web Development",
"SAP Customer Data Cloud",
"React",
"Node.js",
"Go",
"HTMX",
"AI-Assisted Development",
"Full Stack Development"
],
"worksFor": {
"@type": "Organization",
"name": "Olympic Broadcasting Services"
}
}
</script>
</head>
<body>
<!-- Single Black Bar with Everything -->
<div class="action-bar no-print" role="navigation" aria-label="Language and export controls">
<div class="action-bar-content">
<!-- Left: Hamburger Menu + Site Title and Language -->
<div class="site-title">
<!-- Hamburger Menu Button -->
<button class="hamburger-btn" onclick="toggleMenu()" aria-label="Toggle navigation menu">
<iconify-icon icon="mdi:menu" width="24" height="24"></iconify-icon>
</button>
<iconify-icon icon="mdi:file-account" width="24" height="24" class="site-icon"></iconify-icon>
<span class="site-title-text">CV JAMR - {{.CurrentYear}}</span>
<!-- Language selector (after title) -->
<div class="language-selector">
<button class="selector-btn {{if eq .Lang "en"}}active{{end}}" onclick="selectLanguage('en')" aria-label="English">
English
</button>
<button class="selector-btn {{if eq .Lang "es"}}active{{end}}" onclick="selectLanguage('es')" aria-label="Español">
Español
</button>
</div>
</div>
<!-- Center: View controls with labels -->
<div class="view-controls-center">
<!-- CV Length toggle -->
<div class="selector-group">
<label class="selector-label">{{if eq .Lang "es"}}Longitud{{else}}Length{{end}}:</label>
<label class="icon-toggle">
<input type="checkbox" id="lengthToggle" onchange="toggleCVLength()">
<span class="icon-toggle-slider">
<iconify-icon icon="mdi:file-document-outline" width="16" height="16" class="icon-left"></iconify-icon>
<iconify-icon icon="mdi:file-document-multiple-outline" width="16" height="16" class="icon-right"></iconify-icon>
</span>
</label>
</div>
<!-- Logo toggle -->
<div class="selector-group">
<label class="selector-label">{{if eq .Lang "es"}}Logos{{else}}Logos{{end}}:</label>
<label class="icon-toggle">
<input type="checkbox" id="logoToggle" checked onchange="toggleLogos()">
<span class="icon-toggle-slider">
<iconify-icon icon="mdi:image-off-outline" width="16" height="16" class="icon-left"></iconify-icon>
<iconify-icon icon="mdi:image-multiple-outline" width="16" height="16" class="icon-right"></iconify-icon>
</span>
</label>
</div>
<!-- Theme toggle -->
<div class="selector-group">
<label class="selector-label">{{if eq .Lang "es"}}Vista{{else}}View{{end}}:</label>
<label class="icon-toggle">
<input type="checkbox" id="themeToggle" onchange="toggleTheme()">
<span class="icon-toggle-slider">
<iconify-icon icon="mdi:page-layout-sidebar-left" width="16" height="16" class="icon-left"></iconify-icon>
<iconify-icon icon="mdi:page-layout-body" width="16" height="16" class="icon-right"></iconify-icon>
</span>
</label>
</div>
</div>
<!-- Right: Action buttons -->
<div class="action-buttons-right">
<a
class="action-btn pdf-btn"
href="/export/pdf?lang={{.Lang}}"
download
aria-label="{{if eq .Lang "es"}}Descargar como PDF{{else}}Download as PDF{{end}}">
<iconify-icon icon="mdi:download" width="18" height="18"></iconify-icon>
{{if eq .Lang "es"}}Descargar como PDF{{else}}Download as PDF{{end}}
</a>
<button
class="action-btn print-btn"
onclick="printFriendly()"
aria-label="{{if eq .Lang "es"}}Imprimir amigable{{else}}Print Friendly{{end}}">
<iconify-icon icon="mdi:leaf" width="18" height="18"></iconify-icon>
{{if eq .Lang "es"}}Imprimir amigable{{else}}Print Friendly{{end}}
</button>
</div>
<span id="loading"
class="htmx-indicator"
role="status"
aria-live="polite"
aria-label="Loading">
<span class="loader"></span>
</span>
</div>
</div>
<!-- Navigation Menu (Hidden by default) -->
<nav id="navigation-menu" class="navigation-menu no-print" role="navigation" aria-label="CV sections">
<div class="menu-content">
<a href="#education" class="menu-item" onclick="scrollToSection('education')">
<iconify-icon icon="mdi:school" width="20" height="20"></iconify-icon>
<span>{{if eq .Lang "es"}}Formación{{else}}Training{{end}}</span>
</a>
<a href="#skills" class="menu-item" onclick="scrollToSection('skills')">
<iconify-icon icon="mdi:brain" width="20" height="20"></iconify-icon>
<span>{{if eq .Lang "es"}}Competencias{{else}}Skills{{end}}</span>
</a>
<a href="#experience" class="menu-item" onclick="scrollToSection('experience')">
<iconify-icon icon="mdi:office-building" width="20" height="20"></iconify-icon>
<span>{{if eq .Lang "es"}}Experiencia{{else}}Experience{{end}}</span>
</a>
<a href="#awards" class="menu-item" onclick="scrollToSection('awards')">
<iconify-icon icon="mdi:trophy" width="20" height="20"></iconify-icon>
<span>{{if eq .Lang "es"}}Premios y Reconocimientos{{else}}Awards{{end}}</span>
</a>
<a href="#courses" class="menu-item" onclick="scrollToSection('courses')">
<iconify-icon icon="mdi:school" width="20" height="20"></iconify-icon>
<span>{{if eq .Lang "es"}}Cursos Realizados{{else}}Courses{{end}}</span>
</a>
<a href="#languages" class="menu-item" onclick="scrollToSection('languages')">
<iconify-icon icon="mdi:translate" width="20" height="20"></iconify-icon>
<span>{{if eq .Lang "es"}}Idiomas{{else}}Languages{{end}}</span>
</a>
<a href="#references" class="menu-item" onclick="scrollToSection('references')">
<iconify-icon icon="mdi:link-variant" width="20" height="20"></iconify-icon>
<span>{{if eq .Lang "es"}}Referencias{{else}}References{{end}}</span>
</a>
<a href="#other" class="menu-item" onclick="scrollToSection('other')">
<iconify-icon icon="mdi:information" width="20" height="20"></iconify-icon>
<span>{{if eq .Lang "es"}}Otros{{else}}Other{{end}}</span>
</a>
</div>
</nav>
<!-- CV Content Container -->
<div class="cv-container">
<main id="cv-content"
class="cv-paper"
role="main"
aria-live="polite">
{{template "cv-content.html" .}}
</main>
</div>
<!-- Footer (hidden in print) -->
<footer class="no-print">
<p>© {{.CV.Meta.LastUpdated}} {{.CV.Personal.Name}} |
{{if eq .Lang "es"}}Última actualización{{else}}Last updated{{end}}: {{.CV.Meta.LastUpdated}}</p>
</footer>
<!-- Error Toast -->
<div id="error-toast" class="error-toast no-print" role="alert" aria-live="assertive" style="display: none;">
<span class="error-icon">⚠️</span>
<span id="error-message"></span>
<button onclick="this.parentElement.style.display='none'" aria-label="Close error message" class="error-close">×</button>
</div>
<script>
// Toggle navigation menu
function toggleMenu() {
const menu = document.getElementById('navigation-menu');
const btn = document.querySelector('.hamburger-btn');
if (menu.classList.contains('menu-open')) {
menu.classList.remove('menu-open');
btn.setAttribute('aria-expanded', 'false');
} else {
menu.classList.add('menu-open');
btn.setAttribute('aria-expanded', 'true');
}
}
// Scroll to section smoothly
function scrollToSection(sectionId) {
event.preventDefault(); // Prevent default anchor behavior
const section = document.getElementById(sectionId);
if (section) {
const actionBarHeight = document.querySelector('.action-bar').offsetHeight;
const menuHeight = document.querySelector('.navigation-menu').offsetHeight;
const offset = actionBarHeight + (menuHeight || 0) + 20; // Add 20px padding
const elementPosition = section.getBoundingClientRect().top;
const offsetPosition = elementPosition + window.pageYOffset - offset;
window.scrollTo({
top: offsetPosition,
behavior: 'smooth'
});
// Close menu after clicking
const menu = document.getElementById('navigation-menu');
menu.classList.remove('menu-open');
document.querySelector('.hamburger-btn').setAttribute('aria-expanded', 'false');
}
}
// Close menu when clicking outside
document.addEventListener('click', function(event) {
const menu = document.getElementById('navigation-menu');
const btn = document.querySelector('.hamburger-btn');
if (menu && btn && menu.classList.contains('menu-open')) {
if (!menu.contains(event.target) && !btn.contains(event.target)) {
menu.classList.remove('menu-open');
btn.setAttribute('aria-expanded', 'false');
}
}
});
function selectLanguage(lang) {
// Update button states
document.querySelectorAll('.language-selector .selector-btn').forEach(btn => {
btn.classList.remove('active');
});
event.target.closest('.selector-btn').classList.add('active');
// Use HTMX to load new content
htmx.ajax('GET', `/cv?lang=${lang}`, {
target: '#cv-content',
swap: 'innerHTML swap:200ms settle:200ms',
indicator: '#loading'
});
// Update URL
const url = new URL(window.location);
url.searchParams.set('lang', lang);
window.history.pushState({}, '', url);
// Update html lang attribute
document.documentElement.lang = lang;
}
function toggleCVLength() {
const toggle = document.getElementById('lengthToggle');
const paper = document.querySelector('.cv-paper');
if (toggle.checked) {
paper.classList.add('cv-long');
paper.classList.remove('cv-short');
} else {
paper.classList.add('cv-short');
paper.classList.remove('cv-long');
}
}
function toggleLogos() {
const toggle = document.getElementById('logoToggle');
const paper = document.querySelector('.cv-paper');
if (toggle.checked) {
paper.classList.add('show-logos');
} else {
paper.classList.remove('show-logos');
}
}
function toggleTheme() {
const toggle = document.getElementById('themeToggle');
const container = document.querySelector('.cv-container');
if (toggle.checked) {
container.classList.add('theme-clean');
localStorage.setItem('cv-theme', 'clean');
} else {
container.classList.remove('theme-clean');
localStorage.setItem('cv-theme', 'default');
}
}
// Print Friendly - applies Clean theme before printing
function printFriendly() {
const container = document.querySelector('.cv-container');
const wasClean = container.classList.contains('theme-clean');
// Apply clean theme for printing
if (!wasClean) {
container.classList.add('theme-clean');
}
// Print
window.print();
// Restore original theme after print dialog
setTimeout(() => {
if (!wasClean) {
container.classList.remove('theme-clean');
}
}, 100);
}
// Initialize with short version, logos enabled, and saved theme
document.addEventListener('DOMContentLoaded', function() {
document.querySelector('.cv-paper').classList.add('cv-short');
document.querySelector('.cv-paper').classList.add('show-logos');
// Restore theme preference
const savedTheme = localStorage.getItem('cv-theme') || 'default';
if (savedTheme === 'clean') {
document.getElementById('themeToggle').checked = true;
toggleTheme();
}
});
// Error handling utility
function showError(message) {
const errorToast = document.getElementById('error-toast');
const errorMessage = document.getElementById('error-message');
errorMessage.textContent = message;
errorToast.style.display = 'flex';
// Auto-hide after 5 seconds
setTimeout(() => {
errorToast.style.display = 'none';
}, 5000);
}
// HTMX Global Error Handlers
document.body.addEventListener('htmx:responseError', function(evt) {
console.error('HTMX Response Error:', evt.detail);
const lang = document.documentElement.lang;
const message = lang === 'es'
? 'Error al cargar el contenido. Por favor, inténtelo de nuevo.'
: 'Failed to load content. Please try again.';
showError(message);
});
document.body.addEventListener('htmx:sendError', function(evt) {
console.error('HTMX Send Error:', evt.detail);
const lang = document.documentElement.lang;
const message = lang === 'es'
? 'Error de conexión. Verifique su conexión a internet.'
: 'Connection error. Please check your internet connection.';
showError(message);
});
document.body.addEventListener('htmx:timeout', function(evt) {
console.error('HTMX Timeout:', evt.detail);
const lang = document.documentElement.lang;
const message = lang === 'es'
? 'La solicitud tardó demasiado. Por favor, inténtelo de nuevo.'
: 'Request timed out. Please try again.';
showError(message);
});
document.body.addEventListener('htmx:afterSwap', function(evt) {
// Smooth scroll to top on language change
if (evt.detail.target.id === 'cv-content') {
window.scrollTo({ top: 0, behavior: 'smooth' });
}
});
// Log successful swaps for debugging
document.body.addEventListener('htmx:afterRequest', function(evt) {
if (evt.detail.successful) {
console.log('HTMX request successful:', evt.detail.pathInfo.requestPath);
}
});
</script>
</body>
</html>