// 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; // Flag to track language switch in progress let languageSwitching = false; // Expose for testing (read-only access) Object.defineProperty(window, 'languageSwitching', { get: () => languageSwitching }); // ============================================================================= // NAVIGATION & MENU SYSTEM // ============================================================================= /** * Initialize minimal menu control system * CSS handles most logic, JS bridges hamburger to menu * Click toggle for mobile, hover for desktop */ function initMenuSystem() { const hamburgerBtn = document.querySelector('.hamburger-btn'); const menu = document.getElementById('navigation-menu'); if (!hamburgerBtn || !menu) return; // Click handler for mobile - toggles menu-open class hamburgerBtn.addEventListener('click', (e) => { e.stopPropagation(); menu.classList.toggle('menu-open'); }); // Close menu when clicking outside (for mobile) document.addEventListener('click', (e) => { if (!menu.contains(e.target) && !hamburgerBtn.contains(e.target)) { menu.classList.remove('menu-open'); } }); // Desktop hover support - show menu on hamburger hover 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`; }); } } /** * Auto-open sidebar accordions in landscape mobile mode AND desktop * Ensures sidebar content is always visible except in portrait mobile */ function handleLandscapeAccordions() { function openSidebarAccordionsIfNeeded() { const isLandscape = window.matchMedia('(max-width: 915px) and (orientation: landscape)').matches; const isDesktop = window.matchMedia('(min-width: 769px)').matches; const isPortraitMobile = window.matchMedia('(max-width: 768px) and (orientation: portrait)').matches; // Open accordions in landscape mobile OR desktop view // Keep them closed ONLY in portrait mobile (≤768px) if (isLandscape || isDesktop) { document.querySelectorAll('.sidebar-accordion').forEach(accordion => { accordion.setAttribute('open', ''); }); } else if (isPortraitMobile) { // In portrait mobile, leave them closed (user can expand manually) // Don't remove 'open' attribute if user has opened them } } // Run on load openSidebarAccordionsIfNeeded(); // Run on orientation change window.addEventListener('orientationchange', () => { setTimeout(openSidebarAccordionsIfNeeded, 100); }); // Run on resize (for desktop browser testing) window.addEventListener('resize', () => { openSidebarAccordionsIfNeeded(); }); } // Menu close on nav click is now handled by scrollToSection() in utils._hs // ============================================================================= // 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'); let savedLength = localStorage.getItem('cv-length'); let savedIcons = localStorage.getItem('cv-icons'); // Migrate old localStorage values to new ones (one-time auto-migration) if (savedLength === 'extended') { savedLength = 'long'; localStorage.setItem('cv-length', 'long'); } if (savedIcons === 'true') { savedIcons = 'show'; localStorage.setItem('cv-icons', 'show'); } else if (savedIcons === 'false') { savedIcons = 'hide'; localStorage.setItem('cv-icons', 'hide'); } // Apply theme preference const cvContainer = document.querySelector('.cv-container'); if (savedTheme === 'clean') { cvContainer?.classList.add('theme-clean'); const themeToggles = document.querySelectorAll('#themeToggle, #themeToggleMenu'); themeToggles.forEach(toggle => toggle.checked = true); } else if (savedTheme === 'default') { cvContainer?.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 === 'show') { 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 }; /** * Display PDF download toast notification * Shows progress and completion status for PDF downloads * @param {Object} options - Toast configuration * @param {string} options.icon - Icon emoji (📥, ✅, ⚠️) * @param {string} options.title - Toast title * @param {string} options.message - Toast message * @param {number} options.duration - Auto-hide duration in ms (default: 5000) */ window.showPDFToast = function(options = {}) { const toast = document.getElementById('pdf-toast'); const icon = document.getElementById('pdf-toast-icon'); const title = document.getElementById('pdf-toast-title'); const message = document.getElementById('pdf-toast-message'); const progressBar = document.getElementById('pdf-toast-progress'); // Set content if (options.icon) icon.textContent = options.icon; if (options.title) title.textContent = options.title; if (options.message) message.textContent = options.message; // Reset animation toast.classList.remove('show'); void toast.offsetWidth; // Trigger reflow // Reset progress bar animation if (progressBar) { progressBar.style.animation = 'none'; void progressBar.offsetWidth; // Set duration if provided const duration = options.duration || 5000; progressBar.style.animation = `progressShrink ${duration}ms linear forwards`; } // Show toast toast.classList.add('show'); // Auto-hide after duration if (options.autoHide !== false) { const duration = options.duration || 5000; setTimeout(() => { toast.classList.remove('show'); }, duration); } }; /** * Hide PDF toast immediately */ window.hidePDFToast = function() { const toast = document.getElementById('pdf-toast'); toast?.classList.remove('show'); }; // Error toast close handled by hyperscript inline in error-toast.html // ============================================================================= // 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); } }); // Skeleton loader for language transitions // Add .loading class when language button is clicked document.addEventListener('htmx:beforeRequest', function(evt) { try { const element = evt.detail.elt; if (element && element.classList && element.classList.contains('selector-btn')) { // Set flag to track language switching languageSwitching = true; // Add loading class to page containers const page1 = document.getElementById('cv-inner-content-page-1'); const page2 = document.getElementById('cv-inner-content-page-2'); if (page1) page1.classList.add('loading'); if (page2) page2.classList.add('loading'); console.log('Skeleton loader: Added .loading class to page containers'); } } catch (e) { console.error('Error in skeleton loader beforeRequest handler:', e); } }); // Remove .loading class after language transition completes document.addEventListener('htmx:afterSettle', function(evt) { try { if (languageSwitching) { // Wait for final render to complete setTimeout(function() { const page1 = document.getElementById('cv-inner-content-page-1'); const page2 = document.getElementById('cv-inner-content-page-2'); if (page1) page1.classList.remove('loading'); if (page2) page2.classList.remove('loading'); // Reset flag languageSwitching = false; console.log('Skeleton loader: Removed .loading class from page containers'); }, 100); } } catch (e) { console.error('Error in skeleton loader 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.classList.contains('cv-container')) { const desktopToggle = document.getElementById('themeToggle'); const mobileToggle = document.getElementById('themeToggleMenu'); if (desktopToggle && mobileToggle) { const isClean = document.querySelector('.cv-container')?.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(); initPreferences(); initHTMXHandlers(); handleLandscapeAccordions(); // Auto-open sidebar accordions in landscape mode // Note: Scroll behavior now handled by hyperscript in index.html body tag // Note: Zoom control buttons now handled by hyperscript (zoom._hs) // initScrollBehaviorJS() removed - hyperscript handleScroll() is preferred }); // ============================================================================= // 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