diff --git a/internal/handlers/cv.go b/internal/handlers/cv.go index e400879..d47bbaf 100644 --- a/internal/handlers/cv.go +++ b/internal/handlers/cv.go @@ -84,6 +84,17 @@ func (h *CVHandler) Home(w http.ResponseWriter, r *http.Request) { // Get current year currentYear := time.Now().Year() + // Read user preferences from cookies + cvLength := getPreferenceCookie(r, "cv-length", "short") + cvLogos := getPreferenceCookie(r, "cv-logos", "show") + cvTheme := getPreferenceCookie(r, "cv-theme", "default") + + // Prepare CV length class + cvLengthClass := "cv-short" + if cvLength == "long" { + cvLengthClass = "cv-long" + } + // Prepare template data data := map[string]interface{}{ "CV": cv, @@ -96,6 +107,9 @@ func (h *CVHandler) Home(w http.ResponseWriter, r *http.Request) { "CanonicalURL": fmt.Sprintf("https://juan.andres.morenorub.io/?lang=%s", lang), "AlternateEN": "https://juan.andres.morenorub.io/?lang=en", "AlternateES": "https://juan.andres.morenorub.io/?lang=es", + "CVLengthClass": cvLengthClass, + "ShowLogos": (cvLogos == "show"), + "ThemeClean": (cvTheme == "clean"), } // Render template @@ -566,3 +580,247 @@ func getGitRepoFirstCommitDate(repoPath string) string { return "" } + +// ============================================================================== +// HTMX ENDPOINTS - Phase 2 +// ============================================================================== + +// prepareTemplateData prepares common template data used across handlers +func (h *CVHandler) prepareTemplateData(lang string) (map[string]interface{}, error) { + // Load CV data + cv, err := models.LoadCV(lang) + if err != nil { + return nil, err + } + + // Load UI translations + ui, err := models.LoadUI(lang) + if err != nil { + return nil, err + } + + // Calculate duration for each experience + for i := range cv.Experience { + cv.Experience[i].Duration = calculateDuration( + cv.Experience[i].StartDate, + cv.Experience[i].EndDate, + cv.Experience[i].Current, + lang, + ) + } + + // Process projects for dynamic dates + for i := range cv.Projects { + processProjectDates(&cv.Projects[i], lang) + } + + // Split skills between left and right sidebars + skillsLeft, skillsRight := splitSkills(cv.Skills.Technical) + + // Calculate years of experience + yearsOfExperience := calculateYearsOfExperience() + + // Get current year + currentYear := time.Now().Year() + + // Prepare template data + data := map[string]interface{}{ + "CV": cv, + "UI": ui, + "Lang": lang, + "SkillsLeft": skillsLeft, + "SkillsRight": skillsRight, + "YearsOfExperience": yearsOfExperience, + "CurrentYear": currentYear, + "CanonicalURL": fmt.Sprintf("https://juan.andres.morenorub.io/?lang=%s", lang), + "AlternateEN": "https://juan.andres.morenorub.io/?lang=en", + "AlternateES": "https://juan.andres.morenorub.io/?lang=es", + } + + return data, nil +} + +// getPreferenceCookie gets a preference cookie value, returns default if not found +func getPreferenceCookie(r *http.Request, name string, defaultValue string) string { + cookie, err := r.Cookie(name) + if err != nil { + return defaultValue + } + return cookie.Value +} + +// setPreferenceCookie sets a preference cookie (1 year expiry) +func setPreferenceCookie(w http.ResponseWriter, name string, value string) { + http.SetCookie(w, &http.Cookie{ + Name: name, + Value: value, + Path: "/", + MaxAge: 365 * 24 * 60 * 60, // 1 year + HttpOnly: true, + SameSite: http.SameSiteStrictMode, + Secure: false, // Set to true in production with HTTPS + }) +} + +// ToggleLength handles CV length toggle (short/long) +func (h *CVHandler) ToggleLength(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) + return + } + + // Get current state + currentLength := getPreferenceCookie(r, "cv-length", "short") + + // Toggle state + newLength := "long" + if currentLength == "long" { + newLength = "short" + } + + // Save new state + setPreferenceCookie(w, "cv-length", newLength) + + // Get language + lang := r.URL.Query().Get("lang") + if lang == "" { + lang = getPreferenceCookie(r, "cv-language", "en") + } + + // Prepare template data + data, err := h.prepareTemplateData(lang) + if err != nil { + HandleError(w, r, DataLoadError(err, "CV")) + return + } + + // Add length class to data + if newLength == "long" { + data["CVLengthClass"] = "cv-long" + } else { + data["CVLengthClass"] = "cv-short" + } + + // Also read and preserve logo preference + cvLogos := getPreferenceCookie(r, "cv-logos", "show") + data["ShowLogos"] = (cvLogos == "show") + + // Render cv-content template + tmpl, err := h.templates.Render("cv-content.html") + if err != nil { + HandleError(w, r, TemplateError(err, "cv-content.html")) + return + } + + w.Header().Set("Content-Type", "text/html; charset=utf-8") + if err := tmpl.Execute(w, data); err != nil { + HandleError(w, r, TemplateError(err, "cv-content.html")) + return + } +} + +// ToggleLogos handles logo visibility toggle +func (h *CVHandler) ToggleLogos(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) + return + } + + // Get current state + currentLogos := getPreferenceCookie(r, "cv-logos", "show") + + // Toggle state + newLogos := "hide" + if currentLogos == "hide" { + newLogos = "show" + } + + // Save new state + setPreferenceCookie(w, "cv-logos", newLogos) + + // Get language + lang := r.URL.Query().Get("lang") + if lang == "" { + lang = getPreferenceCookie(r, "cv-language", "en") + } + + // Prepare template data + data, err := h.prepareTemplateData(lang) + if err != nil { + HandleError(w, r, DataLoadError(err, "CV")) + return + } + + // Add logos class to data + data["ShowLogos"] = (newLogos == "show") + + // Also read and preserve length preference + cvLength := getPreferenceCookie(r, "cv-length", "short") + if cvLength == "long" { + data["CVLengthClass"] = "cv-long" + } else { + data["CVLengthClass"] = "cv-short" + } + + // Render cv-content template + tmpl, err := h.templates.Render("cv-content.html") + if err != nil { + HandleError(w, r, TemplateError(err, "cv-content.html")) + return + } + + w.Header().Set("Content-Type", "text/html; charset=utf-8") + if err := tmpl.Execute(w, data); err != nil { + HandleError(w, r, TemplateError(err, "cv-content.html")) + return + } +} + +// ToggleTheme handles theme toggle (default/clean) +func (h *CVHandler) ToggleTheme(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) + return + } + + // Get current state + currentTheme := getPreferenceCookie(r, "cv-theme", "default") + + // Toggle state + newTheme := "clean" + if currentTheme == "clean" { + newTheme = "default" + } + + // Save new state + setPreferenceCookie(w, "cv-theme", newTheme) + + // Get language + lang := r.URL.Query().Get("lang") + if lang == "" { + lang = getPreferenceCookie(r, "cv-language", "en") + } + + // Prepare template data + data, err := h.prepareTemplateData(lang) + if err != nil { + HandleError(w, r, DataLoadError(err, "CV")) + return + } + + // Add theme class to data + data["ThemeClean"] = (newTheme == "clean") + + // Render full page to update container class + tmpl, err := h.templates.Render("index.html") + if err != nil { + HandleError(w, r, TemplateError(err, "index.html")) + return + } + + w.Header().Set("Content-Type", "text/html; charset=utf-8") + if err := tmpl.Execute(w, data); err != nil { + HandleError(w, r, TemplateError(err, "index.html")) + return + } +} diff --git a/internal/routes/routes.go b/internal/routes/routes.go index d75a4cd..9eef43e 100644 --- a/internal/routes/routes.go +++ b/internal/routes/routes.go @@ -17,6 +17,11 @@ func Setup(cvHandler *handlers.CVHandler, healthHandler *handlers.HealthHandler) mux.HandleFunc("/cv", cvHandler.CVContent) mux.HandleFunc("/health", healthHandler.Check) + // HTMX endpoints for interactive controls + mux.HandleFunc("/toggle/length", cvHandler.ToggleLength) + mux.HandleFunc("/toggle/logos", cvHandler.ToggleLogos) + mux.HandleFunc("/toggle/theme", cvHandler.ToggleTheme) + // Protected PDF endpoint with rate limiting (3 requests/minute per IP) pdfRateLimiter := middleware.NewRateLimiter(3, 1*time.Minute) protectedPDFHandler := middleware.OriginChecker( diff --git a/static/js/main.js b/static/js/main.js index 634d214..482880e 100644 --- a/static/js/main.js +++ b/static/js/main.js @@ -106,40 +106,21 @@ submenuContainer.classList.toggle('submenu-open'); }; - // Scroll to section smoothly - window.scrollToSection = function(sectionId) { - event.preventDefault(); // Prevent default anchor behavior - - const section = document.getElementById(sectionId); - if (section) { - // Ensure header is visible before scrolling - const actionBar = document.querySelector('.action-bar'); + // 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'); - actionBar.classList.remove('header-hidden'); - navMenu.classList.remove('header-hidden'); + const hamburgerBtn = document.querySelector('.hamburger-btn'); - // Set flag to keep header visible - keepHeaderVisible = true; - - // Close menu after clicking - navMenu.classList.remove('menu-open'); - document.querySelector('.hamburger-btn').setAttribute('aria-expanded', 'false'); - - // Wait a bit for header to be visible, then calculate offset - setTimeout(() => { - const actionBarHeight = actionBar.offsetHeight; - const offset = actionBarHeight + 20; // Add 20px padding - - const elementPosition = section.getBoundingClientRect().top; - const offsetPosition = elementPosition + window.pageYOffset - offset; - - window.scrollTo({ - top: offsetPosition, - behavior: 'smooth' - }); - }, 100); + 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() { @@ -160,100 +141,6 @@ // LANGUAGE & PREFERENCES // ============================================================================= - // Track if URL originally had lang parameter - const urlHadLangParam = new URLSearchParams(window.location.search).has('lang'); - - window.selectLanguage = function(lang) { - // Save language preference to localStorage - localStorage.setItem('cv-language', lang); - - // Reload page with new language parameter - const url = new URL(window.location); - url.searchParams.set('lang', lang); - window.location.href = url.toString(); - }; - - window.toggleCVLength = function() { - const headerToggle = document.getElementById('lengthToggle'); - const menuToggle = document.getElementById('lengthToggleMenu'); - const paper = document.querySelector('.cv-paper'); - - // Get the state from whichever toggle was clicked - const isChecked = event?.target?.id === 'lengthToggleMenu' ? menuToggle?.checked : headerToggle?.checked; - - // Sync both toggles - if (headerToggle) headerToggle.checked = isChecked; - if (menuToggle) menuToggle.checked = isChecked; - - // Save current scroll position - const currentScrollY = window.scrollY || window.pageYOffset; - - if (isChecked) { - paper.classList.add('cv-long'); - paper.classList.remove('cv-short'); - localStorage.setItem('cv-length', 'long'); - } else { - paper.classList.add('cv-short'); - paper.classList.remove('cv-long'); - localStorage.setItem('cv-length', 'short'); - } - - // Restore scroll position after DOM updates - requestAnimationFrame(() => { - window.scrollTo(0, currentScrollY); - }); - }; - - window.toggleLogos = function() { - const headerToggle = document.getElementById('logoToggle'); - const menuToggle = document.getElementById('logoToggleMenu'); - const paper = document.querySelector('.cv-paper'); - - // Get the state from whichever toggle was clicked - const isChecked = event?.target?.id === 'logoToggleMenu' ? menuToggle?.checked : headerToggle?.checked; - - // Sync both toggles - if (headerToggle) headerToggle.checked = isChecked; - if (menuToggle) menuToggle.checked = isChecked; - - // Save current scroll position - const currentScrollY = window.scrollY || window.pageYOffset; - - if (isChecked) { - paper.classList.add('show-logos'); - localStorage.setItem('cv-logos', 'show'); - } else { - paper.classList.remove('show-logos'); - localStorage.setItem('cv-logos', 'hide'); - } - - // Restore scroll position after DOM updates - requestAnimationFrame(() => { - window.scrollTo(0, currentScrollY); - }); - }; - - window.toggleTheme = function() { - const headerToggle = document.getElementById('themeToggle'); - const menuToggle = document.getElementById('themeToggleMenu'); - const container = document.querySelector('.cv-container'); - - // Get the state from whichever toggle was clicked - const isChecked = event?.target?.id === 'themeToggleMenu' ? menuToggle?.checked : headerToggle?.checked; - - // Sync both toggles - if (headerToggle) headerToggle.checked = isChecked; - if (menuToggle) menuToggle.checked = isChecked; - - if (isChecked) { - container.classList.add('theme-clean'); - localStorage.setItem('cv-theme', 'clean'); - } else { - container.classList.remove('theme-clean'); - localStorage.setItem('cv-theme', 'default'); - } - }; - // ============================================================================= // ZOOM CONTROL // ============================================================================= @@ -673,48 +560,6 @@ localStorage.setItem('cv-language', urlLang); } - // Restore CV length preference - const savedLength = localStorage.getItem('cv-length') || 'short'; - const lengthChecked = savedLength === 'long'; - if (lengthChecked) { - paper.classList.add('cv-long'); - paper.classList.remove('cv-short'); - } else { - paper.classList.add('cv-short'); - paper.classList.remove('cv-long'); - } - // Sync both header and menu toggles - const headerLengthToggle = document.getElementById('lengthToggle'); - const menuLengthToggle = document.getElementById('lengthToggleMenu'); - if (headerLengthToggle) headerLengthToggle.checked = lengthChecked; - if (menuLengthToggle) menuLengthToggle.checked = lengthChecked; - - // Restore logos preference - const savedLogos = localStorage.getItem('cv-logos') || 'show'; - const logosChecked = savedLogos === 'show'; - if (logosChecked) { - paper.classList.add('show-logos'); - } else { - paper.classList.remove('show-logos'); - } - // Sync both header and menu toggles - const headerLogoToggle = document.getElementById('logoToggle'); - const menuLogoToggle = document.getElementById('logoToggleMenu'); - if (headerLogoToggle) headerLogoToggle.checked = logosChecked; - if (menuLogoToggle) menuLogoToggle.checked = logosChecked; - - // Restore theme preference - const savedTheme = localStorage.getItem('cv-theme') || 'default'; - const themeChecked = savedTheme === 'clean'; - // Sync both header and menu toggles - const headerThemeToggle = document.getElementById('themeToggle'); - const menuThemeToggle = document.getElementById('themeToggleMenu'); - if (headerThemeToggle) headerThemeToggle.checked = themeChecked; - if (menuThemeToggle) menuThemeToggle.checked = themeChecked; - if (themeChecked) { - window.toggleTheme(); - } - // Initialize zoom control (zoom level, event listeners) initZoomControl(); diff --git a/static/js/main.js.backup b/static/js/main.js.backup new file mode 100644 index 0000000..634d214 --- /dev/null +++ b/static/js/main.js.backup @@ -0,0 +1,954 @@ +// 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; + + // Toggle sidebar accordion (mobile only) + window.toggleSidebar = function(header) { + const content = header.nextElementSibling; + const isActive = header.classList.contains('active'); + + if (isActive) { + // Close + header.classList.remove('active'); + content.classList.remove('active'); + } else { + // Open + header.classList.add('active'); + content.classList.add('active'); + } + }; + + // 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'); + }; + + // Scroll to section smoothly + window.scrollToSection = function(sectionId) { + event.preventDefault(); // Prevent default anchor behavior + + const section = document.getElementById(sectionId); + if (section) { + // Ensure header is visible before scrolling + const actionBar = document.querySelector('.action-bar'); + const navMenu = document.querySelector('.navigation-menu'); + actionBar.classList.remove('header-hidden'); + navMenu.classList.remove('header-hidden'); + + // Set flag to keep header visible + keepHeaderVisible = true; + + // Close menu after clicking + navMenu.classList.remove('menu-open'); + document.querySelector('.hamburger-btn').setAttribute('aria-expanded', 'false'); + + // Wait a bit for header to be visible, then calculate offset + setTimeout(() => { + const actionBarHeight = actionBar.offsetHeight; + const offset = actionBarHeight + 20; // Add 20px padding + + const elementPosition = section.getBoundingClientRect().top; + const offsetPosition = elementPosition + window.pageYOffset - offset; + + window.scrollTo({ + top: offsetPosition, + behavior: 'smooth' + }); + }, 100); + } + }; + + // 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 + // ============================================================================= + + // Track if URL originally had lang parameter + const urlHadLangParam = new URLSearchParams(window.location.search).has('lang'); + + window.selectLanguage = function(lang) { + // Save language preference to localStorage + localStorage.setItem('cv-language', lang); + + // Reload page with new language parameter + const url = new URL(window.location); + url.searchParams.set('lang', lang); + window.location.href = url.toString(); + }; + + window.toggleCVLength = function() { + const headerToggle = document.getElementById('lengthToggle'); + const menuToggle = document.getElementById('lengthToggleMenu'); + const paper = document.querySelector('.cv-paper'); + + // Get the state from whichever toggle was clicked + const isChecked = event?.target?.id === 'lengthToggleMenu' ? menuToggle?.checked : headerToggle?.checked; + + // Sync both toggles + if (headerToggle) headerToggle.checked = isChecked; + if (menuToggle) menuToggle.checked = isChecked; + + // Save current scroll position + const currentScrollY = window.scrollY || window.pageYOffset; + + if (isChecked) { + paper.classList.add('cv-long'); + paper.classList.remove('cv-short'); + localStorage.setItem('cv-length', 'long'); + } else { + paper.classList.add('cv-short'); + paper.classList.remove('cv-long'); + localStorage.setItem('cv-length', 'short'); + } + + // Restore scroll position after DOM updates + requestAnimationFrame(() => { + window.scrollTo(0, currentScrollY); + }); + }; + + window.toggleLogos = function() { + const headerToggle = document.getElementById('logoToggle'); + const menuToggle = document.getElementById('logoToggleMenu'); + const paper = document.querySelector('.cv-paper'); + + // Get the state from whichever toggle was clicked + const isChecked = event?.target?.id === 'logoToggleMenu' ? menuToggle?.checked : headerToggle?.checked; + + // Sync both toggles + if (headerToggle) headerToggle.checked = isChecked; + if (menuToggle) menuToggle.checked = isChecked; + + // Save current scroll position + const currentScrollY = window.scrollY || window.pageYOffset; + + if (isChecked) { + paper.classList.add('show-logos'); + localStorage.setItem('cv-logos', 'show'); + } else { + paper.classList.remove('show-logos'); + localStorage.setItem('cv-logos', 'hide'); + } + + // Restore scroll position after DOM updates + requestAnimationFrame(() => { + window.scrollTo(0, currentScrollY); + }); + }; + + window.toggleTheme = function() { + const headerToggle = document.getElementById('themeToggle'); + const menuToggle = document.getElementById('themeToggleMenu'); + const container = document.querySelector('.cv-container'); + + // Get the state from whichever toggle was clicked + const isChecked = event?.target?.id === 'themeToggleMenu' ? menuToggle?.checked : headerToggle?.checked; + + // Sync both toggles + if (headerToggle) headerToggle.checked = isChecked; + if (menuToggle) menuToggle.checked = isChecked; + + if (isChecked) { + container.classList.add('theme-clean'); + localStorage.setItem('cv-theme', 'clean'); + } else { + container.classList.remove('theme-clean'); + localStorage.setItem('cv-theme', 'default'); + } + }; + + // ============================================================================= + // 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); + } + + // Restore CV length preference + const savedLength = localStorage.getItem('cv-length') || 'short'; + const lengthChecked = savedLength === 'long'; + if (lengthChecked) { + paper.classList.add('cv-long'); + paper.classList.remove('cv-short'); + } else { + paper.classList.add('cv-short'); + paper.classList.remove('cv-long'); + } + // Sync both header and menu toggles + const headerLengthToggle = document.getElementById('lengthToggle'); + const menuLengthToggle = document.getElementById('lengthToggleMenu'); + if (headerLengthToggle) headerLengthToggle.checked = lengthChecked; + if (menuLengthToggle) menuLengthToggle.checked = lengthChecked; + + // Restore logos preference + const savedLogos = localStorage.getItem('cv-logos') || 'show'; + const logosChecked = savedLogos === 'show'; + if (logosChecked) { + paper.classList.add('show-logos'); + } else { + paper.classList.remove('show-logos'); + } + // Sync both header and menu toggles + const headerLogoToggle = document.getElementById('logoToggle'); + const menuLogoToggle = document.getElementById('logoToggleMenu'); + if (headerLogoToggle) headerLogoToggle.checked = logosChecked; + if (menuLogoToggle) menuLogoToggle.checked = logosChecked; + + // Restore theme preference + const savedTheme = localStorage.getItem('cv-theme') || 'default'; + const themeChecked = savedTheme === 'clean'; + // Sync both header and menu toggles + const headerThemeToggle = document.getElementById('themeToggle'); + const menuThemeToggle = document.getElementById('themeToggleMenu'); + if (headerThemeToggle) headerThemeToggle.checked = themeChecked; + if (menuThemeToggle) menuThemeToggle.checked = themeChecked; + if (themeChecked) { + window.toggleTheme(); + } + + // 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 + // ============================================================================= + + // Info Modal Functions + window.openInfoModal = function() { + const modal = document.getElementById('info-modal'); + modal.classList.add('active'); + document.body.style.overflow = 'hidden'; // Prevent scrolling when modal is open + }; + + window.closeInfoModal = function() { + const modal = document.getElementById('info-modal'); + modal.classList.remove('active'); + document.body.style.overflow = ''; // Restore scrolling + }; + + window.closeInfoModalOnBackdrop = function(event) { + if (event.target.id === 'info-modal') { + window.closeInfoModal(); + } + }; + + // PDF Modal Functions + window.openPdfModal = function() { + const modal = document.getElementById('pdf-modal'); + modal.classList.add('active'); + document.body.style.overflow = 'hidden'; // Prevent scrolling when modal is open + }; + + window.closePdfModal = function() { + const modal = document.getElementById('pdf-modal'); + modal.classList.remove('active'); + document.body.style.overflow = ''; // Restore scrolling + }; + + window.closePdfModalOnBackdrop = function(event) { + if (event.target.id === 'pdf-modal') { + window.closePdfModal(); + } + }; + + // Close modals with Escape key + function initModalKeyHandlers() { + document.addEventListener('keydown', function(event) { + if (event.key === 'Escape') { + window.closeInfoModal(); + window.closePdfModal(); + } + }); + } + + // ============================================================================= + // 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); + }; + + // ============================================================================= + // 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(); + initModalKeyHandlers(); + initHTMXHandlers(); + }); + +})(); diff --git a/templates/cv-content.html b/templates/cv-content.html index 32b12aa..6f960b8 100644 --- a/templates/cv-content.html +++ b/templates/cv-content.html @@ -1,3 +1,7 @@ +
{{template "title-badges" .}} @@ -79,3 +83,4 @@ {{template "cv-footer" .}}
+
diff --git a/templates/index.html b/templates/index.html index a918a7b..13a3110 100644 --- a/templates/index.html +++ b/templates/index.html @@ -98,7 +98,7 @@ } - + {{template "action-bar" .}} {{template "hamburger-menu" .}} @@ -106,12 +106,7 @@
-
- {{template "cv-content.html" .}} -
+ {{template "cv-content.html" .}}
{{template "page-footer" .}} diff --git a/templates/partials/navigation/hamburger-menu.html b/templates/partials/navigation/hamburger-menu.html index 9c0c888..c840c1c 100644 --- a/templates/partials/navigation/hamburger-menu.html +++ b/templates/partials/navigation/hamburger-menu.html @@ -10,39 +10,39 @@