2025-10-20 08:54:21 +01:00
package handlers
import (
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"
"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-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-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-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 )
w . Write ( [ ] byte ( html ) )
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
}
}
// getGitRepoFirstCommitDate fetches the first commit date from a git repository
// Supports local git repository paths
func getGitRepoFirstCommitDate ( repoPath string ) string {
// Check if the path exists and is a directory
info , err := os . Stat ( repoPath )
if err != nil || ! info . IsDir ( ) {
return ""
}
// Execute git command to get the first commit date
// Format: YYYY-MM (to match StartDate format)
cmd := exec . Command ( "git" , "-C" , repoPath , "log" , "--reverse" , "--format=%ci" , "--date=format:%Y-%m" )
cmd . Dir = repoPath
output , err := cmd . Output ( )
if err != nil {
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 ""
}