754 lines
28 KiB
JavaScript
754 lines
28 KiB
JavaScript
// CV Interactive Features - CSP-Compliant External JavaScript
|
|
// Extracted from inline scripts for security hardening
|
|
(function() {
|
|
'use strict';
|
|
|
|
// =============================================================================
|
|
// NAVIGATION & MENU SYSTEM
|
|
// =============================================================================
|
|
|
|
// Hover-based menu control
|
|
function initMenuSystem() {
|
|
const hamburgerBtn = document.querySelector('.hamburger-btn');
|
|
const menu = document.getElementById('navigation-menu');
|
|
|
|
if (!hamburgerBtn || !menu) return;
|
|
|
|
// Show menu on hamburger hover
|
|
hamburgerBtn.addEventListener('mouseenter', function() {
|
|
menu.classList.add('menu-hover');
|
|
hamburgerBtn.setAttribute('aria-expanded', 'true');
|
|
});
|
|
|
|
// Hide menu when leaving hamburger (only if not hovering menu)
|
|
hamburgerBtn.addEventListener('mouseleave', function() {
|
|
setTimeout(() => {
|
|
if (!menu.matches(':hover')) {
|
|
menu.classList.remove('menu-hover');
|
|
hamburgerBtn.setAttribute('aria-expanded', 'false');
|
|
}
|
|
}, 100);
|
|
});
|
|
|
|
// Hide menu when leaving menu itself
|
|
menu.addEventListener('mouseleave', function() {
|
|
menu.classList.remove('menu-hover');
|
|
hamburgerBtn.setAttribute('aria-expanded', 'false');
|
|
});
|
|
|
|
// Position submenu dynamically
|
|
const submenuTrigger = document.querySelector('.menu-item-submenu');
|
|
const submenuContent = document.querySelector('.submenu-content');
|
|
|
|
if (submenuTrigger && submenuContent) {
|
|
submenuTrigger.addEventListener('mouseenter', function() {
|
|
const triggerRect = submenuTrigger.getBoundingClientRect();
|
|
submenuContent.style.top = `${triggerRect.top}px`;
|
|
});
|
|
}
|
|
}
|
|
|
|
// Legacy toggle function - kept for compatibility
|
|
window.toggleMenu = function() {
|
|
const menu = document.getElementById('navigation-menu');
|
|
const btn = document.querySelector('.hamburger-btn');
|
|
|
|
if (menu.classList.contains('menu-open')) {
|
|
menu.classList.remove('menu-open');
|
|
btn.setAttribute('aria-expanded', 'false');
|
|
} else {
|
|
menu.classList.add('menu-open');
|
|
btn.setAttribute('aria-expanded', 'true');
|
|
}
|
|
};
|
|
|
|
// Flag to keep header visible after navigation
|
|
let keepHeaderVisible = false;
|
|
|
|
// Expand all sections
|
|
window.expandAllSections = function(event) {
|
|
event.preventDefault();
|
|
const allDetails = document.querySelectorAll('details');
|
|
allDetails.forEach(detail => {
|
|
detail.setAttribute('open', '');
|
|
});
|
|
};
|
|
|
|
// Collapse all sections
|
|
window.collapseAllSections = function(event) {
|
|
event.preventDefault();
|
|
const allDetails = document.querySelectorAll('details');
|
|
allDetails.forEach(detail => {
|
|
detail.removeAttribute('open');
|
|
});
|
|
};
|
|
|
|
// Toggle submenu - no longer needed for hover, but kept for compatibility
|
|
window.toggleSubmenu = function(event) {
|
|
event.preventDefault();
|
|
const submenuContainer = event.currentTarget.parentElement;
|
|
submenuContainer.classList.toggle('submenu-open');
|
|
};
|
|
|
|
// Close menu when navigation links are clicked (native smooth scroll handles scrolling)
|
|
// CSS scroll-padding-top handles the header offset automatically
|
|
document.addEventListener('click', function(e) {
|
|
// Check if clicked element is a navigation link inside the menu
|
|
const navLink = e.target.closest('.submenu-content a[href^="#"]');
|
|
if (navLink) {
|
|
const navMenu = document.querySelector('.navigation-menu');
|
|
const hamburgerBtn = document.querySelector('.hamburger-btn');
|
|
|
|
if (navMenu && hamburgerBtn) {
|
|
navMenu.classList.remove('menu-open');
|
|
hamburgerBtn.setAttribute('aria-expanded', 'false');
|
|
}
|
|
}
|
|
});
|
|
|
|
// Close menu when clicking outside (only for legacy click-opened menus)
|
|
function initClickOutsideHandler() {
|
|
document.addEventListener('click', function(event) {
|
|
const menu = document.getElementById('navigation-menu');
|
|
const btn = document.querySelector('.hamburger-btn');
|
|
|
|
if (menu && btn && menu.classList.contains('menu-open')) {
|
|
if (!menu.contains(event.target) && !btn.contains(event.target)) {
|
|
menu.classList.remove('menu-open');
|
|
btn.setAttribute('aria-expanded', 'false');
|
|
}
|
|
}
|
|
});
|
|
}
|
|
|
|
// =============================================================================
|
|
// LANGUAGE & PREFERENCES
|
|
// =============================================================================
|
|
|
|
// =============================================================================
|
|
// ZOOM CONTROL
|
|
// =============================================================================
|
|
|
|
/**
|
|
* Check if we're on mobile viewport
|
|
* @returns {boolean} True if mobile (viewport <= 768px)
|
|
*/
|
|
function isMobileView() {
|
|
return window.innerWidth <= 768;
|
|
}
|
|
|
|
/**
|
|
* Initialize zoom control on page load
|
|
* Restores saved zoom level from localStorage (desktop only)
|
|
*/
|
|
function initZoomControl() {
|
|
const slider = document.getElementById('zoom-slider');
|
|
const resetBtn = document.getElementById('zoom-reset');
|
|
const zoomWrapper = document.getElementById('zoom-wrapper');
|
|
|
|
if (!slider || !zoomWrapper) return;
|
|
|
|
// On mobile, always use 100% zoom (zoom control is hidden anyway)
|
|
if (isMobileView()) {
|
|
slider.value = 100;
|
|
applyZoom(100, false);
|
|
return; // Skip event listeners on mobile
|
|
}
|
|
|
|
// Desktop: Restore saved zoom level from localStorage
|
|
const savedZoom = localStorage.getItem('cv-zoom');
|
|
if (savedZoom) {
|
|
const zoomValue = parseInt(savedZoom, 10);
|
|
slider.value = zoomValue;
|
|
applyZoom(zoomValue, false); // false = don't save (already loaded from storage)
|
|
}
|
|
|
|
// Real-time slider updates - immediate, smooth analog experience
|
|
slider.addEventListener('input', function(e) {
|
|
const zoomValue = parseInt(e.target.value, 10);
|
|
|
|
// Apply zoom and update display immediately for smooth analog feel
|
|
updateZoomDisplay(zoomValue);
|
|
applyZoom(zoomValue, true);
|
|
});
|
|
|
|
// Reset button
|
|
if (resetBtn) {
|
|
resetBtn.addEventListener('click', function() {
|
|
slider.value = 100;
|
|
applyZoom(100, true);
|
|
slider.focus(); // Return focus to slider for accessibility
|
|
});
|
|
}
|
|
|
|
// Keyboard shortcuts (Ctrl/Cmd + Plus/Minus/0)
|
|
document.addEventListener('keydown', function(e) {
|
|
if ((e.ctrlKey || e.metaKey) && !e.shiftKey) {
|
|
if (e.key === '=' || e.key === '+') {
|
|
e.preventDefault();
|
|
incrementZoom(10);
|
|
} else if (e.key === '-') {
|
|
e.preventDefault();
|
|
incrementZoom(-10);
|
|
} else if (e.key === '0') {
|
|
e.preventDefault();
|
|
slider.value = 100;
|
|
applyZoom(100, true);
|
|
}
|
|
}
|
|
});
|
|
|
|
// Handle window resize - reset zoom when switching to mobile
|
|
let resizeTimeout;
|
|
window.addEventListener('resize', function() {
|
|
clearTimeout(resizeTimeout);
|
|
resizeTimeout = setTimeout(function() {
|
|
if (isMobileView()) {
|
|
// Reset to 100% zoom when switching to mobile
|
|
slider.value = 100;
|
|
applyZoom(100, false);
|
|
}
|
|
}, 250); // Debounce resize events
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Apply zoom transformation to CV paper
|
|
* @param {number} zoomValue - Zoom percentage (25-175, centered at 100)
|
|
* @param {boolean} saveToStorage - Whether to persist to localStorage
|
|
*/
|
|
function applyZoom(zoomValue, saveToStorage = true) {
|
|
const zoomWrapper = document.getElementById('zoom-wrapper');
|
|
if (!zoomWrapper) return;
|
|
|
|
// Convert percentage to decimal (100 = 1.0, 50 = 0.5, etc.)
|
|
const zoomLevel = zoomValue / 100;
|
|
|
|
requestAnimationFrame(() => {
|
|
// Use CSS zoom property - it properly affects layout and extends beyond viewport
|
|
zoomWrapper.style.zoom = zoomLevel;
|
|
|
|
// When zoom > 100%, allow the wrapper to expand beyond viewport width
|
|
// Set width to accommodate the expanded content without bounds
|
|
if (zoomLevel > 1) {
|
|
// Set width to auto to allow natural expansion
|
|
zoomWrapper.style.width = 'auto';
|
|
zoomWrapper.style.minWidth = '100%';
|
|
// Remove max-width constraint to allow horizontal expansion
|
|
zoomWrapper.style.maxWidth = 'none';
|
|
} else {
|
|
// Reset to default when zoom <= 100%
|
|
zoomWrapper.style.width = '';
|
|
zoomWrapper.style.minWidth = '';
|
|
zoomWrapper.style.maxWidth = '';
|
|
}
|
|
|
|
// Reset zoom on fixed buttons so they stay same size
|
|
const backToTopBtn = document.getElementById('back-to-top');
|
|
const infoBtn = document.getElementById('info-button');
|
|
const inverseZoom = 1 / zoomLevel;
|
|
|
|
if (backToTopBtn) backToTopBtn.style.zoom = inverseZoom;
|
|
if (infoBtn) infoBtn.style.zoom = inverseZoom;
|
|
|
|
// Update display
|
|
updateZoomDisplay(zoomValue);
|
|
|
|
// Save to localStorage
|
|
if (saveToStorage) {
|
|
localStorage.setItem('cv-zoom', zoomValue.toString());
|
|
}
|
|
|
|
// Update zoom control position for horizontal scroll
|
|
updateZoomControlPosition();
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Update visual display and ARIA attributes
|
|
* @param {number} zoomValue - Current zoom percentage
|
|
*/
|
|
function updateZoomDisplay(zoomValue) {
|
|
const slider = document.getElementById('zoom-slider');
|
|
const display = document.getElementById('zoom-value-current');
|
|
const resetBtn = document.getElementById('zoom-reset');
|
|
|
|
if (display) {
|
|
display.textContent = zoomValue;
|
|
}
|
|
|
|
if (slider) {
|
|
slider.setAttribute('aria-valuenow', zoomValue);
|
|
slider.setAttribute('aria-valuetext', `${zoomValue}%`);
|
|
}
|
|
|
|
// Add/remove class to enable green hover only when zoom is not 100
|
|
if (resetBtn) {
|
|
if (zoomValue !== 100) {
|
|
resetBtn.classList.add('zoom-not-default');
|
|
} else {
|
|
resetBtn.classList.remove('zoom-not-default');
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Increment/decrement zoom by step amount
|
|
* @param {number} step - Amount to change (positive or negative)
|
|
*/
|
|
function incrementZoom(step) {
|
|
const slider = document.getElementById('zoom-slider');
|
|
if (!slider) return;
|
|
|
|
const currentZoom = parseInt(slider.value, 10);
|
|
const newZoom = Math.min(175, Math.max(25, currentZoom + step));
|
|
|
|
slider.value = newZoom;
|
|
applyZoom(newZoom, true);
|
|
}
|
|
|
|
/**
|
|
* Update zoom control position based on horizontal scroll
|
|
* This keeps the zoom control centered relative to the visible viewport
|
|
*/
|
|
function updateZoomControlPosition() {
|
|
const zoomControl = document.getElementById('zoom-control');
|
|
if (!zoomControl || isMobileView()) return;
|
|
|
|
// Only adjust if zoom control is in default centered position
|
|
// (not dragged to a custom position)
|
|
const savedPosition = localStorage.getItem('cv-zoom-position');
|
|
if (savedPosition) return; // Don't adjust if user has dragged it
|
|
|
|
// Get current horizontal scroll position
|
|
const scrollLeft = window.pageXOffset || document.documentElement.scrollLeft;
|
|
|
|
// Update left position to account for horizontal scroll
|
|
if (scrollLeft > 0) {
|
|
// Adjust position to stay centered in viewport during horizontal scroll
|
|
zoomControl.style.left = `calc(50% + ${scrollLeft}px)`;
|
|
} else {
|
|
// Reset to center when scroll is at start
|
|
zoomControl.style.left = '50%';
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Make zoom control draggable and persist position
|
|
*/
|
|
function initZoomDragging() {
|
|
const zoomControl = document.getElementById('zoom-control');
|
|
if (!zoomControl || isMobileView()) return;
|
|
|
|
let isDragging = false;
|
|
let currentX, currentY, initialX, initialY;
|
|
|
|
// Restore saved position from localStorage
|
|
const savedPosition = localStorage.getItem('cv-zoom-position');
|
|
if (savedPosition) {
|
|
const { bottom, left } = JSON.parse(savedPosition);
|
|
zoomControl.style.bottom = bottom;
|
|
zoomControl.style.left = left;
|
|
zoomControl.style.transform = 'none'; // Remove centering transform when positioned
|
|
}
|
|
|
|
// Start drag on mousedown (but not on slider, close button, or reset button)
|
|
zoomControl.addEventListener('mousedown', function(e) {
|
|
// Ignore if clicking on interactive elements
|
|
if (e.target.closest('.zoom-slider, .zoom-close-btn, .zoom-reset-btn')) {
|
|
return;
|
|
}
|
|
|
|
isDragging = true;
|
|
zoomControl.style.transition = 'none'; // Disable transitions during drag
|
|
|
|
// Get current position
|
|
const rect = zoomControl.getBoundingClientRect();
|
|
initialX = e.clientX - rect.left;
|
|
initialY = e.clientY - rect.top;
|
|
|
|
e.preventDefault();
|
|
});
|
|
|
|
// Drag on mousemove
|
|
document.addEventListener('mousemove', function(e) {
|
|
if (!isDragging) return;
|
|
|
|
e.preventDefault();
|
|
|
|
currentX = e.clientX - initialX;
|
|
currentY = e.clientY - initialY;
|
|
|
|
// Keep within viewport bounds
|
|
const maxX = window.innerWidth - zoomControl.offsetWidth;
|
|
const maxY = window.innerHeight - zoomControl.offsetHeight;
|
|
|
|
currentX = Math.max(0, Math.min(currentX, maxX));
|
|
currentY = Math.max(0, Math.min(currentY, maxY));
|
|
|
|
// Update position
|
|
zoomControl.style.left = currentX + 'px';
|
|
zoomControl.style.bottom = (window.innerHeight - currentY - zoomControl.offsetHeight) + 'px';
|
|
zoomControl.style.transform = 'none'; // Remove centering transform
|
|
});
|
|
|
|
// End drag on mouseup
|
|
document.addEventListener('mouseup', function() {
|
|
if (isDragging) {
|
|
isDragging = false;
|
|
zoomControl.style.transition = 'all 0.3s ease'; // Re-enable transitions
|
|
|
|
// Save position to localStorage
|
|
const position = {
|
|
bottom: zoomControl.style.bottom,
|
|
left: zoomControl.style.left
|
|
};
|
|
localStorage.setItem('cv-zoom-position', JSON.stringify(position));
|
|
}
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Hide zoom control and show menu button
|
|
*/
|
|
function hideZoomControl() {
|
|
const zoomControl = document.getElementById('zoom-control');
|
|
const showButton = document.getElementById('show-zoom-menu-btn');
|
|
|
|
if (zoomControl) {
|
|
zoomControl.style.display = 'none';
|
|
localStorage.setItem('cv-zoom-visible', 'false');
|
|
}
|
|
|
|
if (showButton) {
|
|
showButton.style.display = 'block';
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Show zoom control and hide menu button (global function for onclick)
|
|
*/
|
|
window.showZoomControl = function(event) {
|
|
if (event) event.preventDefault(); // Prevent default link behavior
|
|
|
|
const zoomControl = document.getElementById('zoom-control');
|
|
const showButton = document.getElementById('show-zoom-menu-btn');
|
|
|
|
if (zoomControl) {
|
|
zoomControl.style.display = 'flex';
|
|
localStorage.setItem('cv-zoom-visible', 'true');
|
|
}
|
|
|
|
if (showButton) {
|
|
showButton.style.display = 'none';
|
|
}
|
|
};
|
|
|
|
/**
|
|
* Initialize zoom visibility state from localStorage
|
|
*/
|
|
function initZoomVisibility() {
|
|
if (isMobileView()) return; // Always hidden on mobile
|
|
|
|
const zoomControl = document.getElementById('zoom-control');
|
|
const showButton = document.getElementById('show-zoom-menu-btn');
|
|
const isVisible = localStorage.getItem('cv-zoom-visible');
|
|
|
|
// Default to visible if not set
|
|
if (isVisible === 'false') {
|
|
if (zoomControl) zoomControl.style.display = 'none';
|
|
if (showButton) showButton.style.display = 'block';
|
|
} else {
|
|
if (zoomControl) zoomControl.style.display = 'flex';
|
|
if (showButton) showButton.style.display = 'none';
|
|
}
|
|
|
|
// Setup close button
|
|
const closeBtn = document.getElementById('zoom-close');
|
|
if (closeBtn) {
|
|
closeBtn.addEventListener('click', function(e) {
|
|
e.stopPropagation(); // Prevent drag from starting
|
|
hideZoomControl();
|
|
});
|
|
}
|
|
}
|
|
|
|
// =============================================================================
|
|
// 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);
|
|
}
|
|
|
|
// Initialize zoom control (zoom level, event listeners)
|
|
initZoomControl();
|
|
|
|
// Initialize zoom visibility state (show/hide based on localStorage)
|
|
initZoomVisibility();
|
|
|
|
// Initialize zoom dragging (make draggable, restore position)
|
|
initZoomDragging();
|
|
}
|
|
|
|
// =============================================================================
|
|
// 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');
|
|
|
|
// Update zoom control position on horizontal scroll
|
|
updateZoomControlPosition();
|
|
|
|
// 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');
|
|
}
|
|
}
|
|
|
|
// Show/hide back to top button
|
|
if (currentScroll > 300) {
|
|
backToTopBtn.style.display = 'flex';
|
|
} else {
|
|
backToTopBtn.style.display = 'none';
|
|
}
|
|
|
|
// Add/remove at-bottom class for both buttons
|
|
if (isAtBottom) {
|
|
if (backToTopBtn) backToTopBtn.classList.add('at-bottom');
|
|
if (infoBtn) infoBtn.classList.add('at-bottom');
|
|
} else {
|
|
if (backToTopBtn) backToTopBtn.classList.remove('at-bottom');
|
|
if (infoBtn) infoBtn.classList.remove('at-bottom');
|
|
}
|
|
|
|
lastScrollTop = currentScroll <= 0 ? 0 : currentScroll;
|
|
}, false);
|
|
|
|
// Back to top button click handler
|
|
const backToTopBtn = document.getElementById('back-to-top');
|
|
if (backToTopBtn) {
|
|
backToTopBtn.addEventListener('click', function() {
|
|
window.scrollTo({
|
|
top: 0,
|
|
behavior: 'smooth'
|
|
});
|
|
});
|
|
}
|
|
}
|
|
|
|
// =============================================================================
|
|
// MODALS - Using Native <dialog> Element
|
|
// =============================================================================
|
|
|
|
// 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
|
|
window.showError = function(message) {
|
|
const errorToast = document.getElementById('error-toast');
|
|
const errorMessage = document.getElementById('error-message');
|
|
errorMessage.textContent = message;
|
|
errorToast.style.display = 'flex';
|
|
|
|
// Auto-hide after 5 seconds
|
|
setTimeout(() => {
|
|
errorToast.style.display = 'none';
|
|
}, 5000);
|
|
};
|
|
|
|
// Add close button handler for error toast
|
|
document.addEventListener('click', function(e) {
|
|
if (e.target.closest('.error-close')) {
|
|
const errorToast = document.getElementById('error-toast');
|
|
if (errorToast) {
|
|
errorToast.style.display = 'none';
|
|
}
|
|
}
|
|
});
|
|
|
|
// =============================================================================
|
|
// 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();
|
|
initClickOutsideHandler();
|
|
initPreferences();
|
|
initScrollBehavior();
|
|
initHTMXHandlers();
|
|
});
|
|
|
|
})();
|