From e2c4eafda2c47a718f7b37ccb0b5b4fb25c1d2b3 Mon Sep 17 00:00:00 2001 From: juanatsap Date: Tue, 4 Nov 2025 19:07:34 +0000 Subject: [PATCH] feat: implement proper two-page CV layout matching original React design **Page 1 Changes:** - Left sidebar with first half of skills categories - Main content: Personal info, Education, Skills summary, Experience - No footer on page 1 **Page 2 Changes:** - Main content: Awards, Courses, Languages, References, Other - Right sidebar with second half of skills categories - Footer with address, phone, email (dark gray #303030 background) - Same header as page 1 **Technical Implementation:** - Split skills between left/right sidebars using midpoint calculation in Go handler - CSS Grid layout: Page 1 (left sidebar + main), Page 2 (main + right sidebar) - Footer styled to match React CV exactly (centered, horizontal layout) - Print CSS with proper page breaks for A4 layout - Mobile responsive: stacks to single column, hides page 2 header - All links in References section are clickable **Data Model:** - Moved Languages, Courses, References, Other from sidebar to page 2 main content - Skills split evenly between SkillsLeft and SkillsRight template variables Matches pixel-perfect design from original React CV screenshot. --- internal/handlers/cv.go | 89 ++++++++-- static/css/main.css | 301 +++++++++++++++++++++++++++++++++ templates/cv-content.html | 347 +++++++++++++++++++++++--------------- 3 files changed, 585 insertions(+), 152 deletions(-) diff --git a/internal/handlers/cv.go b/internal/handlers/cv.go index ab0109a..11fb7c7 100644 --- a/internal/handlers/cv.go +++ b/internal/handlers/cv.go @@ -1,21 +1,29 @@ package handlers import ( + "fmt" + "log" "net/http" + "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 + templates *templates.Manager + pdfGenerator *pdf.Generator + serverAddr string } // NewCVHandler creates a new CV handler -func NewCVHandler(tmpl *templates.Manager) *CVHandler { +func NewCVHandler(tmpl *templates.Manager, serverAddr string) *CVHandler { return &CVHandler{ - templates: tmpl, + templates: tmpl, + pdfGenerator: pdf.NewGenerator(30 * time.Second), + serverAddr: serverAddr, } } @@ -40,10 +48,15 @@ func (h *CVHandler) Home(w http.ResponseWriter, r *http.Request) { return } + // Split skills between left and right sidebars + skillsLeft, skillsRight := splitSkills(cv.Skills.Technical) + // Prepare template data data := map[string]interface{}{ - "CV": cv, - "Lang": lang, + "CV": cv, + "Lang": lang, + "SkillsLeft": skillsLeft, + "SkillsRight": skillsRight, } // Render template @@ -81,10 +94,15 @@ func (h *CVHandler) CVContent(w http.ResponseWriter, r *http.Request) { return } + // Split skills between left and right sidebars + skillsLeft, skillsRight := splitSkills(cv.Skills.Technical) + // Prepare template data data := map[string]interface{}{ - "CV": cv, - "Lang": lang, + "CV": cv, + "Lang": lang, + "SkillsLeft": skillsLeft, + "SkillsRight": skillsRight, } // Render template @@ -101,9 +119,7 @@ func (h *CVHandler) CVContent(w http.ResponseWriter, r *http.Request) { } } -// ExportPDF handles PDF export requests -// For now, redirects to print-friendly version -// In production, integrate with chromedp or similar for actual PDF generation +// ExportPDF handles PDF export requests using chromedp func (h *CVHandler) ExportPDF(w http.ResponseWriter, r *http.Request) { // Get language from query parameter lang := r.URL.Query().Get("lang") @@ -111,7 +127,54 @@ func (h *CVHandler) ExportPDF(w http.ResponseWriter, r *http.Request) { lang = "en" } - // Redirect to print-friendly version - // The browser's print dialog will handle PDF generation - http.Redirect(w, r, "/?lang="+lang+"&print=true", http.StatusSeeOther) + // Validate language + if lang != "en" && lang != "es" { + HandleError(w, r, BadRequestError("Unsupported language. Use 'en' or 'es'")) + return + } + + // Construct URL to generate PDF from + // Use localhost instead of the actual server address to avoid network overhead + url := fmt.Sprintf("http://%s/?lang=%s", h.serverAddr, lang) + + log.Printf("Generating PDF from URL: %s", url) + + // Generate PDF + pdfData, err := h.pdfGenerator.GenerateFromURL(r.Context(), url) + if err != nil { + log.Printf("PDF generation failed: %v", err) + HandleError(w, r, InternalError(err)) + return + } + + // Set response headers + filename := fmt.Sprintf("CV-Juan-Andres-Moreno-Rubio-%s.pdf", lang) + 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("Failed to write PDF response: %v", err) + return + } + + log.Printf("Successfully generated PDF: %s (%d bytes)", filename, len(pdfData)) +} + +// splitSkills splits skill categories between left (page 1) and right (page 2) sidebars +// The split is done at the midpoint to evenly distribute skills +func splitSkills(skills []models.SkillCategory) (left, right []models.SkillCategory) { + if len(skills) == 0 { + return nil, nil + } + + // Calculate midpoint + mid := len(skills) / 2 + + // Split at midpoint + left = skills[:mid] + right = skills[mid:] + + return left, right } diff --git a/static/css/main.css b/static/css/main.css index 532e229..25157bd 100644 --- a/static/css/main.css +++ b/static/css/main.css @@ -728,3 +728,304 @@ a:focus { max-width: none; } } + +/* =============================================== + TWO-PAGE LAYOUT STYLES + =============================================== */ + +/* Page Container - Each CV page */ +.cv-page { + background: var(--paper-white); + max-width: 1200px; + margin: 2rem auto; + box-shadow: 2px 2px 9px rgba(0,0,0,0.5); + border: 1px solid #333; + transform: scale(0.95); + transform-origin: top center; + transition: transform 0.3s ease; +} + +/* Page Content Grid */ +.page-content { + display: grid; +} + +/* Page 1: Left sidebar + Main content */ +.page-1 .page-content { + grid-template-columns: 300px 1fr; +} + +/* Page 2: Main content + Right sidebar */ +.page-2 .page-content { + grid-template-columns: 1fr 300px; +} + +/* Sidebar positioning */ +.cv-sidebar-left { + grid-column: 1; + grid-row: 1; +} + +.cv-sidebar-right { + grid-column: 2; + grid-row: 1; +} + +/* Main content positioning */ +.page-1 .cv-main { + grid-column: 2; + grid-row: 1; +} + +.page-2 .cv-main { + grid-column: 1; + grid-row: 1; +} + +/* =============================================== + FOOTER STYLES + =============================================== */ + +.cv-footer { + background: #303030; + color: #ccc; + padding: 20px 0; + margin: 0; + grid-column: 1 / -1; /* Span all columns */ +} + +.footer-content { + list-style: none; + text-align: center; + margin: 0; + padding: 0; +} + +.footer-content li { + display: inline-block; + margin: 0; +} + +.footer-content li > div { + display: inline-block; + margin: 0 20px; + text-align: left; +} + +.footer-label { + width: 200px; + font-size: 1.7em; +} + +.footer-value { + width: 450px; + font-size: 1em; +} + +.footer-value b { + font-weight: normal; + font-size: 1.7em; +} + +.footer-separator { + position: relative; + left: -4%; + font-size: 0.6em; +} + +.footer-separator i { + opacity: 0.3; +} + +.cv-footer a { + color: inherit; +} + +.cv-footer a:hover { + color: #0275d8; + text-decoration: none; + font-weight: bold; +} + +/* =============================================== + PRINT STYLES - TWO-PAGE LAYOUT + =============================================== */ + +@media print { + body { + background: white; + margin: 0; + padding: 0; + } + + .action-bar { + display: none !important; + } + + .cv-page { + box-shadow: none; + border: none; + margin: 0 auto; + transform: scale(1); + max-width: 100%; + page-break-after: always; + page-break-inside: avoid; + } + + .cv-page.page-2 { + page-break-after: auto; + } + + .page-content { + page-break-inside: avoid; + } + + .cv-section { + page-break-inside: avoid; + } + + /* Ensure footer only on page 2 */ + .page-1 .cv-footer { + display: none !important; + } + + .cv-footer { + page-break-inside: avoid; + background: #ddd !important; + color: #333 !important; + -webkit-print-color-adjust: exact; + print-color-adjust: exact; + } + + a { + text-decoration: none; + font-weight: 800; + color: inherit; + } + + /* Set up proper A4 page dimensions */ + @page { + size: A4 portrait; + margin: 0.5in; + } +} + +/* =============================================== + SECTION STYLES FOR PAGE 2 + =============================================== */ + +.award-item, +.course-item, +.language-item, +.reference-item, +.other-content { + margin-bottom: 1em; +} + +.award-item strong, +.course-item strong, +.language-item strong { + font-weight: 600; + color: var(--text-dark); +} + +.award-item small, +.course-item small { + color: #666; + font-size: 0.875em; +} + +.award-desc, +.course-desc { + margin-top: 0.5em; + color: var(--text-gray); + font-size: 0.95em; +} + +.reference-item { + margin-bottom: 0.5em; +} + +.reference-item a { + font-weight: 500; +} + +.ref-type { + color: #999; + margin-left: 0.5em; + font-size: 0.875em; +} + +/* =============================================== + MOBILE RESPONSIVE - TWO-PAGE LAYOUT + =============================================== */ + +@media (max-width: 900px) { + .cv-page { + margin: 1rem; + transform: scale(1); + } + + /* Stack layout on mobile */ + .page-1 .page-content, + .page-2 .page-content { + grid-template-columns: 1fr; + } + + .cv-sidebar-left, + .cv-sidebar-right { + grid-column: 1; + } + + .page-1 .cv-main, + .page-2 .cv-main { + grid-column: 1; + } + + /* Hide header on page 2 for mobile to merge pages */ + .page-2 .cv-title-badges-header { + display: none; + } + + /* Adjust footer for mobile */ + .footer-content li > div { + display: block; + margin: 0; + text-align: center; + width: 100%; + } + + .footer-label { + font-size: 1em; + margin-top: 15px; + color: #777; + } + + .footer-separator { + display: none; + } + + .footer-value { + font-size: 1.5em; + margin-bottom: 0; + padding: 0; + } +} + +/* =============================================== + TABLET RESPONSIVE + =============================================== */ + +@media (max-width: 768px) and (min-width: 577px) { + .page-content { + gap: 1rem; + } + + .footer-label { + font-size: 1.2em; + } + + .footer-value { + font-size: 1em; + } +} diff --git a/templates/cv-content.html b/templates/cv-content.html index d93c778..dd46ee6 100644 --- a/templates/cv-content.html +++ b/templates/cv-content.html @@ -1,149 +1,218 @@ - -
- {{if eq .Lang "es"}}ANALISTA PROGRAMADOR{{else}}ANALYST PROGRAMMER{{end}} - | - NODEJS + REACTJS {{if eq .Lang "es"}}DESARROLLADOR{{else}}DEVELOPER{{end}} - | - WEB {{if eq .Lang "es"}}DESARROLLADOR{{else}}DEVELOPER{{end}} - | - GO {{if eq .Lang "es"}}DESARROLLADOR{{else}}DEVELOPER{{end}} - | - PHP {{if eq .Lang "es"}}DESARROLLADOR{{else}}DEVELOPER{{end}} -
- - - - - -
- -
-
-
-

{{.CV.Personal.Name}}

-

{{if eq .Lang "es"}}20 años de experiencia{{else}}20 years of experience{{end}}

- -
{{.CV.Summary}}
-
-
- {{.CV.Personal.Name}} -
-
+ +
+ +
+ {{if eq .Lang "es"}}ANALISTA PROGRAMADOR{{else}}ANALYST PROGRAMMER{{end}} + | + NODEJS + REACTJS {{if eq .Lang "es"}}DESARROLLADOR{{else}}DEVELOPER{{end}} + | + WEB {{if eq .Lang "es"}}DESARROLLADOR{{else}}DEVELOPER{{end}} + | + GO {{if eq .Lang "es"}}DESARROLLADOR{{else}}DEVELOPER{{end}} + | + PHP {{if eq .Lang "es"}}DESARROLLADOR{{else}}DEVELOPER{{end}}
- -
-

{{if eq .Lang "es"}}Formación{{else}}Training{{end}}

- {{range .CV.Education}} -
- {{.Degree}} ({{.StartDate}}-{{.EndDate}}) {{if eq $.Lang "es"}}obtenido de{{else}}obtained from the{{end}} {{.Institution}} ({{.Location}}) -
- {{end}} -
- - -
-

{{if eq .Lang "es"}}Competencias{{else}}Skills{{end}}

-

- {{if eq .Lang "es"}} - Amplio conocimiento en entornos web, tanto J2EE como PHP. Experto en tecnologías front-end, aunque con considerable experiencia en sistemas back-end. Receptivo al aprendizaje de nuevas tecnologías, y con una gran dosis de creatividad. Capacidad de analizar problemas y aportar soluciones específicas adaptadas a cada tipo de cliente. Me gusta trabajar tanto solo como en grupos. - {{else}} - Extensive knowledge in web environments, both J2EE and PHP. Expert in front-end technologies, although with considerable experience in back-end systems. Receptive to learning new technologies, and with a large dose of creativity. Ability to analyze problems and provide specific solutions tailored to each client type. I like to work both alone and in groups. + +

+ +
+ - -
-

{{if eq .Lang "es"}}Experiencia{{else}}Experience{{end}}

- - {{range .CV.Experience}} -
-
-
-

{{.Position}} / {{if eq $.Lang "es"}}Analista Programador{{else}}Analyst Programmer{{end}}

- {{.StartDate}} / {{if .Current}}{{if eq $.Lang "es"}}presente{{else}}now{{end}}{{else}}{{.EndDate}}{{end}} - ({{.Location}}) + +
+ +
+
+
+

{{.CV.Personal.Name}}

+

{{if eq .Lang "es"}}20 años de experiencia{{else}}20 years of experience{{end}}

+ +
{{.CV.Summary}}
+
+
+ {{.CV.Personal.Name}} +
- {{if .ShortDescription}} -

{{.ShortDescription}}

+ +
+

{{if eq .Lang "es"}}Formación{{else}}Training{{end}}

+ {{range .CV.Education}} +
+ {{.Degree}} ({{.StartDate}}-{{.EndDate}}) {{if eq $.Lang "es"}}obtenido de{{else}}obtained from the{{end}} {{.Institution}} ({{.Location}}) +
+ {{end}} +
+ + +
+

{{if eq .Lang "es"}}Competencias{{else}}Skills{{end}}

+

+ {{if eq .Lang "es"}} + Amplio conocimiento en entornos web, tanto J2EE como PHP. Experto en tecnologías front-end, aunque con considerable experiencia en sistemas back-end. Receptivo al aprendizaje de nuevas tecnologías, y con una gran dosis de creatividad. Capacidad de analizar problemas y aportar soluciones específicas adaptadas a cada tipo de cliente. Me gusta trabajar tanto solo como en grupos. + {{else}} + Extensive knowledge in web environments, both J2EE and PHP. Expert in front-end technologies, although with considerable experience in back-end systems. Receptive to learning new technologies, and with a large dose of creativity. Ability to analyze problems and provide specific solutions tailored to each client type. I like to work both alone and in groups. + {{end}} +

+
+ + +
+

{{if eq .Lang "es"}}Experiencia{{else}}Experience{{end}}

+ + {{range .CV.Experience}} +
+
+
+

{{.Position}} / {{if eq $.Lang "es"}}Analista Programador{{else}}Analyst Programmer{{end}}

+ {{.StartDate}} / {{if .Current}}{{if eq $.Lang "es"}}presente{{else}}now{{end}}{{else}}{{.EndDate}}{{end}} - ({{.Location}}) +
+
+ + {{if .ShortDescription}} +

{{.ShortDescription}}

+ {{end}} + +
+
    + {{range .Responsibilities}} +
  • {{.}}
  • + {{end}} +
+
+
+ {{end}} +
+
+
+
+ + +
+ +
+ {{if eq .Lang "es"}}ANALISTA PROGRAMADOR{{else}}ANALYST PROGRAMMER{{end}} + | + NODEJS + REACTJS {{if eq .Lang "es"}}DESARROLLADOR{{else}}DEVELOPER{{end}} + | + WEB {{if eq .Lang "es"}}DESARROLLADOR{{else}}DEVELOPER{{end}} + | + GO {{if eq .Lang "es"}}DESARROLLADOR{{else}}DEVELOPER{{end}} + | + PHP {{if eq .Lang "es"}}DESARROLLADOR{{else}}DEVELOPER{{end}} +
+ + +
+ +
+ + {{if .CV.Awards}} +
+

{{if eq .Lang "es"}}Premios y Reconocimientos{{else}}Awards{{end}}

+ {{range .CV.Awards}} +
+ {{.Title}}
+ {{.Issuer}} - {{.Date}} + {{if .Description}}

{{.Description}}

{{end}} +
+ {{end}} +
{{end}} -
-
    - {{range .Responsibilities}} -
  • {{.}}
  • - {{end}} -
-
-
- {{end}} -
+ + {{if .CV.Courses}} +
+

{{if eq .Lang "es"}}Cursos Realizados{{else}}Courses{{end}}

+ {{range .CV.Courses}} +
+ {{.Title}}
+ {{.Institution}} - {{.Location}}
+ {{.Date}} ({{.Duration}}) + {{if .Description}}

{{.Description}}

{{end}} +
+ {{end}} +
+ {{end}} -
+ +
+

{{if eq .Lang "es"}}Idiomas{{else}}Languages{{end}}

+ {{range .CV.Languages}} +
+ {{.Language}}: {{.Proficiency}} + {{if .Detail}}
{{.Detail}}{{end}} +
+ {{end}} +
+ + + {{if .CV.References}} +
+

{{if eq .Lang "es"}}Referencias{{else}}References{{end}}

+ {{range .CV.References}} +
+ {{.Title}} + ({{.Type}}) +
+ {{end}} +
+ {{end}} + + + {{if .CV.Other.DriverLicense}} +
+

{{if eq .Lang "es"}}Otros{{else}}Other{{end}}

+
+ {{if eq .Lang "es"}}Carnet de conducir {{.CV.Other.DriverLicense}}{{else}}Driver's License {{.CV.Other.DriverLicense}}{{end}} +
+
+ {{end}} + + + + + + + + +