Files
cv-site/static/js/main.js
T
juanatsap 06eb490950 more htmx
2025-11-14 21:38:09 +00:00

426 lines
18 KiB
JavaScript

// 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 <details> 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 <details> 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 logos 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 savedLogos = localStorage.getItem('cv-logos');
// 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 logos preference
if (cvPaper && savedLogos !== null) {
if (savedLogos === 'true') {
cvPaper.classList.add('show-logos');
const logoToggles = document.querySelectorAll('#logoToggle, #logoToggleMenu');
logoToggles.forEach(toggle => toggle.checked = true);
} else {
cvPaper.classList.remove('show-logos');
const logoToggles = document.querySelectorAll('#logoToggle, #logoToggleMenu');
logoToggles.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 logo toggles
const desktopLogoToggle = document.getElementById('logoToggle');
const mobileLogoToggle = document.getElementById('logoToggleMenu');
if (desktopLogoToggle && mobileLogoToggle) {
const showLogos = target.classList.contains('show-logos');
desktopLogoToggle.checked = showLogos;
mobileLogoToggle.checked = showLogos;
console.log(`Toggle sync - Logos: desktop=${showLogos}, mobile=${showLogos}`);
}
}
} 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 <body> 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 <dialog> 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)
// =============================================================================
})();