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 @@
+