// CV Interactive Features - CSP-Compliant External JavaScript // Extracted from inline scripts for security hardening (function() { 'use strict'; // ============================================================================= // NAVIGATION & MENU SYSTEM // ============================================================================= // Minimal menu control - CSS handles most logic, JS just bridges hamburger to menu function initMenuSystem() { const hamburgerBtn = document.querySelector('.hamburger-btn'); const menu = document.getElementById('navigation-menu'); if (!hamburgerBtn || !menu) return; // Show menu on hamburger hover - CSS handles the rest hamburgerBtn.addEventListener('mouseenter', () => menu.classList.add('menu-hover')); // Hide menu when leaving hamburger if not hovering menu hamburgerBtn.addEventListener('mouseleave', () => { setTimeout(() => { if (!menu.matches(':hover')) menu.classList.remove('menu-hover'); }, 100); }); // Hide menu when leaving menu menu.addEventListener('mouseleave', () => menu.classList.remove('menu-hover')); // Position submenu dynamically (needed because fixed positioning) const submenuTrigger = document.querySelector('.menu-item-submenu'); const submenuContent = document.querySelector('.submenu-content'); if (submenuTrigger && submenuContent) { submenuTrigger.addEventListener('mouseenter', function() { submenuContent.style.top = `${this.getBoundingClientRect().top}px`; }); } } // Flag to keep header visible after navigation let keepHeaderVisible = false; // Expand/collapse all sections utility functions window.expandAllSections = function(event) { event.preventDefault(); document.querySelectorAll('details').forEach(d => d.setAttribute('open', '')); }; window.collapseAllSections = function(event) { event.preventDefault(); document.querySelectorAll('details').forEach(d => d.removeAttribute('open')); }; // Close menu when navigation links clicked - CSS handles scrolling document.addEventListener('click', (e) => { const navLink = e.target.closest('.submenu-content a[href^="#"]'); if (navLink) { document.querySelector('.navigation-menu')?.classList.remove('menu-hover', 'menu-open'); } }); // ============================================================================= // LANGUAGE & PREFERENCES // ============================================================================= // ============================================================================= // ZOOM CONTROL - Now handled by Hyperscript in zoom-control.html // ============================================================================= // All zoom functionality moved to declarative hyperscript: // - Slider updates and real-time zoom application // - Reset button (back to 100%) // - Close/show toggle with localStorage persistence // - Keyboard shortcuts (Ctrl/Cmd +/-/0) // - Draggable positioning with bounds checking // - Mobile detection and auto-disable // - LocalStorage persistence (zoom level, visibility, position) // // Result: ~343 lines of JavaScript eliminated! // ============================================================================= // 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); } // Zoom control initialization now handled by hyperscript in zoom-control.html } // ============================================================================= // 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 infoBtn = document.querySelector('.info-button'); const currentScroll = window.pageYOffset || document.documentElement.scrollTop; const isMenuOpen = navMenu.classList.contains('menu-open'); // Check if at bottom of page (within 50px threshold) const scrollHeight = document.documentElement.scrollHeight; const clientHeight = document.documentElement.clientHeight; const isAtBottom = (scrollHeight - currentScroll - clientHeight) < 50; // 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 + at-bottom positioning backToTopBtn.style.display = currentScroll > 300 ? 'flex' : 'none'; backToTopBtn?.classList.toggle('at-bottom', isAtBottom); infoBtn?.classList.toggle('at-bottom', isAtBottom); lastScrollTop = currentScroll <= 0 ? 0 : currentScroll; }, false); // Back to top - now uses native anchor link with CSS smooth scroll // No click handler needed! Native with scroll-behavior: smooth } // ============================================================================= // MODALS - Using Native Element // ============================================================================= // Native elements handle: // - Open/close with .showModal() and .close() // - Backdrop clicks (via ::backdrop CSS pseudo-element) // - Escape key to close (built-in browser behavior) // - Body scroll prevention (automatic with modal dialogs) // - Focus trapping (automatic accessibility feature) // // No JavaScript needed! All modal logic is now in HTML/CSS. // ============================================================================= // ERROR HANDLING // ============================================================================= // Error handling utility - CSS handles auto-hide animation window.showError = function(message) { const errorToast = document.getElementById('error-toast'); const errorMessage = document.getElementById('error-message'); errorMessage.textContent = message; errorToast.classList.remove('show'); // Reset if already showing // Trigger reflow to restart animation void errorToast.offsetWidth; errorToast.classList.add('show'); // CSS animation handles lifecycle }; // Close button handler for error toast - removes class to trigger hide document.addEventListener('click', (e) => { if (e.target.closest('.error-close')) { document.getElementById('error-toast')?.classList.remove('show'); } }); // ============================================================================= // 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(); initPreferences(); initScrollBehavior(); initHTMXHandlers(); }); })();