phase ii and phase iii
This commit is contained in:
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
+12
-167
@@ -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();
|
||||
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
})();
|
||||
@@ -1,3 +1,7 @@
|
||||
<main id="cv-content"
|
||||
class="cv-paper {{.CVLengthClass}} {{if .ShowLogos}}show-logos{{end}}"
|
||||
role="main"
|
||||
aria-live="polite">
|
||||
<!-- PAGE 1 -->
|
||||
<div class="cv-page page-1">
|
||||
{{template "title-badges" .}}
|
||||
@@ -79,3 +83,4 @@
|
||||
|
||||
{{template "cv-footer" .}}
|
||||
</div>
|
||||
</main>
|
||||
|
||||
@@ -98,7 +98,7 @@
|
||||
}
|
||||
</script>
|
||||
</head>
|
||||
<body>
|
||||
<body {{if .ThemeClean}}class="theme-clean"{{end}}>
|
||||
{{template "action-bar" .}}
|
||||
{{template "hamburger-menu" .}}
|
||||
|
||||
@@ -106,12 +106,7 @@
|
||||
<div id="zoom-wrapper" class="zoom-wrapper">
|
||||
<!-- CV Content Container -->
|
||||
<div class="cv-container">
|
||||
<main id="cv-content"
|
||||
class="cv-paper"
|
||||
role="main"
|
||||
aria-live="polite">
|
||||
{{template "cv-content.html" .}}
|
||||
</main>
|
||||
{{template "cv-content.html" .}}
|
||||
</div>
|
||||
|
||||
{{template "page-footer" .}}
|
||||
|
||||
@@ -10,39 +10,39 @@
|
||||
<iconify-icon icon="mdi:chevron-right" width="16" height="16" class="submenu-arrow"></iconify-icon>
|
||||
</a>
|
||||
<div class="submenu-content">
|
||||
<a href="#education" class="menu-item" onclick="scrollToSection('education')">
|
||||
<a href="#education" class="menu-item">
|
||||
<iconify-icon icon="mdi:school" width="20" height="20"></iconify-icon>
|
||||
<span>{{if eq .Lang "es"}}Formación{{else}}Training{{end}}</span>
|
||||
</a>
|
||||
<a href="#skills" class="menu-item" onclick="scrollToSection('skills')">
|
||||
<a href="#skills" class="menu-item">
|
||||
<iconify-icon icon="mdi:brain" width="20" height="20"></iconify-icon>
|
||||
<span>{{if eq .Lang "es"}}Competencias{{else}}Skills{{end}}</span>
|
||||
</a>
|
||||
<a href="#experience" class="menu-item" onclick="scrollToSection('experience')">
|
||||
<a href="#experience" class="menu-item">
|
||||
<iconify-icon icon="mdi:office-building" width="20" height="20"></iconify-icon>
|
||||
<span>{{if eq .Lang "es"}}Experiencia{{else}}Experience{{end}}</span>
|
||||
</a>
|
||||
<a href="#awards" class="menu-item" onclick="scrollToSection('awards')">
|
||||
<a href="#awards" class="menu-item">
|
||||
<iconify-icon icon="mdi:trophy" width="20" height="20"></iconify-icon>
|
||||
<span>{{if eq .Lang "es"}}Premios y Reconocimientos{{else}}Awards{{end}}</span>
|
||||
</a>
|
||||
<a href="#projects" class="menu-item" onclick="scrollToSection('projects')">
|
||||
<a href="#projects" class="menu-item">
|
||||
<iconify-icon icon="mdi:web" width="20" height="20"></iconify-icon>
|
||||
<span>{{if eq .Lang "es"}}Proyectos Personales / Freelance{{else}}Personal / Freelance Projects{{end}}</span>
|
||||
</a>
|
||||
<a href="#courses" class="menu-item" onclick="scrollToSection('courses')">
|
||||
<a href="#courses" class="menu-item">
|
||||
<iconify-icon icon="mdi:school" width="20" height="20"></iconify-icon>
|
||||
<span>{{if eq .Lang "es"}}Cursos Realizados{{else}}Courses{{end}}</span>
|
||||
</a>
|
||||
<a href="#languages" class="menu-item" onclick="scrollToSection('languages')">
|
||||
<a href="#languages" class="menu-item">
|
||||
<iconify-icon icon="mdi:translate" width="20" height="20"></iconify-icon>
|
||||
<span>{{if eq .Lang "es"}}Idiomas{{else}}Languages{{end}}</span>
|
||||
</a>
|
||||
<a href="#references" class="menu-item" onclick="scrollToSection('references')">
|
||||
<a href="#references" class="menu-item">
|
||||
<iconify-icon icon="mdi:link-variant" width="20" height="20"></iconify-icon>
|
||||
<span>{{if eq .Lang "es"}}Referencias{{else}}References{{end}}</span>
|
||||
</a>
|
||||
<a href="#other" class="menu-item" onclick="scrollToSection('other')">
|
||||
<a href="#other" class="menu-item">
|
||||
<iconify-icon icon="mdi:information" width="20" height="20"></iconify-icon>
|
||||
<span>{{if eq .Lang "es"}}Otros{{else}}Other{{end}}</span>
|
||||
</a>
|
||||
@@ -84,7 +84,13 @@
|
||||
<span>{{if eq .Lang "es"}}Longitud{{else}}Length{{end}}</span>
|
||||
</label>
|
||||
<label class="icon-toggle">
|
||||
<input type="checkbox" id="lengthToggleMenu" onchange="toggleCVLength()">
|
||||
<input type="checkbox"
|
||||
id="lengthToggleMenu"
|
||||
{{if eq .CVLengthClass "cv-long"}}checked{{end}}
|
||||
hx-post="/toggle/length"
|
||||
hx-target=".cv-paper"
|
||||
hx-swap="outerHTML"
|
||||
hx-indicator="#loading">
|
||||
<span class="icon-toggle-slider">
|
||||
<iconify-icon icon="mdi:file-document-outline" width="16" height="16" class="icon-left"></iconify-icon>
|
||||
<iconify-icon icon="mdi:file-document-multiple-outline" width="16" height="16" class="icon-right"></iconify-icon>
|
||||
@@ -99,7 +105,13 @@
|
||||
<span>{{if eq .Lang "es"}}Logos{{else}}Logos{{end}}</span>
|
||||
</label>
|
||||
<label class="icon-toggle">
|
||||
<input type="checkbox" id="logoToggleMenu" checked onchange="toggleLogos()">
|
||||
<input type="checkbox"
|
||||
id="logoToggleMenu"
|
||||
{{if .ShowLogos}}checked{{end}}
|
||||
hx-post="/toggle/logos"
|
||||
hx-target=".cv-paper"
|
||||
hx-swap="outerHTML"
|
||||
hx-indicator="#loading">
|
||||
<span class="icon-toggle-slider">
|
||||
<iconify-icon icon="mdi:image-off-outline" width="16" height="16" class="icon-left"></iconify-icon>
|
||||
<iconify-icon icon="mdi:image-multiple-outline" width="16" height="16" class="icon-right"></iconify-icon>
|
||||
@@ -114,7 +126,13 @@
|
||||
<span>{{if eq .Lang "es"}}Vista{{else}}View{{end}}</span>
|
||||
</label>
|
||||
<label class="icon-toggle">
|
||||
<input type="checkbox" id="themeToggleMenu" onchange="toggleTheme()">
|
||||
<input type="checkbox"
|
||||
id="themeToggleMenu"
|
||||
{{if .ThemeClean}}checked{{end}}
|
||||
hx-post="/toggle/theme"
|
||||
hx-target="body"
|
||||
hx-swap="outerHTML"
|
||||
hx-indicator="#loading">
|
||||
<span class="icon-toggle-slider">
|
||||
<iconify-icon icon="mdi:page-layout-sidebar-left" width="16" height="16" class="icon-left"></iconify-icon>
|
||||
<iconify-icon icon="mdi:page-layout-body" width="16" height="16" class="icon-right"></iconify-icon>
|
||||
|
||||
@@ -1,10 +1,24 @@
|
||||
{{define "language-selector"}}
|
||||
<!-- Language selector -->
|
||||
<div class="language-selector">
|
||||
<button class="selector-btn {{if eq .Lang "en"}}active{{end}}" data-short="EN" onclick="selectLanguage('en')" aria-label="English">
|
||||
<button class="selector-btn {{if eq .Lang "en"}}active{{end}}"
|
||||
data-short="EN"
|
||||
hx-get="/?lang=en"
|
||||
hx-target="body"
|
||||
hx-swap="outerHTML"
|
||||
hx-indicator="#loading"
|
||||
hx-push-url="true"
|
||||
aria-label="English">
|
||||
English
|
||||
</button>
|
||||
<button class="selector-btn {{if eq .Lang "es"}}active{{end}}" data-short="ES" onclick="selectLanguage('es')" aria-label="Español">
|
||||
<button class="selector-btn {{if eq .Lang "es"}}active{{end}}"
|
||||
data-short="ES"
|
||||
hx-get="/?lang=es"
|
||||
hx-target="body"
|
||||
hx-swap="outerHTML"
|
||||
hx-indicator="#loading"
|
||||
hx-push-url="true"
|
||||
aria-label="Español">
|
||||
Español
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -5,7 +5,13 @@
|
||||
<div class="selector-group">
|
||||
<label class="selector-label">{{if eq .Lang "es"}}Longitud{{else}}Length{{end}}:</label>
|
||||
<label class="icon-toggle">
|
||||
<input type="checkbox" id="lengthToggle" onchange="toggleCVLength()">
|
||||
<input type="checkbox"
|
||||
id="lengthToggle"
|
||||
{{if eq .CVLengthClass "cv-long"}}checked{{end}}
|
||||
hx-post="/toggle/length"
|
||||
hx-target=".cv-paper"
|
||||
hx-swap="outerHTML"
|
||||
hx-indicator="#loading">
|
||||
<span class="icon-toggle-slider">
|
||||
<iconify-icon icon="mdi:file-document-outline" width="16" height="16" class="icon-left"></iconify-icon>
|
||||
<iconify-icon icon="mdi:file-document-multiple-outline" width="16" height="16" class="icon-right"></iconify-icon>
|
||||
@@ -17,7 +23,13 @@
|
||||
<div class="selector-group">
|
||||
<label class="selector-label">{{if eq .Lang "es"}}Logos{{else}}Logos{{end}}:</label>
|
||||
<label class="icon-toggle">
|
||||
<input type="checkbox" id="logoToggle" checked onchange="toggleLogos()">
|
||||
<input type="checkbox"
|
||||
id="logoToggle"
|
||||
{{if .ShowLogos}}checked{{end}}
|
||||
hx-post="/toggle/logos"
|
||||
hx-target=".cv-paper"
|
||||
hx-swap="outerHTML"
|
||||
hx-indicator="#loading">
|
||||
<span class="icon-toggle-slider">
|
||||
<iconify-icon icon="mdi:image-off-outline" width="16" height="16" class="icon-left"></iconify-icon>
|
||||
<iconify-icon icon="mdi:image-multiple-outline" width="16" height="16" class="icon-right"></iconify-icon>
|
||||
@@ -29,7 +41,13 @@
|
||||
<div class="selector-group">
|
||||
<label class="selector-label">{{if eq .Lang "es"}}Vista{{else}}View{{end}}:</label>
|
||||
<label class="icon-toggle">
|
||||
<input type="checkbox" id="themeToggle" onchange="toggleTheme()">
|
||||
<input type="checkbox"
|
||||
id="themeToggle"
|
||||
{{if .ThemeClean}}checked{{end}}
|
||||
hx-post="/toggle/theme"
|
||||
hx-target="body"
|
||||
hx-swap="outerHTML"
|
||||
hx-indicator="#loading">
|
||||
<span class="icon-toggle-slider">
|
||||
<iconify-icon icon="mdi:page-layout-sidebar-left" width="16" height="16" class="icon-left"></iconify-icon>
|
||||
<iconify-icon icon="mdi:page-layout-body" width="16" height="16" class="icon-right"></iconify-icon>
|
||||
|
||||
Reference in New Issue
Block a user