Files
cv-site/static/js/main.js
T

327 lines
14 KiB
JavaScript
Raw Normal View History

// 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) => {
2025-11-12 18:55:06 +00:00
const navLink = e.target.closest('.submenu-content a[href^="#"]');
if (navLink) {
document.querySelector('.navigation-menu')?.classList.remove('menu-hover', 'menu-open');
}
2025-11-12 18:55:06 +00:00
});
// =============================================================================
// LANGUAGE & PREFERENCES
// =============================================================================
// =============================================================================
2025-11-12 22:54:46 +00:00
// ZOOM CONTROL - Now handled by Hyperscript in zoom-control.html
// =============================================================================
2025-11-12 22:54:46 +00:00
// 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);
}
2025-11-12 22:54:46 +00:00
// 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');
}
}
2025-11-12 19:54:56 +00:00
// 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);
2025-11-12 19:54:56 +00:00
// Back to top - now uses native anchor link with CSS smooth scroll
// No click handler needed! Native <a href="#top"> with scroll-behavior: smooth
}
// =============================================================================
2025-11-12 19:13:52 +00:00
// MODALS - Using Native <dialog> Element
// =============================================================================
2025-11-12 19:13:52 +00:00
// Native <dialog> 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) => {
2025-11-12 18:59:48 +00:00
if (e.target.closest('.error-close')) {
document.getElementById('error-toast')?.classList.remove('show');
2025-11-12 18:59:48 +00:00
}
});
// =============================================================================
// 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();
});
})();