// 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 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'); 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'); }; /** * 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); } }); // 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 zoom control button handlers * Handles close button, show button, and toggle button */ function initZoomControlButtons() { const closeBtn = document.getElementById('zoom-close'); const showBtn = document.getElementById('show-zoom-menu-btn'); const toggleBtn = document.getElementById('zoom-toggle-button'); const zoomControl = document.getElementById('zoom-control'); // Helper function to toggle zoom visibility function toggleZoom(show) { if (show) { zoomControl.classList.remove('zoom-hidden'); if (showBtn) showBtn.classList.add('zoom-hidden'); // Don't add zoom-active class - button stays same color localStorage.setItem('cv-zoom-visible', 'true'); } else { zoomControl.classList.add('zoom-hidden'); if (showBtn) showBtn.classList.remove('zoom-hidden'); // Don't remove zoom-active class - wasn't added localStorage.setItem('cv-zoom-visible', 'false'); } } // Close button handler if (closeBtn && zoomControl) { closeBtn.addEventListener('click', function(e) { e.preventDefault(); e.stopPropagation(); console.log('Zoom close clicked'); toggleZoom(false); }); } // Show button handler (hamburger menu) if (showBtn && zoomControl) { showBtn.addEventListener('click', function(e) { e.preventDefault(); e.stopPropagation(); console.log('Zoom show clicked'); toggleZoom(true); }); } // Toggle button handler (fixed button) if (toggleBtn && zoomControl) { toggleBtn.addEventListener('click', function(e) { e.preventDefault(); e.stopPropagation(); const isVisible = !zoomControl.classList.contains('zoom-hidden'); console.log('Zoom toggle clicked, currently visible:', isVisible); toggleZoom(!isVisible); }); } // No need to set initial state since we don't use zoom-active class anymore const isVisible = localStorage.getItem('cv-zoom-visible'); console.log('Zoom control initialized. localStorage cv-zoom-visible:', isVisible); } /** * Initialize all CV interactive features when DOM is ready */ document.addEventListener('DOMContentLoaded', function() { initMenuSystem(); initMenuCloseOnClick(); initPreferences(); initErrorToastClose(); initHTMXHandlers(); initZoomControlButtons(); }); // ============================================================================= // 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. // ============================================================================= // PDF MODAL HELPER FUNCTION // ============================================================================= /** * Opens the PDF download modal * Called from references section links with action="downloadPDF" */ window.openPdfModal = function() { const pdfModal = document.querySelector('#pdf-modal'); if (pdfModal) { // Apply mobile centering via inline styles with !important (overrides CSS !important) if (window.innerWidth <= 768) { // Reset inset FIRST (before setting top/left) pdfModal.style.setProperty('inset', 'auto', 'important'); pdfModal.style.setProperty('margin', '0', 'important'); // Now set positioning with !important (after inset reset) pdfModal.style.setProperty('position', 'fixed', 'important'); pdfModal.style.setProperty('top', '50%', 'important'); pdfModal.style.setProperty('left', '50%', 'important'); pdfModal.style.setProperty('right', 'auto', 'important'); pdfModal.style.setProperty('bottom', 'auto', 'important'); pdfModal.style.setProperty('transform', 'translate(-50%, -50%)', 'important'); } pdfModal.showModal(); } }; // ============================================================================= // 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) // ============================================================================= })();