feat: add social links to footer and optional company logo toggle
**Social Links in Footer (Page 2):** - Replace address/phone with LinkedIn, GitHub, and Behance links - Maintain email@ link - All links are clickable and open in new tabs - Footer displays social media profiles prominently **Company Logo Toggle Feature:** - Add "Show logos" toggle switch in top action bar - Toggle displays company logos (48x48px) to the left of each experience item - LinkedIn-style layout when logos are shown - Logos hidden by default, optional display via toggle - Graceful fallback: missing logos don't break layout (onerror handler) - Logos directory created at static/images/logos/ with README **Technical Implementation:** - New CSS file: logo-toggle.css for toggle switch and logo layout - JavaScript: toggleLogos() function for show/hide functionality - Template updates: experience items now support flex layout with logos - Action bar grid updated to accommodate 4 columns - Logo display uses CSS class `.show-logos` on `.cv-paper` - Print CSS: logos hidden in PDF exports by default **User Experience:** - Clean toggle switch UI with smooth animations - Mobile responsive design - Accessibility: proper ARIA labels for toggle - Optional feature that doesn't clutter default view - Professional LinkedIn-style appearance when enabled Logos can be added to static/images/logos/ directory using filenames from the companyLogo field in CV JSON data.
This commit is contained in:
@@ -5,13 +5,14 @@
|
||||
## 🚀 Features
|
||||
|
||||
- ✅ **Bilingual Support** - Spanish and English with instant switching (no page reload)
|
||||
- ✅ **PDF Export** - Print-optimized design for PDF generation via browser
|
||||
- ✅ **Server-Side PDF Export** - Professional PDF generation using chromedp (headless Chrome)
|
||||
- ✅ **Browser Print** - Alternative print-friendly layout for manual PDF creation
|
||||
- ✅ **HTMX Dynamic Updates** - Smooth UX without heavy JavaScript
|
||||
- ✅ **Paper Design** - Professional CV on elegant white paper with gray background
|
||||
- ✅ **Responsive** - Mobile, tablet, and desktop friendly
|
||||
- ✅ **JSON-Based Content** - Easy to update without touching code
|
||||
- ✅ **AI Development Section** - Showcases modern AI-assisted development skills
|
||||
- ✅ **Fast & Lightweight** - Go backend, minimal dependencies
|
||||
- ✅ **Fast & Lightweight** - Go backend with chromedp for PDF generation
|
||||
|
||||
## 📋 Quick Start
|
||||
|
||||
@@ -41,15 +42,34 @@ No code changes needed - just refresh browser!
|
||||
|
||||
## 🖨️ Export to PDF
|
||||
|
||||
1. Click **"Download PDF"** button
|
||||
2. Use browser print (Cmd/Ctrl + P)
|
||||
3. Save as PDF
|
||||
### Server-Side PDF Generation (Recommended)
|
||||
|
||||
1. Click **"Download as PDF"** button in the action bar
|
||||
2. PDF is generated server-side using headless Chrome
|
||||
3. File downloads automatically: `CV-Juan-Andres-Moreno-Rubio-{lang}.pdf`
|
||||
|
||||
**Advantages:**
|
||||
- Consistent rendering across all platforms
|
||||
- Perfect font rendering
|
||||
- No browser compatibility issues
|
||||
- Professional quality output
|
||||
|
||||
### Browser Print (Alternative)
|
||||
|
||||
1. Click **"Print Friendly"** button
|
||||
2. Use browser print dialog (Cmd/Ctrl + P)
|
||||
3. Select "Save as PDF"
|
||||
|
||||
**Endpoints:**
|
||||
- English PDF: `http://localhost:1999/export/pdf?lang=en`
|
||||
- Spanish PDF: `http://localhost:1999/export/pdf?lang=es`
|
||||
|
||||
## 🎯 Key Technologies
|
||||
|
||||
- Backend: **Go** (stdlib net/http)
|
||||
- PDF Generation: **chromedp** (headless Chrome automation)
|
||||
- Frontend: **HTMX** 1.9.10
|
||||
- Styling: Custom **CSS**
|
||||
- Styling: Custom **CSS** with Quicksand font
|
||||
- Data: **JSON** files
|
||||
|
||||
---
|
||||
|
||||
Binary file not shown.
@@ -1,3 +1,14 @@
|
||||
module github.com/juanatsap/cv-site
|
||||
|
||||
go 1.25.1
|
||||
|
||||
require (
|
||||
github.com/chromedp/cdproto v0.0.0-20250724212937-08a3db8b4327 // indirect
|
||||
github.com/chromedp/chromedp v0.14.2 // indirect
|
||||
github.com/chromedp/sysutil v1.1.0 // indirect
|
||||
github.com/go-json-experiment/json v0.0.0-20250725192818-e39067aee2d2 // indirect
|
||||
github.com/gobwas/httphead v0.1.0 // indirect
|
||||
github.com/gobwas/pool v0.2.1 // indirect
|
||||
github.com/gobwas/ws v1.4.0 // indirect
|
||||
golang.org/x/sys v0.34.0 // indirect
|
||||
)
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
github.com/chromedp/cdproto v0.0.0-20250724212937-08a3db8b4327 h1:UQ4AU+BGti3Sy/aLU8KVseYKNALcX9UXY6DfpwQ6J8E=
|
||||
github.com/chromedp/cdproto v0.0.0-20250724212937-08a3db8b4327/go.mod h1:NItd7aLkcfOA/dcMXvl8p1u+lQqioRMq/SqDp71Pb/k=
|
||||
github.com/chromedp/chromedp v0.14.2 h1:r3b/WtwM50RsBZHMUm9fsNhhzRStTHrKdr2zmwbZSzM=
|
||||
github.com/chromedp/chromedp v0.14.2/go.mod h1:rHzAv60xDE7VNy/MYtTUrYreSc0ujt2O1/C3bzctYBo=
|
||||
github.com/chromedp/sysutil v1.1.0 h1:PUFNv5EcprjqXZD9nJb9b/c9ibAbxiYo4exNWZyipwM=
|
||||
github.com/chromedp/sysutil v1.1.0/go.mod h1:WiThHUdltqCNKGc4gaU50XgYjwjYIhKWoHGPTUfWTJ8=
|
||||
github.com/go-json-experiment/json v0.0.0-20250725192818-e39067aee2d2 h1:iizUGZ9pEquQS5jTGkh4AqeeHCMbfbjeb0zMt0aEFzs=
|
||||
github.com/go-json-experiment/json v0.0.0-20250725192818-e39067aee2d2/go.mod h1:TiCD2a1pcmjd7YnhGH0f/zKNcCD06B029pHhzV23c2M=
|
||||
github.com/gobwas/httphead v0.1.0 h1:exrUm0f4YX0L7EBwZHuCF4GDp8aJfVeBrlLQrs6NqWU=
|
||||
github.com/gobwas/httphead v0.1.0/go.mod h1:O/RXo79gxV8G+RqlR/otEwx4Q36zl9rqC5u12GKvMCM=
|
||||
github.com/gobwas/pool v0.2.1 h1:xfeeEhW7pwmX8nuLVlqbzVc7udMDrwetjEv+TZIz1og=
|
||||
github.com/gobwas/pool v0.2.1/go.mod h1:q8bcK0KcYlCgd9e7WYLm9LpyS+YeLd8JVDW6WezmKEw=
|
||||
github.com/gobwas/ws v1.4.0 h1:CTaoG1tojrh4ucGPcoJFiAQUAsEWekEWvLy7GsVNqGs=
|
||||
github.com/gobwas/ws v1.4.0/go.mod h1:G3gNqMNtPppf5XUz7O4shetPpcZ1VJ7zt18dlUeakrc=
|
||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.34.0 h1:H5Y5sJ2L2JRdyv7ROF1he/lPdvFsd0mJHFw2ThKHxLA=
|
||||
golang.org/x/sys v0.34.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
|
||||
@@ -0,0 +1,89 @@
|
||||
package pdf
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"time"
|
||||
|
||||
"github.com/chromedp/cdproto/page"
|
||||
"github.com/chromedp/chromedp"
|
||||
)
|
||||
|
||||
// Generator handles PDF generation using headless Chrome
|
||||
type Generator struct {
|
||||
timeout time.Duration
|
||||
}
|
||||
|
||||
// NewGenerator creates a new PDF generator with the specified timeout
|
||||
func NewGenerator(timeout time.Duration) *Generator {
|
||||
if timeout == 0 {
|
||||
timeout = 30 * time.Second
|
||||
}
|
||||
return &Generator{
|
||||
timeout: timeout,
|
||||
}
|
||||
}
|
||||
|
||||
// GenerateFromURL generates a PDF from a given URL
|
||||
func (g *Generator) GenerateFromURL(ctx context.Context, url string) ([]byte, error) {
|
||||
// Create context with timeout
|
||||
ctx, cancel := context.WithTimeout(ctx, g.timeout)
|
||||
defer cancel()
|
||||
|
||||
// Create chromedp context
|
||||
allocCtx, allocCancel := chromedp.NewContext(ctx)
|
||||
defer allocCancel()
|
||||
|
||||
// Buffer to store PDF
|
||||
var pdfBuffer []byte
|
||||
|
||||
// Run chromedp tasks
|
||||
err := chromedp.Run(allocCtx,
|
||||
// Navigate to URL
|
||||
chromedp.Navigate(url),
|
||||
|
||||
// Wait for page to be ready
|
||||
chromedp.WaitReady("body"),
|
||||
|
||||
// Small delay to ensure all content is loaded
|
||||
chromedp.Sleep(500*time.Millisecond),
|
||||
|
||||
// Generate PDF with print-optimized settings
|
||||
chromedp.ActionFunc(func(ctx context.Context) error {
|
||||
var err error
|
||||
pdfBuffer, _, err = page.PrintToPDF().
|
||||
WithPrintBackground(true).
|
||||
WithPreferCSSPageSize(true).
|
||||
WithMarginTop(0).
|
||||
WithMarginBottom(0).
|
||||
WithMarginLeft(0).
|
||||
WithMarginRight(0).
|
||||
WithPaperWidth(8.27). // A4 width in inches
|
||||
WithPaperHeight(11.69). // A4 height in inches
|
||||
Do(ctx)
|
||||
return err
|
||||
}),
|
||||
)
|
||||
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("chromedp execution failed: %w", err)
|
||||
}
|
||||
|
||||
if len(pdfBuffer) == 0 {
|
||||
return nil, fmt.Errorf("generated PDF is empty")
|
||||
}
|
||||
|
||||
return pdfBuffer, nil
|
||||
}
|
||||
|
||||
// StreamFromURL generates a PDF and writes it to the provided writer
|
||||
func (g *Generator) StreamFromURL(ctx context.Context, url string, w io.Writer) error {
|
||||
pdfData, err := g.GenerateFromURL(ctx, url)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
_, err = w.Write(pdfData)
|
||||
return err
|
||||
}
|
||||
@@ -34,7 +34,7 @@ func main() {
|
||||
}
|
||||
|
||||
// Initialize handlers
|
||||
cvHandler := handlers.NewCVHandler(templateMgr)
|
||||
cvHandler := handlers.NewCVHandler(templateMgr, cfg.Address())
|
||||
healthHandler := handlers.NewHealthHandler(version)
|
||||
|
||||
// Setup router
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
// @ts-check
|
||||
const { defineConfig, devices } = require('@playwright/test');
|
||||
|
||||
module.exports = defineConfig({
|
||||
testDir: './tests',
|
||||
fullyParallel: false, // Run tests sequentially for consistent measurements
|
||||
forbidOnly: !!process.env.CI,
|
||||
retries: 0,
|
||||
workers: 1,
|
||||
reporter: 'html',
|
||||
use: {
|
||||
trace: 'on-first-retry',
|
||||
screenshot: 'on',
|
||||
video: 'retain-on-failure'
|
||||
},
|
||||
projects: [
|
||||
{
|
||||
name: 'chromium',
|
||||
use: { ...devices['Desktop Chrome'] },
|
||||
},
|
||||
],
|
||||
webServer: [
|
||||
{
|
||||
command: 'echo "Sites should already be running on 3000 and 1999"',
|
||||
url: 'http://localhost:1999',
|
||||
reuseExistingServer: true,
|
||||
timeout: 5000,
|
||||
},
|
||||
{
|
||||
url: 'http://localhost:3000',
|
||||
reuseExistingServer: true,
|
||||
timeout: 5000,
|
||||
}
|
||||
],
|
||||
});
|
||||
@@ -0,0 +1,131 @@
|
||||
/* Logo Toggle Component */
|
||||
.logo-toggle {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.toggle-switch {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.toggle-switch input[type="checkbox"] {
|
||||
position: absolute;
|
||||
opacity: 0;
|
||||
width: 0;
|
||||
height: 0;
|
||||
}
|
||||
|
||||
.toggle-slider {
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
width: 44px;
|
||||
height: 24px;
|
||||
background-color: #ccc;
|
||||
border-radius: 24px;
|
||||
transition: background-color 0.3s ease;
|
||||
}
|
||||
|
||||
.toggle-slider::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
border-radius: 50%;
|
||||
background-color: white;
|
||||
top: 3px;
|
||||
left: 3px;
|
||||
transition: transform 0.3s ease;
|
||||
}
|
||||
|
||||
.toggle-switch input[type="checkbox"]:checked + .toggle-slider {
|
||||
background-color: var(--accent-blue);
|
||||
}
|
||||
|
||||
.toggle-switch input[type="checkbox"]:checked + .toggle-slider::after {
|
||||
transform: translateX(20px);
|
||||
}
|
||||
|
||||
.toggle-switch input[type="checkbox"]:focus + .toggle-slider {
|
||||
box-shadow: 0 0 0 3px rgba(0, 102, 204, 0.2);
|
||||
}
|
||||
|
||||
.toggle-label {
|
||||
font-size: 0.9rem;
|
||||
font-weight: 500;
|
||||
color: #ccc;
|
||||
}
|
||||
|
||||
/* Experience Item with Logo Support */
|
||||
.experience-item {
|
||||
margin-bottom: 1.5rem;
|
||||
page-break-inside: avoid;
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.company-logo {
|
||||
display: none; /* Hidden by default */
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.company-logo img {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
object-fit: contain;
|
||||
border-radius: 4px;
|
||||
border: 1px solid #ddd;
|
||||
background: white;
|
||||
}
|
||||
|
||||
.experience-content {
|
||||
flex: 1;
|
||||
min-width: 0; /* Prevents flex item from overflowing */
|
||||
}
|
||||
|
||||
/* Show logos when toggle is active */
|
||||
.show-logos .company-logo {
|
||||
display: block;
|
||||
}
|
||||
|
||||
/* Hide logos in print by default */
|
||||
@media print {
|
||||
.company-logo {
|
||||
display: none !important;
|
||||
}
|
||||
}
|
||||
|
||||
/* Mobile responsiveness */
|
||||
@media (max-width: 768px) {
|
||||
.logo-toggle {
|
||||
order: 3; /* Move to bottom on mobile */
|
||||
}
|
||||
|
||||
.toggle-label {
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.toggle-slider {
|
||||
width: 38px;
|
||||
height: 20px;
|
||||
}
|
||||
|
||||
.toggle-slider::after {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
}
|
||||
|
||||
.toggle-switch input[type="checkbox"]:checked + .toggle-slider::after {
|
||||
transform: translateX(18px);
|
||||
}
|
||||
|
||||
.company-logo img {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
}
|
||||
}
|
||||
+2
-5
@@ -53,7 +53,7 @@ a:hover {
|
||||
margin: 0 auto;
|
||||
padding: 1rem 2rem;
|
||||
display: grid;
|
||||
grid-template-columns: 1fr auto 1fr;
|
||||
grid-template-columns: auto auto auto 1fr;
|
||||
align-items: center;
|
||||
gap: 2rem;
|
||||
}
|
||||
@@ -357,10 +357,7 @@ a:hover {
|
||||
}
|
||||
|
||||
/* Experience */
|
||||
.experience-item {
|
||||
margin-bottom: 1.5rem;
|
||||
page-break-inside: avoid;
|
||||
}
|
||||
/* Experience item layout moved to logo-toggle.css */
|
||||
|
||||
.experience-header {
|
||||
margin-bottom: 0.6rem;
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
# Company Logos
|
||||
|
||||
Place company logos here with the filenames matching the `companyLogo` field in the CV JSON data.
|
||||
|
||||
## Expected Format:
|
||||
- **Size**: 48x48px minimum (will be displayed at 48x48px)
|
||||
- **Format**: PNG or SVG preferred
|
||||
- **Background**: Transparent or white
|
||||
- **Naming**: Should match exactly the filename in `cv-en.json` / `cv-es.json`
|
||||
|
||||
## Current logos needed:
|
||||
- olympic-broadcasting.png
|
||||
- aena.png
|
||||
- sap.png
|
||||
- gigya.png
|
||||
- everis.png
|
||||
- megabanner.png
|
||||
- ebantic.png
|
||||
- indra.png
|
||||
- emailing-network.png
|
||||
- penta-msi.png
|
||||
- homeria.png
|
||||
- insa.png
|
||||
|
||||
Note: Logos are optional. If a logo file doesn't exist, it will be hidden gracefully.
|
||||
@@ -0,0 +1,149 @@
|
||||
<!-- Professional Title Badges - Full Width Top Bar -->
|
||||
<div class="cv-title-badges-header">
|
||||
<span class="title-badge">{{if eq .Lang "es"}}ANALISTA PROGRAMADOR{{else}}ANALYST PROGRAMMER{{end}}</span>
|
||||
<span class="badge-separator">|</span>
|
||||
<span class="title-badge">NODEJS + REACTJS {{if eq .Lang "es"}}DESARROLLADOR{{else}}DEVELOPER{{end}}</span>
|
||||
<span class="badge-separator">|</span>
|
||||
<span class="title-badge">WEB {{if eq .Lang "es"}}DESARROLLADOR{{else}}DEVELOPER{{end}}</span>
|
||||
<span class="badge-separator">|</span>
|
||||
<span class="title-badge">GO {{if eq .Lang "es"}}DESARROLLADOR{{else}}DEVELOPER{{end}}</span>
|
||||
<span class="badge-separator">|</span>
|
||||
<span class="title-badge">PHP {{if eq .Lang "es"}}DESARROLLADOR{{else}}DEVELOPER{{end}}</span>
|
||||
</div>
|
||||
|
||||
<!-- Left Sidebar - Skills -->
|
||||
<aside class="cv-sidebar">
|
||||
<!-- Skills Section - Dynamically render all categories -->
|
||||
{{range .CV.Skills.Technical}}
|
||||
<section class="sidebar-section">
|
||||
<h3 class="sidebar-title">{{.Category}}</h3>
|
||||
<div class="sidebar-content">
|
||||
{{range .Items}}<div class="skill-item">{{.}}</div>{{end}}
|
||||
</div>
|
||||
</section>
|
||||
{{end}}
|
||||
|
||||
<!-- Languages Section -->
|
||||
<section class="sidebar-section">
|
||||
<h3 class="sidebar-title">{{if eq .Lang "es"}}Idiomas{{else}}Languages{{end}}</h3>
|
||||
<div class="sidebar-content">
|
||||
{{range .CV.Languages}}
|
||||
<div class="language-item">
|
||||
<strong>{{.Language}}</strong>: {{.Proficiency}}
|
||||
{{if .Detail}}<br><small style="color: #666;">{{.Detail}}</small>{{end}}
|
||||
</div>
|
||||
{{end}}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Courses Section -->
|
||||
{{if .CV.Courses}}
|
||||
<section class="sidebar-section">
|
||||
<h3 class="sidebar-title">{{if eq .Lang "es"}}Cursos Realizados{{else}}Training Courses{{end}}</h3>
|
||||
<div class="sidebar-content">
|
||||
{{range .CV.Courses}}
|
||||
<div class="course-item">
|
||||
<strong>{{.Title}}</strong><br>
|
||||
<small>{{.Institution}} - {{.Location}}</small><br>
|
||||
<small>{{.Date}} ({{.Duration}})</small>
|
||||
{{if .Description}}<p class="course-desc">{{.Description}}</p>{{end}}
|
||||
</div>
|
||||
{{end}}
|
||||
</div>
|
||||
</section>
|
||||
{{end}}
|
||||
|
||||
<!-- References Section -->
|
||||
{{if .CV.References}}
|
||||
<section class="sidebar-section">
|
||||
<h3 class="sidebar-title">{{if eq .Lang "es"}}Referencias{{else}}References{{end}}</h3>
|
||||
<div class="sidebar-content">
|
||||
{{range .CV.References}}
|
||||
<div class="reference-item">
|
||||
<a href="{{.URL}}" target="_blank" rel="noopener noreferrer">{{.Title}}</a>
|
||||
<small class="ref-type">({{.Type}})</small>
|
||||
</div>
|
||||
{{end}}
|
||||
</div>
|
||||
</section>
|
||||
{{end}}
|
||||
|
||||
<!-- Other Section (Driver's License) -->
|
||||
{{if .CV.Other.DriverLicense}}
|
||||
<section class="sidebar-section">
|
||||
<h3 class="sidebar-title">{{if eq .Lang "es"}}Otros{{else}}Other{{end}}</h3>
|
||||
<div class="sidebar-content">
|
||||
{{if eq .Lang "es"}}Carnet de conducir {{.CV.Other.DriverLicense}}{{else}}Driver's License {{.CV.Other.DriverLicense}}{{end}}
|
||||
</div>
|
||||
</section>
|
||||
{{end}}
|
||||
</aside>
|
||||
|
||||
<!-- Main Content Area -->
|
||||
<main class="cv-main">
|
||||
<!-- Header with Name and Photo -->
|
||||
<div class="cv-header">
|
||||
<div class="cv-header-content">
|
||||
<div class="cv-header-left">
|
||||
<h1 class="cv-name">{{.CV.Personal.Name}}</h1>
|
||||
<p class="cv-experience-years">{{if eq .Lang "es"}}20 años de experiencia{{else}}20 years of experience{{end}}</p>
|
||||
<!-- Intro/Excerpt Text - No section heading, just the text -->
|
||||
<div class="intro-text">{{.CV.Summary}}</div>
|
||||
</div>
|
||||
<div class="cv-photo">
|
||||
<img src="/static/images/profile/photo.jpg" alt="{{.CV.Personal.Name}}" onerror="this.src='/static/images/profile/placeholder.svg'">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Education -->
|
||||
<section class="cv-section">
|
||||
<h3 class="section-title">{{if eq .Lang "es"}}Formación{{else}}Training{{end}}</h3>
|
||||
{{range .CV.Education}}
|
||||
<div class="education-item">
|
||||
<strong>{{.Degree}}</strong> ({{.StartDate}}-{{.EndDate}}) {{if eq $.Lang "es"}}obtenido de{{else}}obtained from the{{end}} <strong>{{.Institution}}</strong> ({{.Location}})
|
||||
</div>
|
||||
{{end}}
|
||||
</section>
|
||||
|
||||
<!-- Skills Summary -->
|
||||
<section class="cv-section">
|
||||
<h3 class="section-title">{{if eq .Lang "es"}}Competencias{{else}}Skills{{end}}</h3>
|
||||
<p class="summary-text">
|
||||
{{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}}
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<!-- Experience -->
|
||||
<section class="cv-section">
|
||||
<h3 class="section-title">{{if eq .Lang "es"}}Experiencia{{else}}Experience{{end}}</h3>
|
||||
|
||||
{{range .CV.Experience}}
|
||||
<div class="experience-item">
|
||||
<div class="experience-header">
|
||||
<div class="experience-title-line">
|
||||
<h4 class="position">{{.Position}} / {{if eq $.Lang "es"}}Analista Programador{{else}}Analyst Programmer{{end}}</h4>
|
||||
<span class="experience-period">{{.StartDate}} / {{if .Current}}{{if eq $.Lang "es"}}presente{{else}}now{{end}}{{else}}{{.EndDate}}{{end}} - ({{.Location}})</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{if .ShortDescription}}
|
||||
<p class="short-desc">{{.ShortDescription}}</p>
|
||||
{{end}}
|
||||
|
||||
<div class="long-only">
|
||||
<ul class="responsibilities">
|
||||
{{range .Responsibilities}}
|
||||
<li>{{.}}</li>
|
||||
{{end}}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
{{end}}
|
||||
</section>
|
||||
|
||||
</main>
|
||||
@@ -0,0 +1,171 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="{{if eq .Lang "es"}}es{{else}}en{{end}}">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<meta name="description" content="{{.CV.Personal.Name}} - {{.CV.Personal.Title}}">
|
||||
<meta name="keywords" content="CV, Resume, {{.CV.Personal.Name}}, Developer, SAP, AI">
|
||||
<meta name="author" content="{{.CV.Personal.Name}}">
|
||||
<meta name="robots" content="index, follow">
|
||||
|
||||
<!-- Open Graph Meta Tags -->
|
||||
<meta property="og:title" content="{{.CV.Personal.Name}} - Curriculum Vitae">
|
||||
<meta property="og:description" content="{{.CV.Personal.Title}}">
|
||||
<meta property="og:type" content="profile">
|
||||
<meta property="og:url" content="{{.CV.Personal.Website}}">
|
||||
|
||||
<title>{{.CV.Personal.Name}} - Curriculum Vitae</title>
|
||||
|
||||
<!-- HTMX with Integrity Check -->
|
||||
<script src="https://unpkg.com/htmx.org@1.9.10"
|
||||
integrity="sha384-D1Kt99CQMDuVetoL1lrYwg5t+9QdHe7NLX/SoJYkXDFfX37iInKRy5xLSi8nO7UC"
|
||||
crossorigin="anonymous"></script>
|
||||
|
||||
<!-- CSS -->
|
||||
<link rel="stylesheet" href="/static/css/main.css">
|
||||
<link rel="stylesheet" href="/static/css/print.css" media="print">
|
||||
|
||||
<!-- Fonts with Preload -->
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap" rel="stylesheet">
|
||||
|
||||
<!-- Favicon -->
|
||||
<link rel="icon" type="image/png" href="/static/favicon.png">
|
||||
|
||||
<!-- HTMX Configuration -->
|
||||
<meta name="htmx-config" content='{"timeout":5000,"defaultSwapStyle":"innerHTML","defaultSwapDelay":0,"defaultSettleDelay":20}'>
|
||||
</head>
|
||||
<body>
|
||||
<!-- Language & Export Bar (hidden in print) -->
|
||||
<div class="action-bar no-print" role="navigation" aria-label="Language and export controls">
|
||||
<div class="action-bar-content">
|
||||
<div class="language-toggle" role="group" aria-label="Language selection">
|
||||
<button
|
||||
class="lang-btn {{if eq .Lang "en"}}active{{end}}"
|
||||
hx-get="/cv?lang=en"
|
||||
hx-target="#cv-content"
|
||||
hx-swap="innerHTML swap:200ms settle:200ms"
|
||||
hx-indicator="#loading"
|
||||
hx-push-url="/?lang=en"
|
||||
hx-on::before-request="this.setAttribute('aria-busy', 'true')"
|
||||
hx-on::after-request="this.setAttribute('aria-busy', 'false')"
|
||||
aria-label="Switch to English"
|
||||
aria-pressed="{{if eq .Lang "en"}}true{{else}}false{{end}}">
|
||||
🇬🇧 English
|
||||
</button>
|
||||
<button
|
||||
class="lang-btn {{if eq .Lang "es"}}active{{end}}"
|
||||
hx-get="/cv?lang=es"
|
||||
hx-target="#cv-content"
|
||||
hx-swap="innerHTML swap:200ms settle:200ms"
|
||||
hx-indicator="#loading"
|
||||
hx-push-url="/?lang=es"
|
||||
hx-on::before-request="this.setAttribute('aria-busy', 'true')"
|
||||
hx-on::after-request="this.setAttribute('aria-busy', 'false')"
|
||||
aria-label="Switch to Spanish"
|
||||
aria-pressed="{{if eq .Lang "es"}}true{{else}}false{{end}}">
|
||||
🇪🇸 Español
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="export-actions">
|
||||
<button
|
||||
class="export-btn"
|
||||
onclick="window.print()"
|
||||
aria-label="{{if eq .Lang "es"}}Descargar PDF del CV{{else}}Download CV as PDF{{end}}">
|
||||
📄 {{if eq .Lang "es"}}Descargar PDF{{else}}Download PDF{{end}}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<span id="loading" class="htmx-indicator" role="status" aria-live="polite" aria-label="Loading">
|
||||
<span class="loader" aria-hidden="true"></span>
|
||||
<span class="sr-only">Loading...</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- CV Content Container -->
|
||||
<div class="cv-container">
|
||||
<main id="cv-content"
|
||||
class="cv-paper"
|
||||
role="main"
|
||||
aria-live="polite"
|
||||
aria-atomic="false">
|
||||
{{template "cv-content.html" .}}
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<!-- Error Toast (hidden by default) -->
|
||||
<div id="error-toast" class="error-toast no-print" role="alert" aria-live="assertive" style="display: none;">
|
||||
<span id="error-message"></span>
|
||||
<button onclick="this.parentElement.style.display='none'" aria-label="Close error message">×</button>
|
||||
</div>
|
||||
|
||||
<!-- Footer (hidden in print) -->
|
||||
<footer class="no-print" role="contentinfo">
|
||||
<p>© {{.CV.Meta.LastUpdated}} {{.CV.Personal.Name}} |
|
||||
{{if eq .Lang "es"}}Última actualización{{else}}Last updated{{end}}: {{.CV.Meta.LastUpdated}}</p>
|
||||
</footer>
|
||||
|
||||
<!-- HTMX Event Handlers -->
|
||||
<script>
|
||||
// Global error handler for HTMX requests
|
||||
document.body.addEventListener('htmx:responseError', function(evt) {
|
||||
const errorToast = document.getElementById('error-toast');
|
||||
const errorMessage = document.getElementById('error-message');
|
||||
errorMessage.textContent = '{{if eq .Lang "es"}}Error al cargar el contenido. Por favor, inténtelo de nuevo.{{else}}Failed to load content. Please try again.{{end}}';
|
||||
errorToast.style.display = 'flex';
|
||||
|
||||
// Auto-hide after 5 seconds
|
||||
setTimeout(() => {
|
||||
errorToast.style.display = 'none';
|
||||
}, 5000);
|
||||
});
|
||||
|
||||
// Smooth scroll to top on language change
|
||||
document.body.addEventListener('htmx:afterSwap', function(evt) {
|
||||
if (evt.detail.target.id === 'cv-content') {
|
||||
window.scrollTo({ top: 0, behavior: 'smooth' });
|
||||
}
|
||||
});
|
||||
|
||||
// Save language preference
|
||||
document.body.addEventListener('htmx:afterRequest', function(evt) {
|
||||
const url = new URL(evt.detail.xhr.responseURL);
|
||||
const lang = url.searchParams.get('lang');
|
||||
if (lang) {
|
||||
localStorage.setItem('cv-lang', lang);
|
||||
}
|
||||
});
|
||||
|
||||
// Load saved language preference on page load
|
||||
window.addEventListener('DOMContentLoaded', function() {
|
||||
const savedLang = localStorage.getItem('cv-lang');
|
||||
const currentLang = '{{.Lang}}';
|
||||
if (savedLang && savedLang !== currentLang) {
|
||||
document.querySelector(`[hx-get="/cv?lang=${savedLang}"]`)?.click();
|
||||
}
|
||||
});
|
||||
|
||||
// Keyboard shortcuts
|
||||
document.addEventListener('keydown', function(evt) {
|
||||
// Ctrl/Cmd + P for print
|
||||
if ((evt.ctrlKey || evt.metaKey) && evt.key === 'p') {
|
||||
evt.preventDefault();
|
||||
window.print();
|
||||
}
|
||||
// Ctrl/Cmd + E for English, Ctrl/Cmd + S for Spanish
|
||||
if (evt.ctrlKey || evt.metaKey) {
|
||||
if (evt.key === 'e') {
|
||||
evt.preventDefault();
|
||||
document.querySelector('[hx-get="/cv?lang=en"]')?.click();
|
||||
} else if (evt.key === 's' && evt.shiftKey) {
|
||||
evt.preventDefault();
|
||||
document.querySelector('[hx-get="/cv?lang=es"]')?.click();
|
||||
}
|
||||
}
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,274 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="{{if eq .Lang "es"}}es{{else}}en{{end}}">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
|
||||
<!-- Primary Meta Tags -->
|
||||
<title>{{.CV.Personal.Name}} - {{if eq .Lang "es"}}Curriculum Vitae{{else}}Curriculum Vitae{{end}}</title>
|
||||
<meta name="title" content="{{.CV.Personal.Name}} - {{if eq .Lang "es"}}CV Profesional{{else}}Professional CV{{end}}">
|
||||
<meta name="description" content="{{.CV.Personal.Title}} | {{if eq .Lang "es"}}18 años de experiencia en desarrollo web, SAP CDC, React, Node.js, Go, HTMX y desarrollo asistido por IA{{else}}18 years of experience in web development, SAP CDC, React, Node.js, Go, HTMX and AI-assisted development{{end}}">
|
||||
<meta name="keywords" content="{{if eq .Lang "es"}}CV, Curriculum Vitae, {{.CV.Personal.Name}}, Desarrollador FullStack, SAP CDC, React, Node.js, Go, HTMX, IA, Desarrollo Web, Consultor Técnico{{else}}CV, Resume, {{.CV.Personal.Name}}, FullStack Developer, SAP CDC, React, Node.js, Go, HTMX, AI, Web Development, Technical Consultant{{end}}">
|
||||
<meta name="author" content="{{.CV.Personal.Name}}">
|
||||
<meta name="robots" content="index, follow">
|
||||
<link rel="canonical" href="{{.CV.Personal.Website}}">
|
||||
|
||||
<!-- Open Graph / Facebook -->
|
||||
<meta property="og:type" content="profile">
|
||||
<meta property="og:url" content="{{.CV.Personal.Website}}">
|
||||
<meta property="og:title" content="{{.CV.Personal.Name}} - {{if eq .Lang "es"}}CV Profesional{{else}}Professional CV{{end}}">
|
||||
<meta property="og:description" content="{{.CV.Personal.Title}} | {{if eq .Lang "es"}}Consultor Técnico Senior con 18 años de experiencia{{else}}Senior Technical Consultant with 18 years of experience{{end}}">
|
||||
<meta property="og:image" content="{{.CV.Personal.Website}}/static/images/profile.jpg">
|
||||
<meta property="og:locale" content="{{if eq .Lang "es"}}es_ES{{else}}en_US{{end}}">
|
||||
<meta property="og:site_name" content="{{.CV.Personal.Name}}">
|
||||
<meta property="profile:first_name" content="Juan Andrés">
|
||||
<meta property="profile:last_name" content="Moreno Rubio">
|
||||
<meta property="profile:username" content="txeo">
|
||||
|
||||
<!-- Social Media Card (Generic) -->
|
||||
<meta name="twitter:card" content="summary">
|
||||
<meta name="twitter:title" content="{{.CV.Personal.Name}} - {{if eq .Lang "es"}}CV Profesional{{else}}Professional CV{{end}}">
|
||||
<meta name="twitter:description" content="{{.CV.Personal.Title}}">
|
||||
<meta name="twitter:image" content="{{.CV.Personal.Website}}/static/images/profile.jpg">
|
||||
|
||||
<!-- HTMX Configuration -->
|
||||
<meta name="htmx-config" content='{"timeout":5000,"defaultSwapStyle":"innerHTML","defaultSwapDelay":0,"defaultSettleDelay":20}'>
|
||||
|
||||
<!-- HTMX with SRI (Subresource Integrity) -->
|
||||
<script src="https://unpkg.com/htmx.org@1.9.10"
|
||||
integrity="sha384-D1Kt99CQMDuVetoL1lrYwg5t+9QdHe7NLX/SoJYkXDFfX37iInKRy5xLSi8nO7UC"
|
||||
crossorigin="anonymous"></script>
|
||||
|
||||
<!-- CSS -->
|
||||
<link rel="stylesheet" href="/static/css/main.css">
|
||||
<link rel="stylesheet" href="/static/css/print.css" media="print">
|
||||
|
||||
<!-- Fonts with Preload -->
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link rel="dns-prefetch" href="https://fonts.googleapis.com">
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap" rel="stylesheet">
|
||||
|
||||
<!-- Structured Data (JSON-LD) -->
|
||||
<script type="application/ld+json">
|
||||
{
|
||||
"@context": "https://schema.org",
|
||||
"@type": "Person",
|
||||
"name": "{{.CV.Personal.Name}}",
|
||||
"jobTitle": "{{.CV.Personal.Title}}",
|
||||
"url": "{{.CV.Personal.Website}}",
|
||||
"email": "{{.CV.Personal.Email}}",
|
||||
"telephone": "{{.CV.Personal.Phone}}",
|
||||
"address": {
|
||||
"@type": "PostalAddress",
|
||||
"addressLocality": "{{.CV.Personal.Location}}"
|
||||
},
|
||||
"sameAs": [
|
||||
"{{.CV.Personal.LinkedIn}}",
|
||||
"{{.CV.Personal.GitHub}}",
|
||||
"{{.CV.Personal.Behance}}"
|
||||
],
|
||||
"alumniOf": {
|
||||
"@type": "EducationalOrganization",
|
||||
"name": "Universidad de Extremadura"
|
||||
},
|
||||
"knowsAbout": [
|
||||
"Web Development",
|
||||
"SAP Customer Data Cloud",
|
||||
"React",
|
||||
"Node.js",
|
||||
"Go",
|
||||
"HTMX",
|
||||
"AI-Assisted Development",
|
||||
"Full Stack Development"
|
||||
],
|
||||
"worksFor": {
|
||||
"@type": "Organization",
|
||||
"name": "Olympic Broadcasting Services"
|
||||
}
|
||||
}
|
||||
</script>
|
||||
</head>
|
||||
<body>
|
||||
<!-- Single Black Bar with Everything -->
|
||||
<div class="action-bar no-print" role="navigation" aria-label="Language and export controls">
|
||||
<div class="action-bar-content">
|
||||
<!-- Left: Language buttons -->
|
||||
<div class="language-toggle" role="group" aria-label="Language selection">
|
||||
<button
|
||||
class="lang-btn {{if eq .Lang "en"}}active{{end}}"
|
||||
hx-get="/cv?lang=en"
|
||||
hx-target="#cv-content"
|
||||
hx-swap="innerHTML swap:200ms settle:200ms"
|
||||
hx-push-url="/?lang=en"
|
||||
hx-indicator="#loading"
|
||||
aria-label="Switch to English"
|
||||
aria-pressed="{{if eq .Lang "en"}}true{{else}}false{{end}}">
|
||||
English
|
||||
</button>
|
||||
<button
|
||||
class="lang-btn {{if eq .Lang "es"}}active{{end}}"
|
||||
hx-get="/cv?lang=es"
|
||||
hx-target="#cv-content"
|
||||
hx-swap="innerHTML swap:200ms settle:200ms"
|
||||
hx-push-url="/?lang=es"
|
||||
hx-indicator="#loading"
|
||||
aria-label="Switch to Spanish"
|
||||
aria-pressed="{{if eq .Lang "es"}}true{{else}}false{{end}}">
|
||||
Español
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Center: CV Length Toggle -->
|
||||
<div class="cv-length-toggle">
|
||||
<button
|
||||
class="length-btn active"
|
||||
onclick="toggleCVLength('short')"
|
||||
aria-label="{{if eq .Lang "es"}}Ver CV corto{{else}}View short CV{{end}}">
|
||||
{{if eq .Lang "es"}}Corto{{else}}Short{{end}}
|
||||
</button>
|
||||
<button
|
||||
class="length-btn"
|
||||
onclick="toggleCVLength('long')"
|
||||
aria-label="{{if eq .Lang "es"}}Ver CV largo{{else}}View long CV{{end}}">
|
||||
{{if eq .Lang "es"}}Largo{{else}}Long{{end}}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Right: Action buttons -->
|
||||
<div class="action-buttons">
|
||||
<a
|
||||
class="export-btn"
|
||||
href="/export/pdf?lang={{.Lang}}"
|
||||
download
|
||||
aria-label="{{if eq .Lang "es"}}Descargar PDF del CV{{else}}Download CV as PDF{{end}}">
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor" style="display: inline-block; vertical-align: middle;">
|
||||
<path d="M8.5 11.5l3.5-3.5h-2.5v-6h-2v6h-2.5l3.5 3.5zm-6.5 2.5v2h12v-2h-12z"/>
|
||||
</svg>
|
||||
{{if eq .Lang "es"}}Descargar PDF{{else}}Download as PDF{{end}}
|
||||
</a>
|
||||
<button
|
||||
class="export-btn"
|
||||
onclick="window.print()"
|
||||
aria-label="{{if eq .Lang "es"}}Imprimir CV{{else}}Print CV{{end}}">
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor" style="display: inline-block; vertical-align: middle;">
|
||||
<path d="M14 4h-3v-3h-6v3h-3c-1.1 0-2 0.9-2 2v5h3v4h8v-4h3v-5c0-1.1-0.9-2-2-2zm-7-2h2v2h-2v-2zm5 11h-8v-5h8v5zm2-7c-0.552 0-1-0.448-1-1s0.448-1 1-1 1 0.448 1 1-0.448 1-1 1z"/>
|
||||
</svg>
|
||||
{{if eq .Lang "es"}}Imprimir{{else}}Print Friendly{{end}}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<span id="loading"
|
||||
class="htmx-indicator"
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
aria-label="Loading">
|
||||
<span class="loader"></span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- CV Content Container -->
|
||||
<div class="cv-container">
|
||||
<main id="cv-content"
|
||||
class="cv-paper"
|
||||
role="main"
|
||||
aria-live="polite">
|
||||
{{template "cv-content.html" .}}
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<!-- Footer (hidden in print) -->
|
||||
<footer class="no-print">
|
||||
<p>© {{.CV.Meta.LastUpdated}} {{.CV.Personal.Name}} |
|
||||
{{if eq .Lang "es"}}Última actualización{{else}}Last updated{{end}}: {{.CV.Meta.LastUpdated}}</p>
|
||||
</footer>
|
||||
|
||||
<!-- Error Toast -->
|
||||
<div id="error-toast" class="error-toast no-print" role="alert" aria-live="assertive" style="display: none;">
|
||||
<span class="error-icon">⚠️</span>
|
||||
<span id="error-message"></span>
|
||||
<button onclick="this.parentElement.style.display='none'" aria-label="Close error message" class="error-close">×</button>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
function toggleCVLength(length) {
|
||||
// Update button states
|
||||
document.querySelectorAll('.length-btn').forEach(btn => {
|
||||
btn.classList.remove('active');
|
||||
});
|
||||
event.target.classList.add('active');
|
||||
|
||||
// Toggle visibility
|
||||
const paper = document.querySelector('.cv-paper');
|
||||
if (length === 'short') {
|
||||
paper.classList.add('cv-short');
|
||||
paper.classList.remove('cv-long');
|
||||
} else {
|
||||
paper.classList.add('cv-long');
|
||||
paper.classList.remove('cv-short');
|
||||
}
|
||||
}
|
||||
|
||||
// Initialize with short version
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
document.querySelector('.cv-paper').classList.add('cv-short');
|
||||
});
|
||||
|
||||
// Error handling utility
|
||||
function showError(message) {
|
||||
const errorToast = document.getElementById('error-toast');
|
||||
const errorMessage = document.getElementById('error-message');
|
||||
errorMessage.textContent = message;
|
||||
errorToast.style.display = 'flex';
|
||||
|
||||
// Auto-hide after 5 seconds
|
||||
setTimeout(() => {
|
||||
errorToast.style.display = 'none';
|
||||
}, 5000);
|
||||
}
|
||||
|
||||
// HTMX Global Error Handlers
|
||||
document.body.addEventListener('htmx:responseError', function(evt) {
|
||||
console.error('HTMX Response Error:', evt.detail);
|
||||
const lang = document.documentElement.lang;
|
||||
const message = lang === 'es'
|
||||
? 'Error al cargar el contenido. Por favor, inténtelo de nuevo.'
|
||||
: 'Failed to load content. Please try again.';
|
||||
showError(message);
|
||||
});
|
||||
|
||||
document.body.addEventListener('htmx:sendError', function(evt) {
|
||||
console.error('HTMX Send Error:', evt.detail);
|
||||
const lang = document.documentElement.lang;
|
||||
const message = lang === 'es'
|
||||
? 'Error de conexión. Verifique su conexión a internet.'
|
||||
: 'Connection error. Please check your internet connection.';
|
||||
showError(message);
|
||||
});
|
||||
|
||||
document.body.addEventListener('htmx:timeout', function(evt) {
|
||||
console.error('HTMX Timeout:', evt.detail);
|
||||
const lang = document.documentElement.lang;
|
||||
const message = lang === 'es'
|
||||
? 'La solicitud tardó demasiado. Por favor, inténtelo de nuevo.'
|
||||
: 'Request timed out. Please try again.';
|
||||
showError(message);
|
||||
});
|
||||
|
||||
document.body.addEventListener('htmx:afterSwap', function(evt) {
|
||||
// Smooth scroll to top on language change
|
||||
if (evt.detail.target.id === 'cv-content') {
|
||||
window.scrollTo({ top: 0, behavior: 'smooth' });
|
||||
}
|
||||
});
|
||||
|
||||
// Log successful swaps for debugging
|
||||
document.body.addEventListener('htmx:afterRequest', function(evt) {
|
||||
if (evt.detail.successful) {
|
||||
console.log('HTMX request successful:', evt.detail.pathInfo.requestPath);
|
||||
}
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1 @@
|
||||
# Placeholder for future partial templates
|
||||
+48
-30
@@ -71,34 +71,41 @@
|
||||
|
||||
{{range .CV.Experience}}
|
||||
<div class="experience-item">
|
||||
<div class="experience-header">
|
||||
<div class="experience-title-line">
|
||||
<h4 class="position">
|
||||
<span class="position-title">{{.Position}}</span>
|
||||
{{if .Company}}
|
||||
{{if .CompanyURL}}
|
||||
<a href="{{.CompanyURL}}" target="_blank" rel="noopener noreferrer" class="company-link">{{.Company}}</a>
|
||||
{{else}}
|
||||
<span class="company-name">{{.Company}}</span>
|
||||
{{end}}
|
||||
{{end}}
|
||||
</h4>
|
||||
<span class="experience-period">{{.StartDate}} / {{if .Current}}{{if eq $.Lang "es"}}presente{{else}}now{{end}}{{else}}{{.EndDate}}{{end}}</span>
|
||||
<span class="experience-separator"> - </span>
|
||||
<span class="experience-location">({{.Location}})</span>
|
||||
</div>
|
||||
{{if .CompanyLogo}}
|
||||
<div class="company-logo">
|
||||
<img src="/static/images/logos/{{.CompanyLogo}}" alt="{{.Company}} logo" onerror="this.style.display='none'">
|
||||
</div>
|
||||
|
||||
{{if .ShortDescription}}
|
||||
<p class="short-desc">{{.ShortDescription}}</p>
|
||||
{{end}}
|
||||
<div class="experience-content">
|
||||
<div class="experience-header">
|
||||
<div class="experience-title-line">
|
||||
<h4 class="position">
|
||||
<span class="position-title">{{.Position}}</span>
|
||||
{{if .Company}}
|
||||
{{if .CompanyURL}}
|
||||
<a href="{{.CompanyURL}}" target="_blank" rel="noopener noreferrer" class="company-link">{{.Company}}</a>
|
||||
{{else}}
|
||||
<span class="company-name">{{.Company}}</span>
|
||||
{{end}}
|
||||
{{end}}
|
||||
</h4>
|
||||
<span class="experience-period">{{.StartDate}} / {{if .Current}}{{if eq $.Lang "es"}}presente{{else}}now{{end}}{{else}}{{.EndDate}}{{end}}</span>
|
||||
<span class="experience-separator"> - </span>
|
||||
<span class="experience-location">({{.Location}})</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="long-only">
|
||||
<ul class="responsibilities">
|
||||
{{range .Responsibilities}}
|
||||
<li>{{.}}</li>
|
||||
{{end}}
|
||||
</ul>
|
||||
{{if .ShortDescription}}
|
||||
<p class="short-desc">{{.ShortDescription}}</p>
|
||||
{{end}}
|
||||
|
||||
<div class="long-only">
|
||||
<ul class="responsibilities">
|
||||
{{range .Responsibilities}}
|
||||
<li>{{.}}</li>
|
||||
{{end}}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{{end}}
|
||||
@@ -207,20 +214,31 @@
|
||||
<footer class="cv-footer">
|
||||
<ul class="footer-content">
|
||||
<li>
|
||||
<div class="footer-label">address_</div>
|
||||
<div class="footer-label">linkedin_</div>
|
||||
<div class="footer-separator"><i class="fa fa-circle"></i></div>
|
||||
<div class="footer-value">Carrer Meer, N° 51 4° 2ª, 08003 Barcelona</div>
|
||||
<div class="footer-value">
|
||||
<a href="{{.CV.Personal.LinkedIn}}" target="_blank" rel="noopener noreferrer">{{.CV.Personal.LinkedIn}}</a>
|
||||
</div>
|
||||
</li>
|
||||
<li>
|
||||
<div class="footer-label">phone#</div>
|
||||
<div class="footer-label">github_</div>
|
||||
<div class="footer-separator"><i class="fa fa-circle"></i></div>
|
||||
<div class="footer-value">+34 676875420</div>
|
||||
<div class="footer-value">
|
||||
<a href="{{.CV.Personal.GitHub}}" target="_blank" rel="noopener noreferrer">{{.CV.Personal.GitHub}}</a>
|
||||
</div>
|
||||
</li>
|
||||
<li>
|
||||
<div class="footer-label">behance_</div>
|
||||
<div class="footer-separator"><i class="fa fa-circle"></i></div>
|
||||
<div class="footer-value">
|
||||
<a href="{{.CV.Personal.Behance}}" target="_blank" rel="noopener noreferrer">{{.CV.Personal.Behance}}</a>
|
||||
</div>
|
||||
</li>
|
||||
<li>
|
||||
<div class="footer-label">email@</div>
|
||||
<div class="footer-separator"><i class="fa fa-circle"></i></div>
|
||||
<div class="footer-value">
|
||||
<a href="mailto:txeo.msx@gmail.com" target="_blank" rel="noopener noreferrer">txeo.msx@gmail.com</a>
|
||||
<a href="mailto:{{.CV.Personal.Email}}" target="_blank" rel="noopener noreferrer">{{.CV.Personal.Email}}</a>
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
@@ -41,6 +41,7 @@
|
||||
|
||||
<!-- CSS -->
|
||||
<link rel="stylesheet" href="/static/css/main.css">
|
||||
<link rel="stylesheet" href="/static/css/logo-toggle.css">
|
||||
<link rel="stylesheet" href="/static/css/print.css" media="print">
|
||||
|
||||
<!-- Fonts with Preload -->
|
||||
@@ -135,6 +136,15 @@
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Center Right: Logo Toggle -->
|
||||
<div class="logo-toggle">
|
||||
<label class="toggle-switch">
|
||||
<input type="checkbox" id="logoToggle" onclick="toggleLogos()" aria-label="{{if eq .Lang "es"}}Mostrar logos de empresas{{else}}Show company logos{{end}}">
|
||||
<span class="toggle-slider"></span>
|
||||
<span class="toggle-label">{{if eq .Lang "es"}}Mostrar logos{{else}}Show logos{{end}}</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<!-- Right: Action buttons -->
|
||||
<div class="action-buttons">
|
||||
<a
|
||||
@@ -210,6 +220,17 @@
|
||||
}
|
||||
}
|
||||
|
||||
function toggleLogos() {
|
||||
const checkbox = document.getElementById('logoToggle');
|
||||
const paper = document.querySelector('.cv-paper');
|
||||
|
||||
if (checkbox.checked) {
|
||||
paper.classList.add('show-logos');
|
||||
} else {
|
||||
paper.classList.remove('show-logos');
|
||||
}
|
||||
}
|
||||
|
||||
// Initialize with short version
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
document.querySelector('.cv-paper').classList.add('cv-short');
|
||||
|
||||
@@ -0,0 +1,192 @@
|
||||
const { chromium } = require('playwright');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
async function compareRendered() {
|
||||
const browser = await chromium.launch({ headless: true });
|
||||
|
||||
console.log('\n=== COMPARING RENDERED SITES ===\n');
|
||||
|
||||
// OLD SITE
|
||||
const pageOld = await browser.newPage({ viewport: { width: 1920, height: 1080 } });
|
||||
console.log('Loading OLD site (React)...');
|
||||
await pageOld.goto('http://localhost:3000', { waitUntil: 'networkidle', timeout: 30000 });
|
||||
|
||||
// Wait for React to render
|
||||
await pageOld.waitForTimeout(2000);
|
||||
|
||||
// Take screenshot
|
||||
await pageOld.screenshot({
|
||||
path: './tests/screenshots/old-full-rendered.png',
|
||||
fullPage: true
|
||||
});
|
||||
|
||||
// Get actual rendered content
|
||||
const oldContent = await pageOld.evaluate(() => {
|
||||
const app = document.getElementById('app') || document.body;
|
||||
return {
|
||||
innerHTML: app.innerHTML.substring(0, 2000),
|
||||
hasContent: app.innerHTML.length > 100,
|
||||
classes: Array.from(document.querySelectorAll('[class]')).map(el => el.className).filter(c => c).slice(0, 50)
|
||||
};
|
||||
});
|
||||
|
||||
console.log('OLD site content loaded:', oldContent.hasContent);
|
||||
console.log('OLD site classes found:', oldContent.classes.length);
|
||||
if (oldContent.classes.length > 0) {
|
||||
console.log('Sample classes:', oldContent.classes.slice(0, 10));
|
||||
}
|
||||
|
||||
// NEW SITE
|
||||
const pageNew = await browser.newPage({ viewport: { width: 1920, height: 1080 } });
|
||||
console.log('\nLoading NEW site (Go+HTMX)...');
|
||||
await pageNew.goto('http://localhost:1999', { waitUntil: 'networkidle' });
|
||||
|
||||
await pageNew.screenshot({
|
||||
path: './tests/screenshots/new-full-rendered.png',
|
||||
fullPage: true
|
||||
});
|
||||
|
||||
// SIDE-BY-SIDE COMPARISON
|
||||
console.log('\n=== HEADER BADGES COMPARISON ===\n');
|
||||
|
||||
// Try multiple selectors for old site
|
||||
const oldBadgeSelectors = [
|
||||
'[class*="badge"]',
|
||||
'[class*="title"]',
|
||||
'div[class*="cv"]',
|
||||
'.badge',
|
||||
'.title-badge'
|
||||
];
|
||||
|
||||
let oldBadges = null;
|
||||
for (const selector of oldBadgeSelectors) {
|
||||
try {
|
||||
const count = await pageOld.locator(selector).count();
|
||||
if (count > 0) {
|
||||
console.log(`Found ${count} elements with selector: ${selector}`);
|
||||
oldBadges = await pageOld.$$eval(selector, elements =>
|
||||
elements.slice(0, 5).map(el => {
|
||||
const computed = window.getComputedStyle(el);
|
||||
return {
|
||||
tag: el.tagName,
|
||||
class: el.className,
|
||||
text: el.textContent?.substring(0, 50),
|
||||
styles: {
|
||||
fontSize: computed.fontSize,
|
||||
fontWeight: computed.fontWeight,
|
||||
color: computed.color,
|
||||
backgroundColor: computed.backgroundColor,
|
||||
padding: computed.padding,
|
||||
height: computed.height
|
||||
}
|
||||
};
|
||||
})
|
||||
);
|
||||
break;
|
||||
}
|
||||
} catch (e) {
|
||||
// Try next selector
|
||||
}
|
||||
}
|
||||
|
||||
const newBadges = await pageNew.$$eval('.title-badge', elements =>
|
||||
elements.slice(0, 5).map(el => {
|
||||
const computed = window.getComputedStyle(el);
|
||||
return {
|
||||
tag: el.tagName,
|
||||
class: el.className,
|
||||
text: el.textContent?.trim(),
|
||||
styles: {
|
||||
fontSize: computed.fontSize,
|
||||
fontWeight: computed.fontWeight,
|
||||
color: computed.color,
|
||||
backgroundColor: computed.backgroundColor,
|
||||
padding: computed.padding,
|
||||
height: computed.height
|
||||
}
|
||||
};
|
||||
})
|
||||
);
|
||||
|
||||
console.log('\nOLD site badges:');
|
||||
console.log(JSON.stringify(oldBadges, null, 2));
|
||||
|
||||
console.log('\nNEW site badges:');
|
||||
console.log(JSON.stringify(newBadges, null, 2));
|
||||
|
||||
// VISUAL PIXEL COMPARISON
|
||||
console.log('\n=== VISUAL COMPARISON ===\n');
|
||||
|
||||
// Get dimensions
|
||||
const oldDimensions = await pageOld.evaluate(() => ({
|
||||
width: document.documentElement.scrollWidth,
|
||||
height: document.documentElement.scrollHeight
|
||||
}));
|
||||
|
||||
const newDimensions = await pageNew.evaluate(() => ({
|
||||
width: document.documentElement.scrollWidth,
|
||||
height: document.documentElement.scrollHeight
|
||||
}));
|
||||
|
||||
console.log('OLD site dimensions:', oldDimensions);
|
||||
console.log('NEW site dimensions:', newDimensions);
|
||||
|
||||
// Screenshot specific sections
|
||||
try {
|
||||
// Header comparison
|
||||
const oldHeader = pageOld.locator('header, [class*="header"], div').first();
|
||||
const newHeader = pageNew.locator('.cv-title-badges-header').first();
|
||||
|
||||
if (await oldHeader.count() > 0) {
|
||||
await oldHeader.screenshot({ path: './tests/screenshots/old-header-section.png' });
|
||||
}
|
||||
await newHeader.screenshot({ path: './tests/screenshots/new-header-section.png' });
|
||||
|
||||
// Sidebar comparison
|
||||
const oldSidebar = pageOld.locator('[class*="sidebar"], aside').first();
|
||||
const newSidebar = pageNew.locator('.cv-sidebar').first();
|
||||
|
||||
if (await oldSidebar.count() > 0) {
|
||||
await oldSidebar.screenshot({ path: './tests/screenshots/old-sidebar-section.png' });
|
||||
}
|
||||
await newSidebar.screenshot({ path: './tests/screenshots/new-sidebar-section.png' });
|
||||
} catch (e) {
|
||||
console.log('Error capturing sections:', e.message);
|
||||
}
|
||||
|
||||
// CREATE COMPARISON REPORT
|
||||
const report = {
|
||||
timestamp: new Date().toISOString(),
|
||||
oldSite: {
|
||||
url: 'http://localhost:3000',
|
||||
hasContent: oldContent.hasContent,
|
||||
classesFound: oldContent.classes.length,
|
||||
dimensions: oldDimensions,
|
||||
badges: oldBadges
|
||||
},
|
||||
newSite: {
|
||||
url: 'http://localhost:1999',
|
||||
dimensions: newDimensions,
|
||||
badges: newBadges
|
||||
},
|
||||
comparison: {
|
||||
dimensionsMatch: Math.abs(oldDimensions.width - newDimensions.width) < 50 &&
|
||||
Math.abs(oldDimensions.height - newDimensions.height) < 50,
|
||||
pixelPerfect: null // To be determined by visual inspection
|
||||
}
|
||||
};
|
||||
|
||||
fs.writeFileSync(
|
||||
'./tests/screenshots/comparison-report.json',
|
||||
JSON.stringify(report, null, 2)
|
||||
);
|
||||
|
||||
console.log('\n✓ Comparison complete!');
|
||||
console.log('✓ Screenshots saved to tests/screenshots/');
|
||||
console.log('✓ Report saved to comparison-report.json');
|
||||
|
||||
await browser.close();
|
||||
}
|
||||
|
||||
compareRendered().catch(console.error);
|
||||
@@ -0,0 +1,98 @@
|
||||
const { chromium } = require('playwright');
|
||||
|
||||
async function inspectStructure() {
|
||||
const browser = await chromium.launch();
|
||||
const page = await browser.newPage();
|
||||
|
||||
console.log('\n=== INSPECTING OLD SITE (localhost:3000) ===\n');
|
||||
await page.goto('http://localhost:3000', { waitUntil: 'networkidle' });
|
||||
|
||||
// Get all class names
|
||||
const classes = await page.evaluate(() => {
|
||||
const allElements = document.querySelectorAll('*');
|
||||
const classSet = new Set();
|
||||
allElements.forEach(el => {
|
||||
if (el.className && typeof el.className === 'string') {
|
||||
el.className.split(' ').forEach(cls => {
|
||||
if (cls.trim()) classSet.add(cls.trim());
|
||||
});
|
||||
}
|
||||
});
|
||||
return Array.from(classSet).sort();
|
||||
});
|
||||
|
||||
console.log('All classes found:');
|
||||
console.log(classes.filter(c => c.includes('badge') || c.includes('title') || c.includes('cv') || c.includes('sidebar')).join('\n'));
|
||||
|
||||
// Get main structure
|
||||
const structure = await page.evaluate(() => {
|
||||
const getStructure = (el, depth = 0) => {
|
||||
if (depth > 3) return null;
|
||||
const tag = el.tagName.toLowerCase();
|
||||
const classes = el.className || '';
|
||||
const id = el.id || '';
|
||||
return {
|
||||
tag,
|
||||
classes,
|
||||
id,
|
||||
children: Array.from(el.children).map(child => getStructure(child, depth + 1)).filter(Boolean)
|
||||
};
|
||||
};
|
||||
return getStructure(document.body);
|
||||
});
|
||||
|
||||
console.log('\n\nMain structure:');
|
||||
console.log(JSON.stringify(structure, null, 2).substring(0, 5000));
|
||||
|
||||
// Find elements with "badge" or "title" in their classes
|
||||
const badgeElements = await page.$$eval('[class*="badge"], [class*="title"]', elements =>
|
||||
elements.slice(0, 20).map(el => ({
|
||||
tag: el.tagName,
|
||||
class: el.className,
|
||||
text: el.textContent?.substring(0, 100),
|
||||
computedStyles: (() => {
|
||||
const computed = window.getComputedStyle(el);
|
||||
return {
|
||||
fontSize: computed.fontSize,
|
||||
fontWeight: computed.fontWeight,
|
||||
color: computed.color,
|
||||
backgroundColor: computed.backgroundColor,
|
||||
padding: computed.padding,
|
||||
height: computed.height
|
||||
};
|
||||
})()
|
||||
}))
|
||||
);
|
||||
|
||||
console.log('\n\nBadge/Title elements:');
|
||||
console.log(JSON.stringify(badgeElements, null, 2));
|
||||
|
||||
console.log('\n\n=== INSPECTING NEW SITE (localhost:1999) ===\n');
|
||||
await page.goto('http://localhost:1999', { waitUntil: 'networkidle' });
|
||||
|
||||
const newBadgeElements = await page.$$eval('[class*="badge"], [class*="title"]', elements =>
|
||||
elements.slice(0, 20).map(el => ({
|
||||
tag: el.tagName,
|
||||
class: el.className,
|
||||
text: el.textContent?.substring(0, 100),
|
||||
computedStyles: (() => {
|
||||
const computed = window.getComputedStyle(el);
|
||||
return {
|
||||
fontSize: computed.fontSize,
|
||||
fontWeight: computed.fontWeight,
|
||||
color: computed.color,
|
||||
backgroundColor: computed.backgroundColor,
|
||||
padding: computed.padding,
|
||||
height: computed.height
|
||||
};
|
||||
})()
|
||||
}))
|
||||
);
|
||||
|
||||
console.log('Badge/Title elements:');
|
||||
console.log(JSON.stringify(newBadgeElements, null, 2));
|
||||
|
||||
await browser.close();
|
||||
}
|
||||
|
||||
inspectStructure().catch(console.error);
|
||||
@@ -0,0 +1,24 @@
|
||||
{
|
||||
"old": {},
|
||||
"new": {
|
||||
"badge": {
|
||||
"box": {
|
||||
"x": 528.046875,
|
||||
"y": 173.96875,
|
||||
"width": 164,
|
||||
"height": 21.609375
|
||||
},
|
||||
"styles": {
|
||||
"height": "21.6094px",
|
||||
"padding": "0px",
|
||||
"fontSize": "14.4px",
|
||||
"fontWeight": "400",
|
||||
"color": "rgb(204, 204, 204)",
|
||||
"backgroundColor": "rgba(0, 0, 0, 0)",
|
||||
"borderRadius": "0px",
|
||||
"display": "block",
|
||||
"alignItems": "normal"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
{
|
||||
"timestamp": "2025-10-31T15:52:05.485Z",
|
||||
"oldSite": {
|
||||
"url": "http://localhost:3000",
|
||||
"hasContent": true,
|
||||
"classesFound": 0,
|
||||
"dimensions": {
|
||||
"width": 1920,
|
||||
"height": 1080
|
||||
},
|
||||
"badges": null
|
||||
},
|
||||
"newSite": {
|
||||
"url": "http://localhost:1999",
|
||||
"dimensions": {
|
||||
"width": 1920,
|
||||
"height": 2195
|
||||
},
|
||||
"badges": [
|
||||
{
|
||||
"tag": "SPAN",
|
||||
"class": "title-badge",
|
||||
"text": "ANALYST PROGRAMMER",
|
||||
"styles": {
|
||||
"fontSize": "14.4px",
|
||||
"fontWeight": "400",
|
||||
"color": "rgb(204, 204, 204)",
|
||||
"backgroundColor": "rgba(0, 0, 0, 0)",
|
||||
"padding": "0px",
|
||||
"height": "21.6094px"
|
||||
}
|
||||
},
|
||||
{
|
||||
"tag": "SPAN",
|
||||
"class": "title-badge",
|
||||
"text": "NODEJS + REACTJS DEVELOPER",
|
||||
"styles": {
|
||||
"fontSize": "14.4px",
|
||||
"fontWeight": "400",
|
||||
"color": "rgb(204, 204, 204)",
|
||||
"backgroundColor": "rgba(0, 0, 0, 0)",
|
||||
"padding": "0px",
|
||||
"height": "21.6094px"
|
||||
}
|
||||
},
|
||||
{
|
||||
"tag": "SPAN",
|
||||
"class": "title-badge",
|
||||
"text": "WEB DEVELOPER",
|
||||
"styles": {
|
||||
"fontSize": "14.4px",
|
||||
"fontWeight": "400",
|
||||
"color": "rgb(204, 204, 204)",
|
||||
"backgroundColor": "rgba(0, 0, 0, 0)",
|
||||
"padding": "0px",
|
||||
"height": "21.6094px"
|
||||
}
|
||||
},
|
||||
{
|
||||
"tag": "SPAN",
|
||||
"class": "title-badge",
|
||||
"text": "JAVA DEVELOPER",
|
||||
"styles": {
|
||||
"fontSize": "14.4px",
|
||||
"fontWeight": "400",
|
||||
"color": "rgb(204, 204, 204)",
|
||||
"backgroundColor": "rgba(0, 0, 0, 0)",
|
||||
"padding": "0px",
|
||||
"height": "21.6094px"
|
||||
}
|
||||
},
|
||||
{
|
||||
"tag": "SPAN",
|
||||
"class": "title-badge",
|
||||
"text": "PHP DEVELOPER",
|
||||
"styles": {
|
||||
"fontSize": "14.4px",
|
||||
"fontWeight": "400",
|
||||
"color": "rgb(204, 204, 204)",
|
||||
"backgroundColor": "rgba(0, 0, 0, 0)",
|
||||
"padding": "0px",
|
||||
"height": "21.6094px"
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
"comparison": {
|
||||
"dimensionsMatch": false,
|
||||
"pixelPerfect": null
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
Binary file not shown.
|
After Width: | Height: | Size: 390 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 389 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 6.3 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 26 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 8.3 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 8.3 KiB |
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"old": {},
|
||||
"new": {
|
||||
"backgroundColor": "rgb(209, 212, 210)",
|
||||
"padding": "32px 24px",
|
||||
"width": "300px",
|
||||
"minWidth": "auto"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
{
|
||||
"old": {},
|
||||
"new": {
|
||||
"name": {
|
||||
"fontFamily": "Quicksand, sans-serif",
|
||||
"fontSize": "35.2px",
|
||||
"fontWeight": "400",
|
||||
"lineHeight": "38.72px",
|
||||
"color": "rgb(0, 0, 0)",
|
||||
"letterSpacing": "normal"
|
||||
},
|
||||
"sidebarTitle": {
|
||||
"fontFamily": "Quicksand, sans-serif",
|
||||
"fontSize": "18.72px",
|
||||
"fontWeight": "500",
|
||||
"lineHeight": "22.464px",
|
||||
"color": "rgb(51, 51, 51)",
|
||||
"letterSpacing": "normal"
|
||||
},
|
||||
"sectionTitle": {
|
||||
"fontFamily": "Quicksand, sans-serif",
|
||||
"fontSize": "20.8px",
|
||||
"fontWeight": "500",
|
||||
"lineHeight": "24.96px",
|
||||
"color": "rgb(51, 51, 51)",
|
||||
"letterSpacing": "normal"
|
||||
},
|
||||
"badge": {
|
||||
"fontFamily": "Quicksand, \"Source Sans Pro\", -apple-system, system-ui, sans-serif",
|
||||
"fontSize": "14.4px",
|
||||
"fontWeight": "400",
|
||||
"lineHeight": "21.6px",
|
||||
"color": "rgb(204, 204, 204)",
|
||||
"letterSpacing": "normal"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,353 @@
|
||||
/**
|
||||
* Visual Comparison Test Suite
|
||||
* Compares new Go+HTMX CV (localhost:1999) vs old React CV (localhost:3000)
|
||||
*/
|
||||
|
||||
const { test, expect } = require('@playwright/test');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const OLD_SITE = 'http://localhost:3000';
|
||||
const NEW_SITE = 'http://localhost:1999';
|
||||
const SCREENSHOTS_DIR = path.join(__dirname, 'screenshots');
|
||||
|
||||
// Ensure screenshots directory exists
|
||||
if (!fs.existsSync(SCREENSHOTS_DIR)) {
|
||||
fs.mkdirSync(SCREENSHOTS_DIR, { recursive: true });
|
||||
}
|
||||
|
||||
test.describe('Visual Comparison: New vs Old CV', () => {
|
||||
|
||||
test('Full page screenshots', async ({ browser }) => {
|
||||
const contextOld = await browser.newContext({ viewport: { width: 1920, height: 1080 } });
|
||||
const contextNew = await browser.newContext({ viewport: { width: 1920, height: 1080 } });
|
||||
|
||||
const pageOld = await contextOld.newPage();
|
||||
const pageNew = await contextNew.newPage();
|
||||
|
||||
// Load both sites
|
||||
await pageOld.goto(OLD_SITE, { waitUntil: 'networkidle' });
|
||||
await pageNew.goto(NEW_SITE, { waitUntil: 'networkidle' });
|
||||
|
||||
// Take full page screenshots
|
||||
await pageOld.screenshot({
|
||||
path: path.join(SCREENSHOTS_DIR, 'old-fullpage.png'),
|
||||
fullPage: true
|
||||
});
|
||||
await pageNew.screenshot({
|
||||
path: path.join(SCREENSHOTS_DIR, 'new-fullpage.png'),
|
||||
fullPage: true
|
||||
});
|
||||
|
||||
console.log('✓ Full page screenshots saved');
|
||||
|
||||
await contextOld.close();
|
||||
await contextNew.close();
|
||||
});
|
||||
|
||||
test('Header section comparison', async ({ browser }) => {
|
||||
const contextOld = await browser.newContext({ viewport: { width: 1920, height: 1080 } });
|
||||
const contextNew = await browser.newContext({ viewport: { width: 1920, height: 1080 } });
|
||||
|
||||
const pageOld = await contextOld.newPage();
|
||||
const pageNew = await contextNew.newPage();
|
||||
|
||||
await pageOld.goto(OLD_SITE, { waitUntil: 'networkidle' });
|
||||
await pageNew.goto(NEW_SITE, { waitUntil: 'networkidle' });
|
||||
|
||||
// Screenshot header sections
|
||||
const headerOld = await pageOld.locator('.cv-title-badges-header, [class*="header"]').first();
|
||||
const headerNew = await pageNew.locator('.cv-title-badges-header').first();
|
||||
|
||||
if (await headerOld.count() > 0) {
|
||||
await headerOld.screenshot({ path: path.join(SCREENSHOTS_DIR, 'old-header.png') });
|
||||
}
|
||||
if (await headerNew.count() > 0) {
|
||||
await headerNew.screenshot({ path: path.join(SCREENSHOTS_DIR, 'new-header.png') });
|
||||
}
|
||||
|
||||
console.log('✓ Header screenshots saved');
|
||||
|
||||
await contextOld.close();
|
||||
await contextNew.close();
|
||||
});
|
||||
|
||||
test('Badge measurements comparison', async ({ browser }) => {
|
||||
const contextOld = await browser.newContext({ viewport: { width: 1920, height: 1080 } });
|
||||
const contextNew = await browser.newContext({ viewport: { width: 1920, height: 1080 } });
|
||||
|
||||
const pageOld = await contextOld.newPage();
|
||||
const pageNew = await contextNew.newPage();
|
||||
|
||||
await pageOld.goto(OLD_SITE, { waitUntil: 'networkidle' });
|
||||
await pageNew.goto(NEW_SITE, { waitUntil: 'networkidle' });
|
||||
|
||||
// Measure badge elements
|
||||
const measurements = {
|
||||
old: {},
|
||||
new: {}
|
||||
};
|
||||
|
||||
// New site badge measurements
|
||||
const badgeNew = pageNew.locator('.title-badge').first();
|
||||
if (await badgeNew.count() > 0) {
|
||||
const box = await badgeNew.boundingBox();
|
||||
const styles = await badgeNew.evaluate(el => {
|
||||
const computed = window.getComputedStyle(el);
|
||||
return {
|
||||
height: computed.height,
|
||||
padding: computed.padding,
|
||||
fontSize: computed.fontSize,
|
||||
fontWeight: computed.fontWeight,
|
||||
color: computed.color,
|
||||
backgroundColor: computed.backgroundColor,
|
||||
borderRadius: computed.borderRadius,
|
||||
display: computed.display,
|
||||
alignItems: computed.alignItems
|
||||
};
|
||||
});
|
||||
measurements.new.badge = { box, styles };
|
||||
}
|
||||
|
||||
// Old site badge measurements
|
||||
const badgeOld = pageOld.locator('.title-badge, [class*="badge"]').first();
|
||||
if (await badgeOld.count() > 0) {
|
||||
const box = await badgeOld.boundingBox();
|
||||
const styles = await badgeOld.evaluate(el => {
|
||||
const computed = window.getComputedStyle(el);
|
||||
return {
|
||||
height: computed.height,
|
||||
padding: computed.padding,
|
||||
fontSize: computed.fontSize,
|
||||
fontWeight: computed.fontWeight,
|
||||
color: computed.color,
|
||||
backgroundColor: computed.backgroundColor,
|
||||
borderRadius: computed.borderRadius,
|
||||
display: computed.display,
|
||||
alignItems: computed.alignItems
|
||||
};
|
||||
});
|
||||
measurements.old.badge = { box, styles };
|
||||
}
|
||||
|
||||
// Save measurements
|
||||
fs.writeFileSync(
|
||||
path.join(SCREENSHOTS_DIR, 'badge-measurements.json'),
|
||||
JSON.stringify(measurements, null, 2)
|
||||
);
|
||||
|
||||
console.log('✓ Badge measurements saved');
|
||||
console.log('\nBadge Comparison:');
|
||||
console.log('OLD:', JSON.stringify(measurements.old.badge?.styles, null, 2));
|
||||
console.log('NEW:', JSON.stringify(measurements.new.badge?.styles, null, 2));
|
||||
|
||||
await contextOld.close();
|
||||
await contextNew.close();
|
||||
});
|
||||
|
||||
test('Typography comparison', async ({ browser }) => {
|
||||
const contextOld = await browser.newContext({ viewport: { width: 1920, height: 1080 } });
|
||||
const contextNew = await browser.newContext({ viewport: { width: 1920, height: 1080 } });
|
||||
|
||||
const pageOld = await contextOld.newPage();
|
||||
const pageNew = await contextNew.newPage();
|
||||
|
||||
await pageOld.goto(OLD_SITE, { waitUntil: 'networkidle' });
|
||||
await pageNew.goto(NEW_SITE, { waitUntil: 'networkidle' });
|
||||
|
||||
const typography = {
|
||||
old: {},
|
||||
new: {}
|
||||
};
|
||||
|
||||
// Selectors to compare
|
||||
const selectors = {
|
||||
name: '.cv-name',
|
||||
sidebarTitle: '.sidebar-title',
|
||||
sectionTitle: '.section-title',
|
||||
badge: '.title-badge'
|
||||
};
|
||||
|
||||
// Measure new site typography
|
||||
for (const [key, selector] of Object.entries(selectors)) {
|
||||
const element = pageNew.locator(selector).first();
|
||||
if (await element.count() > 0) {
|
||||
typography.new[key] = await element.evaluate(el => {
|
||||
const computed = window.getComputedStyle(el);
|
||||
return {
|
||||
fontFamily: computed.fontFamily,
|
||||
fontSize: computed.fontSize,
|
||||
fontWeight: computed.fontWeight,
|
||||
lineHeight: computed.lineHeight,
|
||||
color: computed.color,
|
||||
letterSpacing: computed.letterSpacing
|
||||
};
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Measure old site typography
|
||||
for (const [key, selector] of Object.entries(selectors)) {
|
||||
const element = pageOld.locator(selector).first();
|
||||
if (await element.count() > 0) {
|
||||
typography.old[key] = await element.evaluate(el => {
|
||||
const computed = window.getComputedStyle(el);
|
||||
return {
|
||||
fontFamily: computed.fontFamily,
|
||||
fontSize: computed.fontSize,
|
||||
fontWeight: computed.fontWeight,
|
||||
lineHeight: computed.lineHeight,
|
||||
color: computed.color,
|
||||
letterSpacing: computed.letterSpacing
|
||||
};
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Save typography comparison
|
||||
fs.writeFileSync(
|
||||
path.join(SCREENSHOTS_DIR, 'typography-comparison.json'),
|
||||
JSON.stringify(typography, null, 2)
|
||||
);
|
||||
|
||||
console.log('✓ Typography comparison saved');
|
||||
console.log('\nTypography Comparison:');
|
||||
console.log('OLD:', JSON.stringify(typography.old, null, 2));
|
||||
console.log('NEW:', JSON.stringify(typography.new, null, 2));
|
||||
|
||||
await contextOld.close();
|
||||
await contextNew.close();
|
||||
});
|
||||
|
||||
test('Sidebar comparison', async ({ browser }) => {
|
||||
const contextOld = await browser.newContext({ viewport: { width: 1920, height: 1080 } });
|
||||
const contextNew = await browser.newContext({ viewport: { width: 1920, height: 1080 } });
|
||||
|
||||
const pageOld = await contextOld.newPage();
|
||||
const pageNew = await contextNew.newPage();
|
||||
|
||||
await pageOld.goto(OLD_SITE, { waitUntil: 'networkidle' });
|
||||
await pageNew.goto(NEW_SITE, { waitUntil: 'networkidle' });
|
||||
|
||||
// Screenshot sidebars
|
||||
const sidebarOld = pageOld.locator('.cv-sidebar, [class*="sidebar"]').first();
|
||||
const sidebarNew = pageNew.locator('.cv-sidebar').first();
|
||||
|
||||
if (await sidebarOld.count() > 0) {
|
||||
await sidebarOld.screenshot({ path: path.join(SCREENSHOTS_DIR, 'old-sidebar.png') });
|
||||
}
|
||||
if (await sidebarNew.count() > 0) {
|
||||
await sidebarNew.screenshot({ path: path.join(SCREENSHOTS_DIR, 'new-sidebar.png') });
|
||||
}
|
||||
|
||||
// Measure sidebar styles
|
||||
const sidebarComparison = {
|
||||
old: {},
|
||||
new: {}
|
||||
};
|
||||
|
||||
if (await sidebarNew.count() > 0) {
|
||||
sidebarComparison.new = await sidebarNew.evaluate(el => {
|
||||
const computed = window.getComputedStyle(el);
|
||||
return {
|
||||
backgroundColor: computed.backgroundColor,
|
||||
padding: computed.padding,
|
||||
width: computed.width,
|
||||
minWidth: computed.minWidth
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
if (await sidebarOld.count() > 0) {
|
||||
sidebarComparison.old = await sidebarOld.evaluate(el => {
|
||||
const computed = window.getComputedStyle(el);
|
||||
return {
|
||||
backgroundColor: computed.backgroundColor,
|
||||
padding: computed.padding,
|
||||
width: computed.width,
|
||||
minWidth: computed.minWidth
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
fs.writeFileSync(
|
||||
path.join(SCREENSHOTS_DIR, 'sidebar-comparison.json'),
|
||||
JSON.stringify(sidebarComparison, null, 2)
|
||||
);
|
||||
|
||||
console.log('✓ Sidebar comparison saved');
|
||||
console.log('\nSidebar Comparison:');
|
||||
console.log('OLD:', JSON.stringify(sidebarComparison.old, null, 2));
|
||||
console.log('NEW:', JSON.stringify(sidebarComparison.new, null, 2));
|
||||
|
||||
await contextOld.close();
|
||||
await contextNew.close();
|
||||
});
|
||||
|
||||
test('Critical elements style extraction', async ({ browser }) => {
|
||||
const contextOld = await browser.newContext({ viewport: { width: 1920, height: 1080 } });
|
||||
const contextNew = await browser.newContext({ viewport: { width: 1920, height: 1080 } });
|
||||
|
||||
const pageOld = await contextOld.newPage();
|
||||
const pageNew = await contextNew.newPage();
|
||||
|
||||
await pageOld.goto(OLD_SITE, { waitUntil: 'networkidle' });
|
||||
await pageNew.goto(NEW_SITE, { waitUntil: 'networkidle' });
|
||||
|
||||
const criticalElements = [
|
||||
'.cv-title-badges-header',
|
||||
'.title-badge',
|
||||
'.badge-separator',
|
||||
'.sidebar-title',
|
||||
'.section-title',
|
||||
'.cv-name'
|
||||
];
|
||||
|
||||
const styleComparison = {
|
||||
old: {},
|
||||
new: {}
|
||||
};
|
||||
|
||||
// Extract from new site
|
||||
for (const selector of criticalElements) {
|
||||
const element = pageNew.locator(selector).first();
|
||||
if (await element.count() > 0) {
|
||||
styleComparison.new[selector] = await element.evaluate(el => {
|
||||
const computed = window.getComputedStyle(el);
|
||||
const styles = {};
|
||||
for (let i = 0; i < computed.length; i++) {
|
||||
const prop = computed[i];
|
||||
styles[prop] = computed.getPropertyValue(prop);
|
||||
}
|
||||
return styles;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Extract from old site
|
||||
for (const selector of criticalElements) {
|
||||
const element = pageOld.locator(selector).first();
|
||||
if (await element.count() > 0) {
|
||||
styleComparison.old[selector] = await element.evaluate(el => {
|
||||
const computed = window.getComputedStyle(el);
|
||||
const styles = {};
|
||||
for (let i = 0; i < computed.length; i++) {
|
||||
const prop = computed[i];
|
||||
styles[prop] = computed.getPropertyValue(prop);
|
||||
}
|
||||
return styles;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
fs.writeFileSync(
|
||||
path.join(SCREENSHOTS_DIR, 'critical-elements-full-styles.json'),
|
||||
JSON.stringify(styleComparison, null, 2)
|
||||
);
|
||||
|
||||
console.log('✓ Critical elements styles extracted');
|
||||
|
||||
await contextOld.close();
|
||||
await contextNew.close();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user