Files
cv-site/internal/handlers/cv.go
T
juanatsap 585db9aeb1 fix: Convert if-else chains to switch statements for staticcheck QF1003
- Convert cvIcons migration logic to switch statement (line 103-110)
- Convert currentIcons migration logic to switch statement (line 861-866)
- Resolves golangci-lint staticcheck QF1003 warnings in GitHub Actions
2025-11-20 13:23:32 +00:00

1001 lines
28 KiB
Go

package handlers
import (
"context"
"fmt"
"log"
"net/http"
"os"
"os/exec"
"path/filepath"
"strings"
"time"
"github.com/juanatsap/cv-site/internal/models"
"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 := models.LoadCV(lang)
if err != nil {
HandleError(w, r, DataLoadError(err, "CV"))
return
}
// Load UI translations
ui, err := models.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 := models.LoadCV(lang)
if err != nil {
HandleError(w, r, DataLoadError(err, "CV"))
return
}
// Load UI translations
ui, err := models.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 := models.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 []models.SkillCategory) (left, right []models.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 *models.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 := 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) 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
}
}