package handlers import ( "context" "fmt" "log" "net/http" "os" "os/exec" "path/filepath" "strings" "time" cvmodel "github.com/juanatsap/cv-site/internal/models/cv" uimodel "github.com/juanatsap/cv-site/internal/models/ui" "github.com/juanatsap/cv-site/internal/pdf" "github.com/juanatsap/cv-site/internal/templates" ) // CVHandler handles CV-related requests type CVHandler struct { templates *templates.Manager pdfGenerator *pdf.Generator serverAddr string } // NewCVHandler creates a new CV handler func NewCVHandler(tmpl *templates.Manager, serverAddr string) *CVHandler { return &CVHandler{ templates: tmpl, pdfGenerator: pdf.NewGenerator(30 * time.Second), serverAddr: serverAddr, } } // Home renders the full CV page func (h *CVHandler) Home(w http.ResponseWriter, r *http.Request) { // Check if this is a shortcut URL request (cv-jamr-{year}-{lang}.pdf) if strings.HasPrefix(r.URL.Path, "/cv-jamr-") && strings.HasSuffix(r.URL.Path, ".pdf") { h.DefaultCVShortcut(w, r) return } // Get language from query parameter, default to English lang := r.URL.Query().Get("lang") if lang == "" { lang = "en" } // Validate language if lang != "en" && lang != "es" { HandleError(w, r, BadRequestError("Unsupported language. Use 'en' or 'es'")) return } // Load CV data cv, err := cvmodel.LoadCV(lang) if err != nil { HandleError(w, r, DataLoadError(err, "CV")) return } // Load UI translations ui, err := uimodel.LoadUI(lang) if err != nil { HandleError(w, r, DataLoadError(err, "UI")) return } // 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() // Read user preferences from cookies cvLength := getPreferenceCookie(r, "cv-length", "short") cvIcons := getPreferenceCookie(r, "cv-icons", "show") cvTheme := getPreferenceCookie(r, "cv-theme", "default") // Migrate old preference values to new ones (one-time auto-migration) if cvLength == "extended" { cvLength = "long" setPreferenceCookie(w, "cv-length", "long") } switch cvIcons { case "true": cvIcons = "show" setPreferenceCookie(w, "cv-icons", "show") case "false": cvIcons = "hide" setPreferenceCookie(w, "cv-icons", "hide") } // Prepare CV length class cvLengthClass := "cv-short" if cvLength == "long" { cvLengthClass = "cv-long" } // 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", "CVLengthClass": cvLengthClass, "ShowIcons": (cvIcons == "show"), "ThemeClean": (cvTheme == "clean"), } // Render template 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 } } // CVContent renders just the CV content for HTMX swaps func (h *CVHandler) CVContent(w http.ResponseWriter, r *http.Request) { // Get language from query parameter lang := r.URL.Query().Get("lang") if lang == "" { lang = "en" } // Validate language if lang != "en" && lang != "es" { HandleError(w, r, BadRequestError("Unsupported language. Use 'en' or 'es'")) return } // Load CV data cv, err := cvmodel.LoadCV(lang) if err != nil { HandleError(w, r, DataLoadError(err, "CV")) return } // Load UI translations ui, err := uimodel.LoadUI(lang) if err != nil { HandleError(w, r, DataLoadError(err, "UI")) return } // 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", } // Render 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 } } // DefaultCVShortcut handles shortcut URLs for default CV downloads // Pattern: /cv-jamr-{year}-{lang}.pdf (e.g., /cv-jamr-2025-en.pdf) // Validates year matches current year and redirects to default PDF export func (h *CVHandler) DefaultCVShortcut(w http.ResponseWriter, r *http.Request) { // Extract path (e.g., "/cv-jamr-2025-en.pdf") path := r.URL.Path log.Printf("DefaultCVShortcut called with path: %s", path) // Parse filename pattern: cv-jamr-{year}-{lang}.pdf parts := strings.Split(strings.TrimPrefix(path, "/"), "-") if len(parts) != 4 { http.NotFound(w, r) return } // Extract year and language yearStr := parts[2] langWithExt := parts[3] // "en.pdf" or "es.pdf" lang := strings.TrimSuffix(langWithExt, ".pdf") // Validate language if lang != "en" && lang != "es" { http.NotFound(w, r) return } // Validate year matches current year currentYear := fmt.Sprintf("%d", time.Now().Year()) if yearStr != currentYear { // Return 404 if year doesn't match (old or future URLs) log.Printf("Invalid year in shortcut URL: %s (current: %s)", yearStr, currentYear) http.NotFound(w, r) return } // Generate PDF directly instead of redirecting // This ensures the Content-Disposition filename is respected by browsers log.Printf("Shortcut URL: %s → generating PDF (short + with_skills)", path) // Prepare cookies for PDF generation (short, with_skills, light mode) cookies := map[string]string{ "cv-length": "short", "cv-icons": "show", "cv-language": lang, "cv-theme": "default", // with_skills = default theme "color-theme": "light", // Always light for PDFs } // Construct URL for PDF generation targetURL := fmt.Sprintf("http://%s/?lang=%s", h.serverAddr, lang) // Generate PDF with screen render mode (for sidebar layout) ctx := r.Context() pdfData, err := h.pdfGenerator.GenerateFromURLWithOptions(ctx, targetURL, cookies, pdf.RenderModeScreen) if err != nil { log.Printf("PDF generation failed for shortcut URL: %v", err) HandleError(w, r, InternalError(err)) return } // Use the shortcut filename directly (simple, user-friendly) filename := filepath.Base(path) // cv-jamr-2025-en.pdf // Set response headers with shortcut filename w.Header().Set("Content-Type", "application/pdf") w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=%s", filename)) w.Header().Set("Content-Length", fmt.Sprintf("%d", len(pdfData))) // Write PDF data if _, err := w.Write(pdfData); err != nil { log.Printf("Error writing PDF response: %v", err) return } log.Printf("PDF generated successfully: %s (%d bytes)", filename, len(pdfData)) } // ExportPDF handles PDF export requests using chromedp func (h *CVHandler) ExportPDF(w http.ResponseWriter, r *http.Request) { // Extract and validate query parameters lang := r.URL.Query().Get("lang") if lang == "" { lang = "en" } if lang != "en" && lang != "es" { HandleError(w, r, BadRequestError("Unsupported language. Use 'en' or 'es'")) return } length := r.URL.Query().Get("length") if length == "" { length = "short" } if length != "short" && length != "long" { HandleError(w, r, BadRequestError("Unsupported length. Use 'short' or 'long'")) return } icons := r.URL.Query().Get("icons") if icons == "" { icons = "show" } if icons != "show" && icons != "hide" { HandleError(w, r, BadRequestError("Unsupported icons option. Use 'show' or 'hide'")) return } version := r.URL.Query().Get("version") if version == "" { version = "with_skills" } if version != "with_skills" && version != "clean" { HandleError(w, r, BadRequestError("Unsupported version. Use 'with_skills' or 'clean'")) return } log.Printf("PDF export requested: lang=%s, length=%s, icons=%s, version=%s", lang, length, icons, version) // Load CV data to get name for filename cv, err := cvmodel.LoadCV(lang) if err != nil { HandleError(w, r, DataLoadError(err, "CV")) return } // Prepare cookies to set preferences cookies := map[string]string{ "cv-length": length, "cv-icons": icons, "cv-language": lang, } // Set theme cookie based on version parameter if version == "clean" { cookies["cv-theme"] = "clean" } else { cookies["cv-theme"] = "default" } // CRITICAL: ALWAYS force light mode for PDF generation (print-friendly) // This ensures PDFs are NEVER generated in dark mode, regardless of user's preference cookies["color-theme"] = "light" // Construct URL for PDF generation (navigate to home page) targetURL := fmt.Sprintf("http://%s/?lang=%s", h.serverAddr, lang) // Determine render mode based on version parameter // Clean version: use @media print CSS (print-friendly, no sidebars) // Extended version: use @media screen CSS (full layout with sidebars) var renderMode pdf.RenderMode if version == "clean" { renderMode = pdf.RenderModePrint } else { renderMode = pdf.RenderModeScreen } // Generate PDF with cookies and appropriate render mode ctx := r.Context() pdfData, err := h.pdfGenerator.GenerateFromURLWithOptions(ctx, targetURL, cookies, renderMode) if err != nil { log.Printf("PDF generation failed: %v", err) HandleError(w, r, InternalError(err)) return } // Generate filename based on parameters // Format: cv-{length}[-{version}]-{initials}-{year}-{lang}.pdf // Note: {version} is OMITTED when it's "clean" // Examples: // - cv-short-jamr-2025-es.pdf (clean version, no skills) // - cv-short-with_skills-jamr-2025-es.pdf (with skills sidebar) // - cv-long-jamr-2025-en.pdf (clean version, no skills) // - cv-long-with_skills-jamr-2025-en.pdf (with skills sidebar) // Generate initials from name nameParts := strings.Fields(cv.Personal.Name) initials := "" for _, part := range nameParts { if len(part) > 0 { // Take first letter of each name part initials += string([]rune(part)[0]) } } initials = strings.ToLower(initials) // Get current year currentYear := time.Now().Year() // Build filename: cv-{length}[-{version}]-{initials}-{year}-{lang}.pdf // Omit version if it's "clean" // Replace underscores with hyphens in version for filename (with_skills → with-skills) var filename string if version == "clean" { filename = fmt.Sprintf("cv-%s-%s-%d-%s.pdf", length, initials, currentYear, lang) } else { versionForFilename := strings.ReplaceAll(version, "_", "-") filename = fmt.Sprintf("cv-%s-%s-%s-%d-%s.pdf", length, versionForFilename, initials, currentYear, lang) } // Set response headers w.Header().Set("Content-Type", "application/pdf") w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=%s", filename)) w.Header().Set("Content-Length", fmt.Sprintf("%d", len(pdfData))) // Write PDF data if _, err := w.Write(pdfData); err != nil { log.Printf("Error writing PDF response: %v", err) return } log.Printf("PDF generated successfully: %s (%d bytes)", filename, len(pdfData)) } // splitSkills splits skill categories between left (page 1) and right (page 2) sidebars // Each category explicitly specifies which sidebar it belongs to via the "sidebar" field func splitSkills(skills []cvmodel.SkillCategory) (left, right []cvmodel.SkillCategory) { if len(skills) == 0 { return nil, nil } // Filter by sidebar field for _, skill := range skills { if skill.Sidebar == "right" { right = append(right, skill) } else { // Default to left if not specified or if set to "left" left = append(left, skill) } } return left, right } // calculateYearsOfExperience calculates years of experience since April 1, 2005 // This matches the original React implementation that calculated from 01/04/2005 func calculateYearsOfExperience() int { // First day at work: April 1, 2005 firstDay := time.Date(2005, time.April, 1, 9, 0, 0, 0, time.UTC) // Current date now := time.Now() // Calculate the difference in years years := now.Year() - firstDay.Year() // Adjust if we haven't reached the anniversary this year yet if now.Month() < firstDay.Month() || (now.Month() == firstDay.Month() && now.Day() < firstDay.Day()) { years-- } return years } // calculateDuration calculates the duration between two dates in years and months // Date format expected: "YYYY-MM" (e.g., "2021-01") // Returns a formatted string like "3 years 6 months" or "6 months" func calculateDuration(startDate, endDate string, current bool, lang string) string { // Parse start date start, err := time.Parse("2006-01", startDate) if err != nil { return "" } // Determine end date var end time.Time if current { end = time.Now() } else { end, err = time.Parse("2006-01", endDate) if err != nil { return "" } } // Calculate total months totalMonths := (end.Year()-start.Year())*12 + int(end.Month()-start.Month()) // If end date is before start date, return empty if totalMonths < 0 { return "" } years := totalMonths / 12 months := totalMonths % 12 // Format the duration string based on language var result string if lang == "es" { if years > 0 && months > 0 { yearStr := "años" if years == 1 { yearStr = "año" } monthStr := "meses" if months == 1 { monthStr = "mes" } result = fmt.Sprintf("(%d %s %d %s)", years, yearStr, months, monthStr) } else if years > 0 { yearStr := "años" if years == 1 { yearStr = "año" } result = fmt.Sprintf("(%d %s)", years, yearStr) } else { monthStr := "meses" if months == 1 { monthStr = "mes" } result = fmt.Sprintf("(%d %s)", months, monthStr) } } else { if years > 0 && months > 0 { yearStr := "years" if years == 1 { yearStr = "year" } monthStr := "months" if months == 1 { monthStr = "month" } result = fmt.Sprintf("(%d %s %d %s)", years, yearStr, months, monthStr) } else if years > 0 { yearStr := "years" if years == 1 { yearStr = "year" } result = fmt.Sprintf("(%d %s)", years, yearStr) } else { monthStr := "months" if months == 1 { monthStr = "month" } result = fmt.Sprintf("(%d %s)", months, monthStr) } } return result } // processProjectDates calculates dynamic dates for projects // If a project has a gitRepoUrl, it fetches the first commit date // For current projects, it sets the current system date func processProjectDates(project *cvmodel.Project, lang string) { now := time.Now() // Set dynamic current date for ongoing projects if project.Current { if lang == "es" { project.DynamicDate = "Presente" } else { project.DynamicDate = "Present" } } // If project has a git repository URL, fetch the first commit date if project.GitRepoUrl != "" { commitDate := getGitRepoFirstCommitDate(project.GitRepoUrl) if commitDate != "" { project.ComputedStartDate = commitDate } } // If no computed date and no static date, use current date for current projects if project.ComputedStartDate == "" && project.StartDate == "" && project.Current { project.ComputedStartDate = now.Format("2006-01") } // If we have a computed date but no static date, use the computed one if project.ComputedStartDate != "" && project.StartDate == "" { project.StartDate = project.ComputedStartDate } } // findProjectRoot finds the project root directory // It looks for .git directory walking up the directory tree func findProjectRoot() (string, error) { // Start from current working directory cwd, err := os.Getwd() if err != nil { return "", err } // Walk up the directory tree looking for .git dir := cwd for { gitPath := filepath.Join(dir, ".git") if info, err := os.Stat(gitPath); err == nil && info.IsDir() { // Found .git directory - this is the project root return dir, nil } // Move up one directory parent := filepath.Dir(dir) if parent == dir { // Reached root directory without finding .git // Fall back to current working directory return cwd, nil } dir = parent } } // validateRepoPath validates that a repository path is safe to use // Security: Prevents path traversal and command injection attacks // Only allows paths within the project directory func validateRepoPath(path string) error { // Resolve to absolute path to prevent path traversal absPath, err := filepath.Abs(path) if err != nil { return fmt.Errorf("invalid path: %w", err) } // Get project root directory - find the git repo root // This ensures the validation works regardless of where code runs from projectRoot, err := findProjectRoot() if err != nil { return fmt.Errorf("cannot determine project root: %w", err) } // Security check: Only allow paths within project directory // This prevents malicious paths like "../../../etc/passwd" if !strings.HasPrefix(absPath, projectRoot) { return fmt.Errorf("repository path outside project directory: %s", path) } // Verify path exists and is a directory info, err := os.Stat(absPath) if err != nil { return fmt.Errorf("path does not exist: %w", err) } if !info.IsDir() { return fmt.Errorf("path is not a directory: %s", path) } return nil } // getGitRepoFirstCommitDate fetches the first commit date from a git repository // Supports local git repository paths // Security: Validates path and uses timeout to prevent hanging func getGitRepoFirstCommitDate(repoPath string) string { // Security: Validate repository path before executing git command if err := validateRepoPath(repoPath); err != nil { log.Printf("Security: Rejected git operation for invalid path %s: %v", repoPath, err) return "" } // Security: Add timeout context to prevent hanging ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() // Execute git command with timeout protection // Using CommandContext for automatic cancellation on timeout cmd := exec.CommandContext(ctx, "git", "-C", repoPath, "log", "--reverse", "--format=%ci", "--date=format:%Y-%m") output, err := cmd.Output() if err != nil { // Log error but don't expose details to prevent information disclosure log.Printf("Git command failed for path %s: %v", repoPath, err) return "" } // Parse the output to get the first commit date lines := strings.Split(strings.TrimSpace(string(output)), "\n") if len(lines) == 0 { return "" } // Extract YYYY-MM from the first commit timestamp // Format of output: "2024-06-15 10:30:45 +0200" firstLine := lines[0] parts := strings.Fields(firstLine) if len(parts) > 0 { datePart := parts[0] // "2024-06-15" dateParts := strings.Split(datePart, "-") if len(dateParts) >= 2 { return dateParts[0] + "-" + dateParts[1] // "2024-06" } } 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 := cvmodel.LoadCV(lang) if err != nil { return nil, err } // Load UI translations ui, err := uimodel.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) using atomic out-of-band swaps 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") // Migrate old value if needed if currentLength == "extended" { currentLength = "long" } // 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 with length state cvLengthClass := "cv-short" if newLength == "long" { cvLengthClass = "cv-long" } data := map[string]interface{}{ "Lang": lang, "CVLengthClass": cvLengthClass, } // Render length-toggle template with out-of-band swaps tmpl, err := h.templates.Render("length-toggle.html") if err != nil { HandleError(w, r, TemplateError(err, "length-toggle.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, "length-toggle.html")) return } } // ToggleIcons handles icon visibility toggle using atomic out-of-band swaps func (h *CVHandler) ToggleIcons(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) return } // Get current state currentIcons := getPreferenceCookie(r, "cv-icons", "show") // Migrate old values if needed switch currentIcons { case "true": currentIcons = "show" case "false": currentIcons = "hide" } // Toggle state newIcons := "hide" if currentIcons == "hide" { newIcons = "show" } // Save new state setPreferenceCookie(w, "cv-icons", newIcons) // Get language lang := r.URL.Query().Get("lang") if lang == "" { lang = getPreferenceCookie(r, "cv-language", "en") } // Prepare template data with logo state data := map[string]interface{}{ "Lang": lang, "ShowIcons": (newIcons == "show"), } // Render logo-toggle template with out-of-band swaps tmpl, err := h.templates.Render("logo-toggle.html") if err != nil { HandleError(w, r, TemplateError(err, "logo-toggle.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, "logo-toggle.html")) return } } // SwitchLanguage handles language switching with atomic updates // Uses HTMX out-of-band swaps to update both the language selector buttons // and all CV content wrappers in a single response func (h *CVHandler) SwitchLanguage(w http.ResponseWriter, r *http.Request) { // Get language from query parameter lang := r.URL.Query().Get("lang") if lang == "" { lang = "en" } // Validate language if lang != "en" && lang != "es" { HandleError(w, r, BadRequestError("Unsupported language. Use 'en' or 'es'")) return } // Save language preference setPreferenceCookie(w, "cv-language", lang) // Prepare template data data, err := h.prepareTemplateData(lang) if err != nil { HandleError(w, r, DataLoadError(err, "CV")) return } // Preserve current length and logo preferences cvLength := getPreferenceCookie(r, "cv-length", "short") cvIcons := getPreferenceCookie(r, "cv-icons", "show") cvTheme := getPreferenceCookie(r, "cv-theme", "default") // Add preferences to data if cvLength == "long" { data["CVLengthClass"] = "cv-long" } else { data["CVLengthClass"] = "cv-short" } data["ShowIcons"] = (cvIcons == "show") data["ThemeClean"] = (cvTheme == "clean") // Render language-switch template with out-of-band swaps tmpl, err := h.templates.Render("language-switch.html") if err != nil { HandleError(w, r, TemplateError(err, "language-switch.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, "language-switch.html")) return } } // ToggleTheme handles theme toggle (default/clean) using atomic out-of-band swaps 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 with theme state data := map[string]interface{}{ "Lang": lang, "ThemeClean": (newTheme == "clean"), } // Render theme-toggle template with out-of-band swaps tmpl, err := h.templates.Render("theme-toggle.html") if err != nil { HandleError(w, r, TemplateError(err, "theme-toggle.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, "theme-toggle.html")) return } }