Files
cv-site/static/js/main.js
T

707 lines
26 KiB
JavaScript
Raw Normal View History

// CV Interactive Features - CSP-Compliant External JavaScript
// Extracted from inline scripts for security hardening
(function() {
'use strict';
// =============================================================================
// NAVIGATION & MENU SYSTEM
// =============================================================================
// Hover-based menu control
function initMenuSystem() {
const hamburgerBtn = document.querySelector('.hamburger-btn');
const menu = document.getElementById('navigation-menu');
if (!hamburgerBtn || !menu) return;
// Show menu on hamburger hover
hamburgerBtn.addEventListener('mouseenter', function() {
menu.classList.add('menu-hover');
hamburgerBtn.setAttribute('aria-expanded', 'true');
});
// Hide menu when leaving hamburger (only if not hovering menu)
hamburgerBtn.addEventListener('mouseleave', function() {
setTimeout(() => {
if (!menu.matches(':hover')) {
menu.classList.remove('menu-hover');
hamburgerBtn.setAttribute('aria-expanded', 'false');
}
}, 100);
});
// Hide menu when leaving menu itself
menu.addEventListener('mouseleave', function() {
menu.classList.remove('menu-hover');
hamburgerBtn.setAttribute('aria-expanded', 'false');
});
// Position submenu dynamically
const submenuTrigger = document.querySelector('.menu-item-submenu');
const submenuContent = document.querySelector('.submenu-content');
if (submenuTrigger && submenuContent) {
submenuTrigger.addEventListener('mouseenter', function() {
const triggerRect = submenuTrigger.getBoundingClientRect();
submenuContent.style.top = `${triggerRect.top}px`;
});
}
}
// Legacy toggle function - kept for compatibility
window.toggleMenu = function() {
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');
}
};
// Flag to keep header visible after navigation
let keepHeaderVisible = false;
// Toggle sidebar accordion (mobile only)
window.toggleSidebar = function(header) {
const content = header.nextElementSibling;
const isActive = header.classList.contains('active');
if (isActive) {
// Close
header.classList.remove('active');
content.classList.remove('active');
} else {
// Open
header.classList.add('active');
content.classList.add('active');
}
};
// Expand all sections
window.expandAllSections = function(event) {
event.preventDefault();
const allDetails = document.querySelectorAll('details');
allDetails.forEach(detail => {
detail.setAttribute('open', '');
});
};
// Collapse all sections
window.collapseAllSections = function(event) {
event.preventDefault();
const allDetails = document.querySelectorAll('details');
allDetails.forEach(detail => {
detail.removeAttribute('open');
});
};
// Toggle submenu - no longer needed for hover, but kept for compatibility
window.toggleSubmenu = function(event) {
event.preventDefault();
const submenuContainer = event.currentTarget.parentElement;
submenuContainer.classList.toggle('submenu-open');
};
// Scroll to section smoothly
window.scrollToSection = function(sectionId) {
event.preventDefault(); // Prevent default anchor behavior
const section = document.getElementById(sectionId);
if (section) {
// Ensure header is visible before scrolling
const actionBar = document.querySelector('.action-bar');
const navMenu = document.querySelector('.navigation-menu');
actionBar.classList.remove('header-hidden');
navMenu.classList.remove('header-hidden');
// Set flag to keep header visible
keepHeaderVisible = true;
// Close menu after clicking
navMenu.classList.remove('menu-open');
document.querySelector('.hamburger-btn').setAttribute('aria-expanded', 'false');
// Wait a bit for header to be visible, then calculate offset
setTimeout(() => {
const actionBarHeight = actionBar.offsetHeight;
const offset = actionBarHeight + 20; // Add 20px padding
const elementPosition = section.getBoundingClientRect().top;
const offsetPosition = elementPosition + window.pageYOffset - offset;
window.scrollTo({
top: offsetPosition,
behavior: 'smooth'
});
}, 100);
}
};
// Close menu when clicking outside (only for legacy click-opened menus)
function initClickOutsideHandler() {
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');
}
}
});
}
// =============================================================================
// LANGUAGE & PREFERENCES
// =============================================================================
// Track if URL originally had lang parameter
const urlHadLangParam = new URLSearchParams(window.location.search).has('lang');
window.selectLanguage = function(lang) {
// Save language preference to localStorage
localStorage.setItem('cv-language', lang);
// Reload page with new language parameter
const url = new URL(window.location);
url.searchParams.set('lang', lang);
window.location.href = url.toString();
};
window.toggleCVLength = function() {
const headerToggle = document.getElementById('lengthToggle');
const menuToggle = document.getElementById('lengthToggleMenu');
const paper = document.querySelector('.cv-paper');
// Get the state from whichever toggle was clicked
const isChecked = event?.target?.id === 'lengthToggleMenu' ? menuToggle?.checked : headerToggle?.checked;
// Sync both toggles
if (headerToggle) headerToggle.checked = isChecked;
if (menuToggle) menuToggle.checked = isChecked;
// Save current scroll position
const currentScrollY = window.scrollY || window.pageYOffset;
if (isChecked) {
paper.classList.add('cv-long');
paper.classList.remove('cv-short');
localStorage.setItem('cv-length', 'long');
} else {
paper.classList.add('cv-short');
paper.classList.remove('cv-long');
localStorage.setItem('cv-length', 'short');
}
// Restore scroll position after DOM updates
requestAnimationFrame(() => {
window.scrollTo(0, currentScrollY);
});
};
window.toggleLogos = function() {
const headerToggle = document.getElementById('logoToggle');
const menuToggle = document.getElementById('logoToggleMenu');
const paper = document.querySelector('.cv-paper');
// Get the state from whichever toggle was clicked
const isChecked = event?.target?.id === 'logoToggleMenu' ? menuToggle?.checked : headerToggle?.checked;
// Sync both toggles
if (headerToggle) headerToggle.checked = isChecked;
if (menuToggle) menuToggle.checked = isChecked;
// Save current scroll position
const currentScrollY = window.scrollY || window.pageYOffset;
if (isChecked) {
paper.classList.add('show-logos');
localStorage.setItem('cv-logos', 'show');
} else {
paper.classList.remove('show-logos');
localStorage.setItem('cv-logos', 'hide');
}
// Restore scroll position after DOM updates
requestAnimationFrame(() => {
window.scrollTo(0, currentScrollY);
});
};
window.toggleTheme = function() {
const headerToggle = document.getElementById('themeToggle');
const menuToggle = document.getElementById('themeToggleMenu');
const container = document.querySelector('.cv-container');
// Get the state from whichever toggle was clicked
const isChecked = event?.target?.id === 'themeToggleMenu' ? menuToggle?.checked : headerToggle?.checked;
// Sync both toggles
if (headerToggle) headerToggle.checked = isChecked;
if (menuToggle) menuToggle.checked = isChecked;
if (isChecked) {
container.classList.add('theme-clean');
localStorage.setItem('cv-theme', 'clean');
} else {
container.classList.remove('theme-clean');
localStorage.setItem('cv-theme', 'default');
}
};
// =============================================================================
// ZOOM CONTROL
// =============================================================================
/**
* Initialize zoom control on page load
* Restores saved zoom level from localStorage
*/
function initZoomControl() {
const slider = document.getElementById('zoom-slider');
const resetBtn = document.getElementById('zoom-reset');
const cvPaper = document.querySelector('.cv-paper');
if (!slider || !cvPaper) return;
// Restore saved zoom level
const savedZoom = localStorage.getItem('cv-zoom');
if (savedZoom) {
const zoomValue = parseInt(savedZoom, 10);
slider.value = zoomValue;
applyZoom(zoomValue, false); // false = don't save (already loaded from storage)
}
// Real-time slider updates - immediate, smooth analog experience
slider.addEventListener('input', function(e) {
const zoomValue = parseInt(e.target.value, 10);
// Apply zoom and update display immediately for smooth analog feel
updateZoomDisplay(zoomValue);
applyZoom(zoomValue, true);
});
// Reset button
if (resetBtn) {
resetBtn.addEventListener('click', function() {
slider.value = 100;
applyZoom(100, true);
slider.focus(); // Return focus to slider for accessibility
});
}
// Keyboard shortcuts (Ctrl/Cmd + Plus/Minus/0)
document.addEventListener('keydown', function(e) {
if ((e.ctrlKey || e.metaKey) && !e.shiftKey) {
if (e.key === '=' || e.key === '+') {
e.preventDefault();
incrementZoom(10);
} else if (e.key === '-') {
e.preventDefault();
incrementZoom(-10);
} else if (e.key === '0') {
e.preventDefault();
slider.value = 100;
applyZoom(100, true);
}
}
});
}
/**
* Apply zoom transformation to CV paper
* @param {number} zoomValue - Zoom percentage (50-200)
* @param {boolean} saveToStorage - Whether to persist to localStorage
*/
function applyZoom(zoomValue, saveToStorage = true) {
const cvPaper = document.querySelector('.cv-paper');
if (!cvPaper) return;
// Convert percentage to scale factor (100 = 1.0, 150 = 1.5, etc.)
const scaleFactor = zoomValue / 100;
// Preserve scroll position (matching existing toggle pattern)
requestAnimationFrame(() => {
const scrollTop = window.pageYOffset || document.documentElement.scrollTop;
// Apply transform
cvPaper.style.transform = `scale(${scaleFactor})`;
// Restore scroll position
window.scrollTo(0, scrollTop);
// Update display
updateZoomDisplay(zoomValue);
// Save to localStorage
if (saveToStorage) {
localStorage.setItem('cv-zoom', zoomValue.toString());
}
});
}
/**
* Update visual display and ARIA attributes
* @param {number} zoomValue - Current zoom percentage
*/
function updateZoomDisplay(zoomValue) {
const slider = document.getElementById('zoom-slider');
const display = document.getElementById('zoom-value-current');
if (display) {
display.textContent = zoomValue;
}
if (slider) {
slider.setAttribute('aria-valuenow', zoomValue);
slider.setAttribute('aria-valuetext', `${zoomValue}%`);
}
}
/**
* Increment/decrement zoom by step amount
* @param {number} step - Amount to change (positive or negative)
*/
function incrementZoom(step) {
const slider = document.getElementById('zoom-slider');
if (!slider) return;
const currentZoom = parseInt(slider.value, 10);
const newZoom = Math.min(200, Math.max(50, currentZoom + step));
slider.value = newZoom;
applyZoom(newZoom, true);
}
// =============================================================================
// PRINT & PDF
// =============================================================================
// Print Friendly - Apply Clean Theme + Short Version for minimal printing
window.printFriendly = function() {
const container = document.querySelector('.cv-container');
const paper = document.querySelector('.cv-paper');
const wasClean = container.classList.contains('theme-clean');
const wasLong = paper.classList.contains('cv-long');
// Store current zoom
const currentZoom = localStorage.getItem('cv-zoom') || '100';
// Apply clean theme for minimal print (no sidebars, no header, no icons)
if (!wasClean) {
container.classList.add('theme-clean');
}
// Force SHORT version for print (hide detailed content)
paper.classList.remove('cv-long');
paper.classList.add('cv-short');
// Temporarily reset zoom for printing
if (paper) {
paper.style.transform = 'scale(1)';
}
// Small delay to let CSS apply
setTimeout(() => {
window.print();
// Restore original theme and length after print dialog closes
setTimeout(() => {
if (!wasClean) {
container.classList.remove('theme-clean');
}
// Restore original length
if (wasLong) {
paper.classList.remove('cv-short');
paper.classList.add('cv-long');
}
// Restore zoom
if (paper && currentZoom !== '100') {
applyZoom(parseInt(currentZoom, 10), false);
}
}, 100);
}, 50);
};
// =============================================================================
// INITIALIZATION & PREFERENCES
// =============================================================================
function initPreferences() {
const paper = document.querySelector('.cv-paper');
// Handle language preference
const urlLang = new URLSearchParams(window.location.search).get('lang');
const savedLang = localStorage.getItem('cv-language');
if (!urlLang && savedLang) {
// URL is clean but we have a saved preference - redirect with lang parameter
const url = new URL(window.location);
url.searchParams.set('lang', savedLang);
window.location.replace(url.toString());
} else if (urlLang) {
// Save URL language to localStorage
localStorage.setItem('cv-language', urlLang);
}
// Restore CV length preference
const savedLength = localStorage.getItem('cv-length') || 'short';
const lengthChecked = savedLength === 'long';
if (lengthChecked) {
paper.classList.add('cv-long');
paper.classList.remove('cv-short');
} else {
paper.classList.add('cv-short');
paper.classList.remove('cv-long');
}
// Sync both header and menu toggles
const headerLengthToggle = document.getElementById('lengthToggle');
const menuLengthToggle = document.getElementById('lengthToggleMenu');
if (headerLengthToggle) headerLengthToggle.checked = lengthChecked;
if (menuLengthToggle) menuLengthToggle.checked = lengthChecked;
// Restore logos preference
const savedLogos = localStorage.getItem('cv-logos') || 'show';
const logosChecked = savedLogos === 'show';
if (logosChecked) {
paper.classList.add('show-logos');
} else {
paper.classList.remove('show-logos');
}
// Sync both header and menu toggles
const headerLogoToggle = document.getElementById('logoToggle');
const menuLogoToggle = document.getElementById('logoToggleMenu');
if (headerLogoToggle) headerLogoToggle.checked = logosChecked;
if (menuLogoToggle) menuLogoToggle.checked = logosChecked;
// Restore theme preference
const savedTheme = localStorage.getItem('cv-theme') || 'default';
const themeChecked = savedTheme === 'clean';
// Sync both header and menu toggles
const headerThemeToggle = document.getElementById('themeToggle');
const menuThemeToggle = document.getElementById('themeToggleMenu');
if (headerThemeToggle) headerThemeToggle.checked = themeChecked;
if (menuThemeToggle) menuThemeToggle.checked = themeChecked;
if (themeChecked) {
window.toggleTheme();
}
// Initialize zoom control
initZoomControl();
}
// =============================================================================
// SCROLL BEHAVIOR
// =============================================================================
function initScrollBehavior() {
let lastScrollTop = 0;
let scrollThreshold = 100; // Start hiding after 100px scroll
window.addEventListener('scroll', function() {
const actionBar = document.querySelector('.action-bar');
const navMenu = document.querySelector('.navigation-menu');
const backToTopBtn = document.getElementById('back-to-top');
const currentScroll = window.pageYOffset || document.documentElement.scrollTop;
const isMenuOpen = navMenu.classList.contains('menu-open');
// If scrolling up, reset the keepHeaderVisible flag
if (currentScroll < lastScrollTop) {
keepHeaderVisible = false;
}
// Hide/show header based on scroll direction
if (currentScroll > scrollThreshold) {
if (currentScroll > lastScrollTop && !keepHeaderVisible) {
// Scrolling down - hide header (only if keepHeaderVisible is false)
actionBar.classList.add('header-hidden');
// Only hide menu if it's open
if (isMenuOpen) {
navMenu.classList.add('header-hidden');
}
} else {
// Scrolling up - show header
actionBar.classList.remove('header-hidden');
// Only show menu if it's open
if (isMenuOpen) {
navMenu.classList.remove('header-hidden');
}
}
} else {
// At top - always show header
actionBar.classList.remove('header-hidden');
// Only affect menu if it's open
if (isMenuOpen) {
navMenu.classList.remove('header-hidden');
}
}
// Show/hide back to top button
if (currentScroll > 300) {
backToTopBtn.style.display = 'flex';
} else {
backToTopBtn.style.display = 'none';
}
lastScrollTop = currentScroll <= 0 ? 0 : currentScroll;
}, false);
// Back to top button click handler
const backToTopBtn = document.getElementById('back-to-top');
if (backToTopBtn) {
backToTopBtn.addEventListener('click', function() {
window.scrollTo({
top: 0,
behavior: 'smooth'
});
});
}
}
// =============================================================================
// MODALS
// =============================================================================
// Info Modal Functions
window.openInfoModal = function() {
const modal = document.getElementById('info-modal');
modal.classList.add('active');
document.body.style.overflow = 'hidden'; // Prevent scrolling when modal is open
};
window.closeInfoModal = function() {
const modal = document.getElementById('info-modal');
modal.classList.remove('active');
document.body.style.overflow = ''; // Restore scrolling
};
window.closeInfoModalOnBackdrop = function(event) {
if (event.target.id === 'info-modal') {
window.closeInfoModal();
}
};
// PDF Modal Functions
window.openPdfModal = function() {
const modal = document.getElementById('pdf-modal');
modal.classList.add('active');
document.body.style.overflow = 'hidden'; // Prevent scrolling when modal is open
};
window.closePdfModal = function() {
const modal = document.getElementById('pdf-modal');
modal.classList.remove('active');
document.body.style.overflow = ''; // Restore scrolling
};
window.closePdfModalOnBackdrop = function(event) {
if (event.target.id === 'pdf-modal') {
window.closePdfModal();
}
};
// Close modals with Escape key
function initModalKeyHandlers() {
document.addEventListener('keydown', function(event) {
if (event.key === 'Escape') {
window.closeInfoModal();
window.closePdfModal();
}
});
}
// =============================================================================
// ERROR HANDLING
// =============================================================================
// Error handling utility
window.showError = function(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 EVENT HANDLERS
// =============================================================================
function initHTMXHandlers() {
// 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.';
window.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.';
window.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.';
window.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' });
}
// Track HTMX navigation events with Matomo
if (typeof _paq !== 'undefined' && evt.detail.target.id === 'cv-content') {
// Track language change as virtual pageview
const lang = new URLSearchParams(window.location.search).get('lang') || 'en';
_paq.push(['setCustomUrl', window.location.href]);
_paq.push(['setDocumentTitle', document.title]);
_paq.push(['trackPageView']);
}
});
// 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);
}
});
}
// =============================================================================
// INITIALIZATION
// =============================================================================
// Initialize everything when DOM is ready
document.addEventListener('DOMContentLoaded', function() {
initMenuSystem();
initClickOutsideHandler();
initPreferences();
initScrollBehavior();
initModalKeyHandlers();
initHTMXHandlers();
});
})();