// CV Interactive Features - CSP-Compliant External JavaScript // Extracted from inline scripts for security hardening (function() { 'use strict'; // ============================================================================= // GLOBAL VARIABLES // ============================================================================= // Flag to keep header visible after navigation let keepHeaderVisible = false; // ============================================================================= // NAVIGATION & MENU SYSTEM // ============================================================================= /** * Initialize minimal menu control system * 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`; }); } } /** * Expand all
sections in the CV * @param {Event} event - Click event */ window.expandAllSections = function(event) { event.preventDefault(); document.querySelectorAll('details').forEach(d => d.setAttribute('open', '')); }; /** * Collapse all
sections in the CV * @param {Event} event - Click event */ window.collapseAllSections = function(event) { event.preventDefault(); document.querySelectorAll('details').forEach(d => d.removeAttribute('open')); }; /** * Close menu when navigation links are clicked * CSS handles scrolling with scroll-behavior: smooth */ function initMenuCloseOnClick() { 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'); } }); } // ============================================================================= // PREFERENCES & LANGUAGE // ============================================================================= /** * Initialize user preferences from localStorage * Handles language, theme, length, and icons persistence across sessions */ function initPreferences() { // 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); } // Apply other preferences from localStorage on page load // This ensures client-side preferences override server defaults const savedTheme = localStorage.getItem('cv-theme'); const savedLength = localStorage.getItem('cv-length'); const savedIcons = localStorage.getItem('cv-icons'); // Apply theme preference if (savedTheme === 'clean') { document.body.classList.add('theme-clean'); const themeToggles = document.querySelectorAll('#themeToggle, #themeToggleMenu'); themeToggles.forEach(toggle => toggle.checked = true); } else if (savedTheme === 'default') { document.body.classList.remove('theme-clean'); const themeToggles = document.querySelectorAll('#themeToggle, #themeToggleMenu'); themeToggles.forEach(toggle => toggle.checked = false); } // Apply length preference const cvPaper = document.querySelector('.cv-paper'); if (cvPaper && savedLength) { if (savedLength === 'long') { cvPaper.classList.remove('cv-short'); cvPaper.classList.add('cv-long'); const lengthToggles = document.querySelectorAll('#lengthToggle, #lengthToggleMenu'); lengthToggles.forEach(toggle => toggle.checked = true); } else { cvPaper.classList.remove('cv-long'); cvPaper.classList.add('cv-short'); const lengthToggles = document.querySelectorAll('#lengthToggle, #lengthToggleMenu'); lengthToggles.forEach(toggle => toggle.checked = false); } } // Apply icons preference if (cvPaper && savedIcons !== null) { if (savedIcons === 'true') { cvPaper.classList.add('show-icons'); const iconToggles = document.querySelectorAll('#iconToggle, #iconToggleMenu'); iconToggles.forEach(toggle => toggle.checked = true); } else { cvPaper.classList.remove('show-icons'); const iconToggles = document.querySelectorAll('#iconToggle, #iconToggleMenu'); iconToggles.forEach(toggle => toggle.checked = false); } } } // ============================================================================= // ERROR HANDLING // ============================================================================= /** * Display error toast notification * CSS handles auto-hide animation via @keyframes toastLifecycle * @param {string} message - Error message to display */ 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 */ function initErrorToastClose() { document.addEventListener('click', (e) => { if (e.target.closest('.error-close')) { document.getElementById('error-toast')?.classList.remove('show'); } }); } // ============================================================================= // HTMX EVENT HANDLERS // ============================================================================= /** * Initialize HTMX global event handlers * Handles errors, analytics, and post-swap behaviors */ function initHTMXHandlers() { // Variable to store scroll position for swaps that should preserve position let savedScrollPosition = 0; let shouldRestoreScroll = false; // Save scroll position before swap document.addEventListener('htmx:beforeSwap', function(evt) { try { const target = evt.detail.target; // Only preserve scroll for toggle operations (not language changes) // Language changes target #cv-content, toggles target .cv-paper or body if (target && (target.classList?.contains('cv-paper') || target.tagName === 'BODY')) { savedScrollPosition = window.pageYOffset; shouldRestoreScroll = true; } else { shouldRestoreScroll = false; } } catch (e) { console.error('Error in htmx:beforeSwap handler:', e); } }); // Restore scroll position after swap (only for toggles) document.addEventListener('htmx:afterSettle', function(evt) { try { if (shouldRestoreScroll && savedScrollPosition >= 0) { window.scrollTo(0, savedScrollPosition); savedScrollPosition = 0; shouldRestoreScroll = false; } } catch (e) { console.error('Error in htmx:afterSettle handler:', e); } }); // Sync toggle states between desktop and mobile menu document.addEventListener('htmx:afterSwap', function(evt) { try { // After any swap, sync the corresponding elements const target = evt.detail.target; // Sync language buttons when CV page content swaps if (target && target.classList && target.classList.contains('cv-page-content-wrapper')) { const urlParams = new URLSearchParams(window.location.search); const currentLang = urlParams.get('lang') || 'en'; const enBtn = document.querySelector('button[aria-label="English"]'); const esBtn = document.querySelector('button[aria-label="Español"]'); if (enBtn && esBtn) { if (currentLang === 'en') { enBtn.classList.add('active'); esBtn.classList.remove('active'); } else { esBtn.classList.add('active'); enBtn.classList.remove('active'); } } } // Sync theme toggles (body swap) - though theme now uses hyperscript if (target && target.tagName === 'BODY') { const desktopToggle = document.getElementById('themeToggle'); const mobileToggle = document.getElementById('themeToggleMenu'); if (desktopToggle && mobileToggle) { const isClean = document.body.classList.contains('theme-clean'); desktopToggle.checked = isClean; mobileToggle.checked = isClean; } } // Sync length and logo toggles (.cv-paper swap) if (target && target.classList && target.classList.contains('cv-paper')) { // Sync length toggles const desktopToggle = document.getElementById('lengthToggle'); const mobileToggle = document.getElementById('lengthToggleMenu'); if (desktopToggle && mobileToggle) { const isLong = target.classList.contains('cv-long'); desktopToggle.checked = isLong; mobileToggle.checked = isLong; console.log(`Toggle sync - Length: desktop=${isLong}, mobile=${isLong}`); } // Sync icon toggles const desktopIconToggle = document.getElementById('iconToggle'); const mobileIconToggle = document.getElementById('iconToggleMenu'); if (desktopIconToggle && mobileIconToggle) { const showIcons = target.classList.contains('show-icons'); desktopIconToggle.checked = showIcons; mobileIconToggle.checked = showIcons; console.log(`Toggle sync - Icons: desktop=${showIcons}, mobile=${showIcons}`); } } } catch (e) { console.error('Error syncing toggles:', e); } }); // HTMX Global Error Handlers document.addEventListener('htmx:responseError', function(evt) { console.error('HTMX Response Error:', evt.detail); console.error('Error details:', { xhr: evt.detail.xhr, target: evt.detail.target, requestConfig: evt.detail.requestConfig }); 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.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.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.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.addEventListener('htmx:afterRequest', function(evt) { if (evt.detail.successful) { console.log('HTMX request successful:', evt.detail.pathInfo.requestPath); } }); } // ============================================================================= // INITIALIZATION // ============================================================================= /** * Initialize all CV interactive features when DOM is ready */ document.addEventListener('DOMContentLoaded', function() { initMenuSystem(); initMenuCloseOnClick(); initPreferences(); initErrorToastClose(); initHTMXHandlers(); }); // ============================================================================= // HYPERSCRIPT-POWERED FEATURES (NO JS NEEDED) // ============================================================================= // The following features have been moved to hyperscript for better // maintainability, declarative syntax, and cleaner HTML templates. // All hyperscript functions are defined in /static/hyperscript/functions._hs // ZOOM CONTROL (Phase 5 - Eliminated ~343 lines) // ----------------------------------------------- // Now handled by hyperscript in zoom-control.html // Features: // - 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) // SCROLL BEHAVIOR (Phase 6 - Eliminated ~59 lines) // ------------------------------------------------ // Now handled by hyperscript on element in index.html // Functions: initScrollBehavior(), handleScroll() // Features: // - Header hide/show based on scroll direction (100px threshold) // - Back-to-top button visibility (appears after 300px scroll) // - At-bottom positioning for fixed buttons (within 50px of page bottom) // - Menu visibility coordination when open // - State tracking: lastScroll, scrollThreshold, keepHeaderVisible // PRINT FUNCTION (Phase 6 - Eliminated ~44 lines, Fixed bug) // ---------------------------------------------------------- // Now handled by hyperscript in action-buttons.html and hamburger-menu.html // Function: printFriendly() // Features: // - Stores current theme, length, and zoom state // - Applies clean theme + short version for printing // - Resets zoom to 100% for consistent print output // - Calls window.print() // - Restores original state after print dialog closes // - Properly restores zoom by triggering slider input event (fixes Phase 5 bug) // MODALS (Phase 4A - Eliminated ~47 lines) // ---------------------------------------- // Now handled by native HTML5 element // Features: // - 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. // ============================================================================= // TOTAL REDUCTION SUMMARY // ============================================================================= // Baseline: 954 lines // Phase 4A: -285 lines (Native APIs, CSS, HTMX) // Phase 5: -343 lines (Hyperscript zoom control) // Phase 6: -87 lines (Hyperscript scroll & print + organization) // Current: 239 lines (74.9% reduction) // ============================================================================= })();