2025-10-20 08:54:21 +01:00
package handlers
import (
2025-11-11 13:53:14 +00:00
"context"
2025-11-04 19:07:34 +00:00
"fmt"
"log"
2025-10-20 08:54:21 +01:00
"net/http"
2025-11-09 02:43:40 +00:00
"os"
"os/exec"
2025-11-11 13:53:14 +00:00
"path/filepath"
2025-11-09 02:43:40 +00:00
"strings"
2025-11-04 19:07:34 +00:00
"time"
2025-10-20 08:54:21 +01:00
"github.com/juanatsap/cv-site/internal/models"
2025-11-04 19:07:34 +00:00
"github.com/juanatsap/cv-site/internal/pdf"
2025-10-20 08:54:21 +01:00
"github.com/juanatsap/cv-site/internal/templates"
)
// CVHandler handles CV-related requests
type CVHandler struct {
2025-11-04 19:07:34 +00:00
templates * templates . Manager
pdfGenerator * pdf . Generator
serverAddr string
2025-10-20 08:54:21 +01:00
}
// NewCVHandler creates a new CV handler
2025-11-04 19:07:34 +00:00
func NewCVHandler ( tmpl * templates . Manager , serverAddr string ) * CVHandler {
2025-10-20 08:54:21 +01:00
return & CVHandler {
2025-11-04 19:07:34 +00:00
templates : tmpl ,
pdfGenerator : pdf . NewGenerator ( 30 * time . Second ) ,
serverAddr : serverAddr ,
2025-10-20 08:54:21 +01:00
}
}
// Home renders the full CV page
func ( h * CVHandler ) Home ( w http . ResponseWriter , r * http . Request ) {
// 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
}
2025-11-09 21:03:39 +00:00
// Load UI translations
ui , err := models . LoadUI ( lang )
if err != nil {
HandleError ( w , r , DataLoadError ( err , "UI" ) )
return
}
2025-11-06 10:36:00 +00:00
// 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 ,
)
}
2025-11-09 02:43:40 +00:00
// Process projects for dynamic dates
for i := range cv . Projects {
processProjectDates ( & cv . Projects [ i ] , lang )
}
2025-11-04 19:07:34 +00:00
// Split skills between left and right sidebars
skillsLeft , skillsRight := splitSkills ( cv . Skills . Technical )
2025-11-06 09:11:17 +00:00
// Calculate years of experience
yearsOfExperience := calculateYearsOfExperience ( )
2025-11-07 11:49:47 +00:00
// Get current year
currentYear := time . Now ( ) . Year ( )
2025-11-12 18:55:06 +00:00
// Read user preferences from cookies
cvLength := getPreferenceCookie ( r , "cv-length" , "short" )
2025-11-15 18:42:35 +00:00
cvIcons := getPreferenceCookie ( r , "cv-icons" , "show" )
2025-11-12 18:55:06 +00:00
cvTheme := getPreferenceCookie ( r , "cv-theme" , "default" )
// Prepare CV length class
cvLengthClass := "cv-short"
if cvLength == "long" {
cvLengthClass = "cv-long"
}
2025-10-20 08:54:21 +01:00
// Prepare template data
data := map [ string ] interface { } {
2025-11-06 09:11:17 +00:00
"CV" : cv ,
2025-11-09 21:03:39 +00:00
"UI" : ui ,
2025-11-06 09:11:17 +00:00
"Lang" : lang ,
"SkillsLeft" : skillsLeft ,
"SkillsRight" : skillsRight ,
"YearsOfExperience" : yearsOfExperience ,
2025-11-07 11:49:47 +00:00
"CurrentYear" : currentYear ,
2025-11-11 13:53:14 +00:00
"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" ,
2025-11-12 18:55:06 +00:00
"CVLengthClass" : cvLengthClass ,
2025-11-15 18:42:35 +00:00
"ShowIcons" : ( cvIcons == "show" ) ,
2025-11-12 18:55:06 +00:00
"ThemeClean" : ( cvTheme == "clean" ) ,
2025-10-20 08:54:21 +01:00
}
// 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
}
2025-11-09 21:03:39 +00:00
// Load UI translations
ui , err := models . LoadUI ( lang )
if err != nil {
HandleError ( w , r , DataLoadError ( err , "UI" ) )
return
}
2025-11-06 10:36:00 +00:00
// 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 ,
)
}
2025-11-09 02:43:40 +00:00
// Process projects for dynamic dates
for i := range cv . Projects {
processProjectDates ( & cv . Projects [ i ] , lang )
}
2025-11-04 19:07:34 +00:00
// Split skills between left and right sidebars
skillsLeft , skillsRight := splitSkills ( cv . Skills . Technical )
2025-11-06 09:11:17 +00:00
// Calculate years of experience
yearsOfExperience := calculateYearsOfExperience ( )
2025-11-07 11:49:47 +00:00
// Get current year
currentYear := time . Now ( ) . Year ( )
2025-10-20 08:54:21 +01:00
// Prepare template data
data := map [ string ] interface { } {
2025-11-06 09:11:17 +00:00
"CV" : cv ,
2025-11-09 21:03:39 +00:00
"UI" : ui ,
2025-11-06 09:11:17 +00:00
"Lang" : lang ,
"SkillsLeft" : skillsLeft ,
"SkillsRight" : skillsRight ,
"YearsOfExperience" : yearsOfExperience ,
2025-11-07 11:49:47 +00:00
"CurrentYear" : currentYear ,
2025-11-11 13:53:14 +00:00
"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" ,
2025-10-20 08:54:21 +01:00
}
// 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
}
}
2025-11-04 19:07:34 +00:00
// ExportPDF handles PDF export requests using chromedp
2025-11-10 15:45:55 +00:00
// TEMPORARILY DISABLED - Work in progress
2025-10-20 08:54:21 +01:00
func ( h * CVHandler ) ExportPDF ( w http . ResponseWriter , r * http . Request ) {
// Get language from query parameter
lang := r . URL . Query ( ) . Get ( "lang" )
if lang == "" {
lang = "en"
}
2025-11-04 19:07:34 +00:00
// Validate language
if lang != "en" && lang != "es" {
HandleError ( w , r , BadRequestError ( "Unsupported language. Use 'en' or 'es'" ) )
return
}
2025-11-10 15:45:55 +00:00
log . Printf ( "PDF export requested but temporarily disabled (redirecting to print friendly)" )
2025-11-04 19:07:34 +00:00
2025-11-10 15:45:55 +00:00
// Return HTML with message and redirect to print friendly
message := "PDF Export - Work in Progress"
body := "The PDF export feature is currently being improved. Please use the <strong>Print Friendly</strong> button instead (Ctrl+P or Cmd+P to save as PDF)."
if lang == "es" {
message = "Exportación PDF - En Desarrollo"
body = "La función de exportación a PDF está siendo mejorada. Por favor, usa el botón <strong>Imprimir Amigable</strong> en su lugar (Ctrl+P o Cmd+P para guardar como PDF)."
2025-11-04 19:07:34 +00:00
}
2025-11-10 15:45:55 +00:00
w . Header ( ) . Set ( "Content-Type" , "text/html; charset=utf-8" )
w . WriteHeader ( http . StatusOK )
2025-11-04 19:07:34 +00:00
2025-11-10 15:45:55 +00:00
redirectMsg := "Redirecting in 5 seconds..."
if lang == "es" {
redirectMsg = "Redirigiendo en 5 segundos..."
2025-11-04 19:07:34 +00:00
}
2025-11-10 15:45:55 +00:00
html := fmt . Sprintf ( ` <!DOCTYPE html>
<html lang="%s">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>%s</title>
<style>
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
display: flex;
justify-content: center;
align-items: center;
min-height: 100vh;
margin: 0;
background: linear-gradient(135deg, #667eea 0%%, #764ba2 100%%);
}
.container {
background: white;
padding: 3rem;
border-radius: 12px;
box-shadow: 0 20px 60px rgba(0,0,0,0.3);
max-width: 500px;
text-align: center;
}
h1 {
color: #333;
margin: 0 0 1rem 0;
font-size: 1.8rem;
}
p {
color: #666;
line-height: 1.6;
margin: 1rem 0;
}
.redirect-info {
margin-top: 2rem;
padding: 1rem;
background: #f0f4ff;
border-radius: 8px;
font-size: 0.9rem;
color: #555;
}
.icon {
font-size: 3rem;
margin-bottom: 1rem;
}
</style>
<script>
setTimeout(function() {
window.location.href = '/?lang=%s';
}, 5000);
</script>
</head>
<body>
<div class="container">
<div class="icon">🚧</div>
<h1>%s</h1>
<p>%s</p>
<div class="redirect-info">
%s
</div>
</div>
</body>
</html> ` , lang , message , lang , message , body , redirectMsg )
2025-11-10 17:26:05 +00:00
if _ , err := w . Write ( [ ] byte ( html ) ) ; err != nil {
log . Printf ( "Error writing response: %v" , err )
}
2025-11-04 19:07:34 +00:00
}
// splitSkills splits skill categories between left (page 1) and right (page 2) sidebars
2025-11-08 15:05:54 +00:00
// Each category explicitly specifies which sidebar it belongs to via the "sidebar" field
2025-11-04 19:07:34 +00:00
func splitSkills ( skills [ ] models . SkillCategory ) ( left , right [ ] models . SkillCategory ) {
if len ( skills ) == 0 {
return nil , nil
}
2025-11-08 15:05:54 +00:00
// 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 )
}
2025-11-08 14:33:55 +00:00
}
2025-11-04 19:07:34 +00:00
return left , right
2025-10-20 08:54:21 +01:00
}
2025-11-06 09:11:17 +00:00
// 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
}
2025-11-06 10:36:00 +00:00
// 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
}
2025-11-09 02:43:40 +00:00
// 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
}
}
2025-11-11 13:53:14 +00:00
// 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
}
2025-11-09 02:43:40 +00:00
// getGitRepoFirstCommitDate fetches the first commit date from a git repository
// Supports local git repository paths
2025-11-11 13:53:14 +00:00
// Security: Validates path and uses timeout to prevent hanging
2025-11-09 02:43:40 +00:00
func getGitRepoFirstCommitDate ( repoPath string ) string {
2025-11-11 13:53:14 +00:00
// 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 )
2025-11-09 02:43:40 +00:00
return ""
}
2025-11-11 13:53:14 +00:00
// 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" )
2025-11-09 02:43:40 +00:00
output , err := cmd . Output ( )
if err != nil {
2025-11-11 13:53:14 +00:00
// Log error but don't expose details to prevent information disclosure
log . Printf ( "Git command failed for path %s: %v" , repoPath , err )
2025-11-09 02:43:40 +00:00
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 ""
}
2025-11-12 18:55:06 +00:00
// ==============================================================================
// 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
} )
}
2025-11-14 21:38:09 +00:00
// ToggleLength handles CV length toggle (short/long) using atomic out-of-band swaps
2025-11-12 18:55:06 +00:00
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" )
2025-11-14 21:38:09 +00:00
2025-11-12 18:55:06 +00:00
// 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" )
}
2025-11-14 21:38:09 +00:00
// Prepare template data with length state
cvLengthClass := "cv-short"
2025-11-12 18:55:06 +00:00
if newLength == "long" {
2025-11-14 21:38:09 +00:00
cvLengthClass = "cv-long"
2025-11-12 18:55:06 +00:00
}
2025-11-14 21:38:09 +00:00
data := map [ string ] interface { } {
"Lang" : lang ,
"CVLengthClass" : cvLengthClass ,
}
2025-11-12 18:55:06 +00:00
2025-11-14 21:38:09 +00:00
// Render length-toggle template with out-of-band swaps
tmpl , err := h . templates . Render ( "length-toggle.html" )
2025-11-12 18:55:06 +00:00
if err != nil {
2025-11-14 21:38:09 +00:00
HandleError ( w , r , TemplateError ( err , "length-toggle.html" ) )
2025-11-12 18:55:06 +00:00
return
}
w . Header ( ) . Set ( "Content-Type" , "text/html; charset=utf-8" )
if err := tmpl . Execute ( w , data ) ; err != nil {
2025-11-14 21:38:09 +00:00
HandleError ( w , r , TemplateError ( err , "length-toggle.html" ) )
2025-11-12 18:55:06 +00:00
return
}
}
2025-11-15 18:42:35 +00:00
// ToggleIcons handles icon visibility toggle using atomic out-of-band swaps
func ( h * CVHandler ) ToggleIcons ( w http . ResponseWriter , r * http . Request ) {
2025-11-12 18:55:06 +00:00
if r . Method != http . MethodPost {
http . Error ( w , "Method not allowed" , http . StatusMethodNotAllowed )
return
}
// Get current state
2025-11-15 18:42:35 +00:00
currentIcons := getPreferenceCookie ( r , "cv-icons" , "show" )
2025-11-14 21:38:09 +00:00
2025-11-12 18:55:06 +00:00
// Toggle state
2025-11-15 18:42:35 +00:00
newIcons := "hide"
if currentIcons == "hide" {
newIcons = "show"
2025-11-12 18:55:06 +00:00
}
// Save new state
2025-11-15 18:42:35 +00:00
setPreferenceCookie ( w , "cv-icons" , newIcons )
2025-11-12 18:55:06 +00:00
// Get language
lang := r . URL . Query ( ) . Get ( "lang" )
if lang == "" {
lang = getPreferenceCookie ( r , "cv-language" , "en" )
}
2025-11-14 21:38:09 +00:00
// Prepare template data with logo state
data := map [ string ] interface { } {
"Lang" : lang ,
2025-11-15 18:42:35 +00:00
"ShowIcons" : ( newIcons == "show" ) ,
2025-11-14 21:38:09 +00:00
}
// 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 )
2025-11-12 18:55:06 +00:00
// Prepare template data
data , err := h . prepareTemplateData ( lang )
if err != nil {
HandleError ( w , r , DataLoadError ( err , "CV" ) )
return
}
2025-11-14 21:38:09 +00:00
// Preserve current length and logo preferences
2025-11-12 18:55:06 +00:00
cvLength := getPreferenceCookie ( r , "cv-length" , "short" )
2025-11-15 18:42:35 +00:00
cvIcons := getPreferenceCookie ( r , "cv-icons" , "show" )
2025-11-14 21:38:09 +00:00
cvTheme := getPreferenceCookie ( r , "cv-theme" , "default" )
// Add preferences to data
2025-11-12 18:55:06 +00:00
if cvLength == "long" {
data [ "CVLengthClass" ] = "cv-long"
} else {
data [ "CVLengthClass" ] = "cv-short"
}
2025-11-15 18:42:35 +00:00
data [ "ShowIcons" ] = ( cvIcons == "show" )
2025-11-14 21:38:09 +00:00
data [ "ThemeClean" ] = ( cvTheme == "clean" )
2025-11-12 18:55:06 +00:00
2025-11-14 21:38:09 +00:00
// Render language-switch template with out-of-band swaps
tmpl , err := h . templates . Render ( "language-switch.html" )
2025-11-12 18:55:06 +00:00
if err != nil {
2025-11-14 21:38:09 +00:00
HandleError ( w , r , TemplateError ( err , "language-switch.html" ) )
2025-11-12 18:55:06 +00:00
return
}
w . Header ( ) . Set ( "Content-Type" , "text/html; charset=utf-8" )
if err := tmpl . Execute ( w , data ) ; err != nil {
2025-11-14 21:38:09 +00:00
HandleError ( w , r , TemplateError ( err , "language-switch.html" ) )
2025-11-12 18:55:06 +00:00
return
}
}
2025-11-14 21:38:09 +00:00
// ToggleTheme handles theme toggle (default/clean) using atomic out-of-band swaps
2025-11-12 18:55:06 +00:00
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" )
2025-11-14 21:38:09 +00:00
2025-11-12 18:55:06 +00:00
// 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" )
}
2025-11-14 21:38:09 +00:00
// Prepare template data with theme state
data := map [ string ] interface { } {
"Lang" : lang ,
"ThemeClean" : ( newTheme == "clean" ) ,
2025-11-12 18:55:06 +00:00
}
2025-11-14 21:38:09 +00:00
// Render theme-toggle template with out-of-band swaps
tmpl , err := h . templates . Render ( "theme-toggle.html" )
2025-11-12 18:55:06 +00:00
if err != nil {
2025-11-14 21:38:09 +00:00
HandleError ( w , r , TemplateError ( err , "theme-toggle.html" ) )
2025-11-12 18:55:06 +00:00
return
}
w . Header ( ) . Set ( "Content-Type" , "text/html; charset=utf-8" )
if err := tmpl . Execute ( w , data ) ; err != nil {
2025-11-14 21:38:09 +00:00
HandleError ( w , r , TemplateError ( err , "theme-toggle.html" ) )
2025-11-12 18:55:06 +00:00
return
}
}