a8d6805e27
This commit includes graphical keyboard icons integration, modal styling improvements, and comprehensive "Logos" to "Icons" terminology update. Changes: - Add graphical keyboard icons using Iconify MDI (Tab, Ctrl, Cmd, Esc, etc.) - Implement color scheme: black title, green subtitle/headers, blue kbd elements - Add visual boxes with borders and shadows for section grouping - Change modal from 3-column to 2-column grid layout (900px width) - Fix critical bug: all 5 sections now render (was only showing 2) Rename "Logos" to "Icons" across entire codebase: - Go models: ToggleLogos → ToggleIcons, ShowLogos → ShowIcons - Routes: /toggle/logos → /toggle/icons - Templates: desktop-logo-toggle → desktop-icon-toggle, #logoToggle → #iconToggle - JavaScript: logoToggles → iconToggles, sync logic updated - CSS: .show-logos → .show-icons - UI JSON: toggleLogos → toggleIcons - Comments and labels updated Technical details: - Rebuilt Go binary to fix template rendering error - Fixed JSON struct tag: json:"toggleLogos" → json:"toggleIcons" - Updated kbd element styling for icon alignment (inline-flex) - Added margin-bottom to subtitle (0.5rem) - Grid now 2 columns for better 5-section layout All 5 sections now render correctly: 1. Zoom Control 2. View Controls 3. Navigation 4. Actions 5. Browser Defaults
858 lines
23 KiB
Go
858 lines
23 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) {
|
|
// 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")
|
|
|
|
// 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
|
|
}
|
|
}
|
|
|
|
// ExportPDF handles PDF export requests using chromedp
|
|
// TEMPORARILY DISABLED - Work in progress
|
|
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"
|
|
}
|
|
|
|
// Validate language
|
|
if lang != "en" && lang != "es" {
|
|
HandleError(w, r, BadRequestError("Unsupported language. Use 'en' or 'es'"))
|
|
return
|
|
}
|
|
|
|
log.Printf("PDF export requested but temporarily disabled (redirecting to print friendly)")
|
|
|
|
// 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)."
|
|
}
|
|
|
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
|
w.WriteHeader(http.StatusOK)
|
|
|
|
redirectMsg := "Redirecting in 5 seconds..."
|
|
if lang == "es" {
|
|
redirectMsg = "Redirigiendo en 5 segundos..."
|
|
}
|
|
|
|
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)
|
|
|
|
if _, err := w.Write([]byte(html)); err != nil {
|
|
log.Printf("Error writing response: %v", err)
|
|
}
|
|
}
|
|
|
|
// 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")
|
|
|
|
// 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")
|
|
|
|
// 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
|
|
}
|
|
}
|