// 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(); }); })();