Initial commit: Go + HTMX CV Site
- Minimal, professional CV design with paper-on-gray layout - Bilingual support (Spanish/English) with HTMX language switching - JSON-based content management (cv-en.json, cv-es.json) - Print-optimized CSS for PDF export - Responsive design for all devices - Go backend with stdlib net/http - Clean, maintainable codebase Features: - 18+ years professional experience - SAP CDC expertise - Complete project history - Education, certifications, awards - Multi-language support Tech stack: Go, HTMX, vanilla CSS
This commit is contained in:
@@ -0,0 +1,26 @@
|
||||
# Environment Configuration Example
|
||||
# Copy this file to .env and customize as needed
|
||||
|
||||
# Server Configuration
|
||||
PORT=8080
|
||||
HOST=localhost
|
||||
GO_ENV=development
|
||||
|
||||
# Template Configuration
|
||||
TEMPLATE_DIR=templates
|
||||
PARTIALS_DIR=templates/partials
|
||||
TEMPLATE_HOT_RELOAD=true
|
||||
|
||||
# Data Configuration
|
||||
DATA_DIR=data
|
||||
|
||||
# Server Timeouts (seconds)
|
||||
READ_TIMEOUT=15
|
||||
WRITE_TIMEOUT=15
|
||||
|
||||
# Production Settings
|
||||
# Uncomment for production:
|
||||
# GO_ENV=production
|
||||
# TEMPLATE_HOT_RELOAD=false
|
||||
# READ_TIMEOUT=30
|
||||
# WRITE_TIMEOUT=30
|
||||
+31
@@ -0,0 +1,31 @@
|
||||
# Binaries
|
||||
cv-server
|
||||
*.exe
|
||||
*.exe~
|
||||
*.dll
|
||||
*.so
|
||||
*.dylib
|
||||
|
||||
# Test binaries
|
||||
*.test
|
||||
|
||||
# Output
|
||||
*.out
|
||||
|
||||
# Go workspace
|
||||
go.work
|
||||
|
||||
# IDE
|
||||
.vscode/
|
||||
.idea/
|
||||
*.swp
|
||||
*.swo
|
||||
*~
|
||||
|
||||
# OS
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
# Temp files
|
||||
*.tmp
|
||||
*.log
|
||||
+395
@@ -0,0 +1,395 @@
|
||||
# Architecture Documentation
|
||||
|
||||
## Overview
|
||||
|
||||
This CV website is built following Go best practices with a focus on:
|
||||
- **Simplicity**: Clean, readable code
|
||||
- **Maintainability**: Well-structured packages
|
||||
- **Reliability**: Proper error handling and logging
|
||||
- **Performance**: Efficient template caching
|
||||
- **Security**: Multiple layers of protection
|
||||
|
||||
## Architecture Patterns
|
||||
|
||||
### 1. Package Structure (Internal Directory Pattern)
|
||||
|
||||
```
|
||||
cv/
|
||||
├── main.go # Application entry point
|
||||
└── internal/ # Private packages (cannot be imported by other projects)
|
||||
├── config/ # Configuration management
|
||||
├── handlers/ # HTTP request handlers
|
||||
├── middleware/ # HTTP middleware
|
||||
├── models/ # Data models and business logic
|
||||
└── templates/ # Template management
|
||||
```
|
||||
|
||||
**Benefits**:
|
||||
- Clear separation of concerns
|
||||
- Encapsulation (internal packages cannot be imported externally)
|
||||
- Easy to test and maintain
|
||||
- Scalable structure
|
||||
|
||||
### 2. Dependency Injection
|
||||
|
||||
Handlers and services receive their dependencies through constructors:
|
||||
|
||||
```go
|
||||
// ✅ Good: Dependencies injected
|
||||
type CVHandler struct {
|
||||
templates *templates.Manager
|
||||
}
|
||||
|
||||
func NewCVHandler(tmpl *templates.Manager) *CVHandler {
|
||||
return &CVHandler{templates: tmpl}
|
||||
}
|
||||
|
||||
// ❌ Bad: Global state
|
||||
var globalTemplates *template.Template
|
||||
```
|
||||
|
||||
**Benefits**:
|
||||
- Testability (easy to mock dependencies)
|
||||
- Flexibility (swap implementations)
|
||||
- Explicit dependencies
|
||||
|
||||
### 3. Error Handling Strategy
|
||||
|
||||
Three-tier error handling:
|
||||
|
||||
```go
|
||||
// 1. Domain errors (AppError)
|
||||
type AppError struct {
|
||||
Err error // Original error
|
||||
Message string // User-friendly message
|
||||
StatusCode int // HTTP status
|
||||
Internal bool // Hide details from client
|
||||
}
|
||||
|
||||
// 2. Error constructors for common cases
|
||||
NotFoundError("Resource not found")
|
||||
BadRequestError("Invalid input")
|
||||
InternalError(err)
|
||||
|
||||
// 3. Centralized error handler
|
||||
HandleError(w, r, err)
|
||||
```
|
||||
|
||||
**Benefits**:
|
||||
- Consistent error responses
|
||||
- Proper logging
|
||||
- Security (internal errors hidden)
|
||||
- Client-specific responses (JSON, HTML, HTMX)
|
||||
|
||||
### 4. Middleware Chain
|
||||
|
||||
Onion-like middleware wrapping:
|
||||
|
||||
```
|
||||
Request → Recovery → Logger → Security → Handler → Response
|
||||
↑ ↑ ↑ ↑
|
||||
Panics Logging Headers Business Logic
|
||||
```
|
||||
|
||||
**Implementation**:
|
||||
```go
|
||||
handler := middleware.Recovery(
|
||||
middleware.Logger(
|
||||
middleware.SecurityHeaders(mux),
|
||||
),
|
||||
)
|
||||
```
|
||||
|
||||
**Benefits**:
|
||||
- Separation of cross-cutting concerns
|
||||
- Reusable components
|
||||
- Easy to add/remove middleware
|
||||
- Predictable request flow
|
||||
|
||||
## Component Details
|
||||
|
||||
### Configuration Management (`internal/config`)
|
||||
|
||||
**Pattern**: Environment-based configuration with sensible defaults
|
||||
|
||||
```go
|
||||
cfg := config.Load() // Reads from env vars
|
||||
cfg.Server.Port // Defaults to "8080"
|
||||
cfg.Template.HotReload // Auto-detects development mode
|
||||
```
|
||||
|
||||
**Features**:
|
||||
- Environment variable support
|
||||
- Type-safe configuration
|
||||
- Development/production modes
|
||||
- Sensible defaults
|
||||
|
||||
### Template Management (`internal/templates`)
|
||||
|
||||
**Pattern**: Template manager with hot-reload support
|
||||
|
||||
```go
|
||||
manager := templates.NewManager(cfg)
|
||||
manager.Render("index.html") // Hot-reloads in dev mode
|
||||
```
|
||||
|
||||
**Features**:
|
||||
- Centralized template loading
|
||||
- Custom template functions
|
||||
- Hot-reload in development
|
||||
- Thread-safe caching
|
||||
- Graceful error handling
|
||||
|
||||
### HTTP Handlers (`internal/handlers`)
|
||||
|
||||
**Pattern**: Handler structs with methods
|
||||
|
||||
```go
|
||||
type CVHandler struct {
|
||||
templates *templates.Manager
|
||||
}
|
||||
|
||||
func (h *CVHandler) Home(w http.ResponseWriter, r *http.Request)
|
||||
func (h *CVHandler) CVContent(w http.ResponseWriter, r *http.Request)
|
||||
```
|
||||
|
||||
**Features**:
|
||||
- Clean separation of routes
|
||||
- Dependency injection
|
||||
- Consistent error handling
|
||||
- HTMX-aware responses
|
||||
|
||||
### Middleware (`internal/middleware`)
|
||||
|
||||
**Components**:
|
||||
1. **Recovery**: Catches panics, logs stack traces
|
||||
2. **Logger**: Structured request/response logging
|
||||
3. **SecurityHeaders**: CSP, XSS protection, clickjacking prevention
|
||||
|
||||
## Security Features
|
||||
|
||||
### 1. Security Headers
|
||||
|
||||
```go
|
||||
X-Frame-Options: SAMEORIGIN // Prevent clickjacking
|
||||
X-Content-Type-Options: nosniff // Prevent MIME sniffing
|
||||
X-XSS-Protection: 1; mode=block // XSS protection
|
||||
Content-Security-Policy: ... // Restrict resource loading
|
||||
Referrer-Policy: strict-origin-... // Control referrer info
|
||||
```
|
||||
|
||||
### 2. Request Timeouts
|
||||
|
||||
```go
|
||||
server := &http.Server{
|
||||
ReadTimeout: 15 * time.Second, // Prevent slow clients
|
||||
WriteTimeout: 15 * time.Second, // Prevent slow responses
|
||||
IdleTimeout: 120 * time.Second, // Keep-alive timeout
|
||||
}
|
||||
```
|
||||
|
||||
### 3. Error Information Hiding
|
||||
|
||||
```go
|
||||
appErr := NewAppError(err, "Database error", 500, true)
|
||||
// Client sees: "Internal Server Error"
|
||||
// Logs show: actual error details
|
||||
```
|
||||
|
||||
## Performance Optimizations
|
||||
|
||||
### 1. Template Caching
|
||||
|
||||
```go
|
||||
// Templates parsed once at startup
|
||||
templates := template.New("").ParseGlob("*.html")
|
||||
|
||||
// Hot-reload only in development
|
||||
if cfg.Template.HotReload {
|
||||
templates.ParseGlob("*.html") // Re-parse on each request
|
||||
}
|
||||
```
|
||||
|
||||
### 2. Static File Caching
|
||||
|
||||
```go
|
||||
// Development: 1 hour cache
|
||||
// Production: 1 day cache
|
||||
Cache-Control: public, max-age=86400
|
||||
```
|
||||
|
||||
### 3. HTTP/2 Support
|
||||
|
||||
Go's `http.Server` automatically supports HTTP/2 when using HTTPS.
|
||||
|
||||
## Graceful Shutdown
|
||||
|
||||
```go
|
||||
// 1. Listen for signals
|
||||
shutdown := make(chan os.Signal, 1)
|
||||
signal.Notify(shutdown, os.Interrupt, syscall.SIGTERM)
|
||||
|
||||
// 2. Shutdown with timeout
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
server.Shutdown(ctx)
|
||||
```
|
||||
|
||||
**Features**:
|
||||
- Finish in-flight requests
|
||||
- 30-second grace period
|
||||
- Forced shutdown if timeout exceeded
|
||||
|
||||
## HTMX Integration Patterns
|
||||
|
||||
### 1. Partial Content Rendering
|
||||
|
||||
```go
|
||||
// Full page: /
|
||||
// Partial: /cv?lang=es (returns only content div)
|
||||
|
||||
if r.Header.Get("HX-Request") != "" {
|
||||
// HTMX request - return partial
|
||||
renderPartial()
|
||||
} else {
|
||||
// Regular request - return full page
|
||||
renderFull()
|
||||
}
|
||||
```
|
||||
|
||||
### 2. Language Switching
|
||||
|
||||
```html
|
||||
<button hx-get="/cv?lang=es"
|
||||
hx-target="#cv-content"
|
||||
hx-swap="innerHTML">
|
||||
🇪🇸 Español
|
||||
</button>
|
||||
```
|
||||
|
||||
**Benefits**:
|
||||
- No JavaScript frameworks needed
|
||||
- Progressive enhancement
|
||||
- Server-rendered content
|
||||
- Better SEO
|
||||
|
||||
## Testing Strategy
|
||||
|
||||
### Manual Testing
|
||||
|
||||
```bash
|
||||
# 1. Health check
|
||||
curl http://localhost:8080/health
|
||||
|
||||
# 2. Happy path
|
||||
curl "http://localhost:8080/?lang=en"
|
||||
|
||||
# 3. Error cases
|
||||
curl "http://localhost:8080/?lang=invalid" # 400 Bad Request
|
||||
|
||||
# 4. HTMX requests
|
||||
curl -H "HX-Request: true" "http://localhost:8080/cv?lang=es"
|
||||
|
||||
# 5. Security headers
|
||||
curl -I http://localhost:8080/
|
||||
```
|
||||
|
||||
### Future: Automated Tests
|
||||
|
||||
```go
|
||||
func TestCVHandler_Home(t *testing.T) {
|
||||
// Setup
|
||||
cfg := &config.TemplateConfig{Dir: "../../templates"}
|
||||
tmpl, _ := templates.NewManager(cfg)
|
||||
handler := handlers.NewCVHandler(tmpl)
|
||||
|
||||
// Execute
|
||||
req := httptest.NewRequest("GET", "/?lang=en", nil)
|
||||
w := httptest.NewRecorder()
|
||||
handler.Home(w, req)
|
||||
|
||||
// Assert
|
||||
assert.Equal(t, 200, w.Code)
|
||||
}
|
||||
```
|
||||
|
||||
## Deployment Options
|
||||
|
||||
### 1. Standalone Binary
|
||||
|
||||
```bash
|
||||
go build -o cv-server -ldflags="-s -w" .
|
||||
./cv-server
|
||||
```
|
||||
|
||||
### 2. Docker
|
||||
|
||||
```bash
|
||||
docker build -t cv-server .
|
||||
docker run -p 8080:8080 cv-server
|
||||
```
|
||||
|
||||
### 3. Cloud Platforms
|
||||
|
||||
**Recommended**:
|
||||
- **Fly.io**: `fly launch` (auto-detects Dockerfile)
|
||||
- **Railway**: Connect GitHub, auto-deploy
|
||||
- **Google Cloud Run**: Serverless containers
|
||||
- **AWS ECS/Fargate**: Container orchestration
|
||||
|
||||
## Best Practices Applied
|
||||
|
||||
1. ✅ **Standard Project Layout**: Internal packages, clear structure
|
||||
2. ✅ **Error Handling**: Custom error types, consistent handling
|
||||
3. ✅ **Logging**: Structured, informative logs
|
||||
4. ✅ **Configuration**: Environment-based with defaults
|
||||
5. ✅ **Security**: Multiple layers of protection
|
||||
6. ✅ **Graceful Shutdown**: Clean service termination
|
||||
7. ✅ **Dependency Injection**: Testable, maintainable code
|
||||
8. ✅ **Middleware Pattern**: Separation of concerns
|
||||
9. ✅ **Template Management**: Efficient, cached rendering
|
||||
10. ✅ **Production-Ready**: Timeouts, health checks, monitoring hooks
|
||||
|
||||
## Scaling Considerations
|
||||
|
||||
### Current State: Single Instance
|
||||
- Perfect for CV website (low traffic)
|
||||
- ~1000s of requests/second capability
|
||||
- Minimal resource usage
|
||||
|
||||
### Future: Multi-Instance (if needed)
|
||||
1. **Load Balancer**: nginx, Caddy, or cloud LB
|
||||
2. **Shared Storage**: For static files (S3, Cloud Storage)
|
||||
3. **Health Checks**: `/health` endpoint already implemented
|
||||
4. **Metrics**: Add Prometheus metrics
|
||||
5. **Caching**: Redis for template cache (if very high traffic)
|
||||
|
||||
## Monitoring & Observability
|
||||
|
||||
### Current Implementation
|
||||
- **Structured Logging**: Request/response logging
|
||||
- **Health Check**: `/health` endpoint
|
||||
- **Error Tracking**: Detailed error logs with stack traces
|
||||
|
||||
### Future Enhancements
|
||||
```go
|
||||
// Metrics
|
||||
prometheus.InstrumentHandler("/", handler)
|
||||
|
||||
// Distributed Tracing
|
||||
opentelemetry.Trace(handler)
|
||||
|
||||
// Error Monitoring
|
||||
sentry.CaptureException(err)
|
||||
```
|
||||
|
||||
## Conclusion
|
||||
|
||||
This architecture provides:
|
||||
- ✅ Clean, maintainable code
|
||||
- ✅ Production-ready error handling
|
||||
- ✅ Security best practices
|
||||
- ✅ Performance optimizations
|
||||
- ✅ Easy deployment options
|
||||
- ✅ Room for future growth
|
||||
|
||||
Perfect for a professional CV website with potential to scale.
|
||||
+46
@@ -0,0 +1,46 @@
|
||||
# Build stage
|
||||
FROM golang:1.25-alpine AS builder
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Copy go mod files
|
||||
COPY go.mod ./
|
||||
|
||||
# Download dependencies
|
||||
RUN go mod download
|
||||
|
||||
# Copy source code
|
||||
COPY . .
|
||||
|
||||
# Build binary
|
||||
RUN CGO_ENABLED=0 GOOS=linux go build -o cv-server -ldflags="-s -w" .
|
||||
|
||||
# Runtime stage
|
||||
FROM alpine:latest
|
||||
|
||||
# Install CA certificates for HTTPS
|
||||
RUN apk --no-cache add ca-certificates
|
||||
|
||||
WORKDIR /root/
|
||||
|
||||
# Copy binary from builder
|
||||
COPY --from=builder /app/cv-server .
|
||||
|
||||
# Copy application files
|
||||
COPY templates templates/
|
||||
COPY data data/
|
||||
COPY static static/
|
||||
|
||||
# Expose port
|
||||
EXPOSE 8080
|
||||
|
||||
# Set production environment
|
||||
ENV GO_ENV=production
|
||||
ENV PORT=8080
|
||||
|
||||
# Health check
|
||||
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
|
||||
CMD wget --no-verbose --tries=1 --spider http://localhost:8080/health || exit 1
|
||||
|
||||
# Run the binary
|
||||
CMD ["./cv-server"]
|
||||
@@ -0,0 +1,884 @@
|
||||
# HTMX CV Site - Production Readiness Review
|
||||
|
||||
## Executive Summary
|
||||
|
||||
**Current Status:** 85% Production Ready
|
||||
**Performance:** Exceptional (0.8-1ms response times, well below 85-120ms target)
|
||||
**Core Implementation:** Solid HTMX patterns, clean architecture
|
||||
**Priority Focus:** Accessibility, Error Handling, SEO, Security
|
||||
|
||||
---
|
||||
|
||||
## 1. HTMX Implementation Analysis
|
||||
|
||||
### ✅ **Strengths**
|
||||
|
||||
1. **Excellent Performance**
|
||||
- Initial page load: 0.8ms
|
||||
- HTMX partial swap: 1.0ms
|
||||
- Well below recommended 85-120ms target
|
||||
- Go backend provides exceptional speed
|
||||
|
||||
2. **Clean HTMX Patterns**
|
||||
- Proper use of `hx-get` for language switching
|
||||
- Targeted swaps with `hx-target="#cv-content"`
|
||||
- Loading indicators with `hx-indicator`
|
||||
- Locality of behavior maintained
|
||||
|
||||
3. **Good Progressive Enhancement**
|
||||
- Functional without JavaScript (direct URL access works)
|
||||
- Links are actual HTTP GET requests
|
||||
- No JavaScript frameworks required
|
||||
|
||||
### ⚠️ **Critical Issues to Address**
|
||||
|
||||
#### **1.1 Missing Browser History Management**
|
||||
|
||||
**Problem:** Language changes don't update browser URL
|
||||
**Impact:** Back button doesn't work, bookmarks don't preserve language
|
||||
**Priority:** HIGH
|
||||
|
||||
**Solution:**
|
||||
```html
|
||||
<button
|
||||
class="lang-btn {{if eq .Lang "en"}}active{{end}}"
|
||||
hx-get="/cv?lang=en"
|
||||
hx-target="#cv-content"
|
||||
hx-swap="innerHTML"
|
||||
hx-push-url="/?lang=en" <!-- ADD THIS -->
|
||||
hx-indicator="#loading">
|
||||
🇬🇧 English
|
||||
</button>
|
||||
```
|
||||
|
||||
#### **1.2 No Error Handling**
|
||||
|
||||
**Problem:** Failed HTMX requests show no feedback to users
|
||||
**Impact:** Poor UX when network fails or server errors
|
||||
**Priority:** HIGH
|
||||
|
||||
**Solution:** Add global error handler
|
||||
```javascript
|
||||
document.body.addEventListener('htmx:responseError', function(evt) {
|
||||
// Show user-friendly error message
|
||||
showErrorToast('Failed to load content. Please try again.');
|
||||
});
|
||||
|
||||
document.body.addEventListener('htmx:timeout', function(evt) {
|
||||
showErrorToast('Request timed out. Please check your connection.');
|
||||
});
|
||||
```
|
||||
|
||||
#### **1.3 Missing ARIA Live Regions**
|
||||
|
||||
**Problem:** Screen readers don't announce dynamic content changes
|
||||
**Impact:** Accessibility violation (WCAG 2.1 Level A)
|
||||
**Priority:** HIGH
|
||||
|
||||
**Solution:**
|
||||
```html
|
||||
<main id="cv-content"
|
||||
class="cv-paper"
|
||||
role="main"
|
||||
aria-live="polite"
|
||||
aria-atomic="false"> <!-- ADD THESE -->
|
||||
{{template "cv-content.html" .}}
|
||||
</main>
|
||||
```
|
||||
|
||||
#### **1.4 No Transition Effects**
|
||||
|
||||
**Problem:** Instant swaps feel jarring
|
||||
**Impact:** Poor UX, no visual feedback during changes
|
||||
**Priority:** MEDIUM
|
||||
|
||||
**Solution:**
|
||||
```html
|
||||
hx-swap="innerHTML swap:200ms settle:200ms"
|
||||
```
|
||||
|
||||
**CSS:**
|
||||
```css
|
||||
.cv-paper {
|
||||
transition: opacity 200ms;
|
||||
}
|
||||
|
||||
.cv-paper.htmx-swapping {
|
||||
opacity: 0;
|
||||
}
|
||||
```
|
||||
|
||||
#### **1.5 No Request Timeout Configuration**
|
||||
|
||||
**Problem:** Requests wait indefinitely on slow connections
|
||||
**Impact:** Hanging UI, poor UX
|
||||
**Priority:** MEDIUM
|
||||
|
||||
**Solution:**
|
||||
```html
|
||||
<meta name="htmx-config" content='{"timeout":5000}'>
|
||||
```
|
||||
|
||||
#### **1.6 Language Preference Not Persisted**
|
||||
|
||||
**Problem:** Users must reselect language on each visit
|
||||
**Impact:** Inconvenience for repeat visitors
|
||||
**Priority:** LOW
|
||||
|
||||
**Solution:**
|
||||
```javascript
|
||||
// Save preference on language change
|
||||
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 preference on page load
|
||||
window.addEventListener('DOMContentLoaded', function() {
|
||||
const savedLang = localStorage.getItem('cv-lang');
|
||||
if (savedLang) {
|
||||
// Trigger HTMX request for saved language
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 2. Accessibility (WCAG 2.1 Level AA)
|
||||
|
||||
### ⚠️ **Critical Issues**
|
||||
|
||||
#### **2.1 Missing ARIA Attributes**
|
||||
|
||||
**Current State:** Minimal ARIA usage
|
||||
**Required for WCAG 2.1:**
|
||||
|
||||
```html
|
||||
<!-- Language toggle -->
|
||||
<div class="language-toggle" role="group" aria-label="Language selection">
|
||||
<button
|
||||
class="lang-btn"
|
||||
aria-label="Switch to English"
|
||||
aria-pressed="true|false"
|
||||
aria-busy="true|false"> <!-- During loading -->
|
||||
🇬🇧 English
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Loading indicator -->
|
||||
<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> <!-- Screen reader text -->
|
||||
</span>
|
||||
|
||||
<!-- Error messages -->
|
||||
<div role="alert" aria-live="assertive">
|
||||
<!-- Error content -->
|
||||
</div>
|
||||
```
|
||||
|
||||
#### **2.2 Missing Focus Management**
|
||||
|
||||
**Problem:** Focus doesn't move to updated content
|
||||
**Solution:** Add focus management after swap
|
||||
|
||||
```javascript
|
||||
document.body.addEventListener('htmx:afterSwap', function(evt) {
|
||||
if (evt.detail.target.id === 'cv-content') {
|
||||
// Focus on main heading
|
||||
const heading = evt.detail.target.querySelector('h1');
|
||||
if (heading) heading.focus();
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
#### **2.3 Insufficient Keyboard Navigation**
|
||||
|
||||
**Recommendations:**
|
||||
- Add keyboard shortcuts (Ctrl+E for English, Ctrl+S for Spanish)
|
||||
- Ensure all interactive elements are keyboard accessible
|
||||
- Add skip-to-content link
|
||||
|
||||
```html
|
||||
<a href="#cv-content" class="skip-link">Skip to content</a>
|
||||
```
|
||||
|
||||
```css
|
||||
.skip-link {
|
||||
position: absolute;
|
||||
top: -40px;
|
||||
left: 0;
|
||||
background: var(--accent-blue);
|
||||
color: white;
|
||||
padding: 8px;
|
||||
z-index: 100;
|
||||
}
|
||||
|
||||
.skip-link:focus {
|
||||
top: 0;
|
||||
}
|
||||
```
|
||||
|
||||
#### **2.4 Color Contrast Issues**
|
||||
|
||||
**Check Required:**
|
||||
- `.text-light` (#6a6a6a) on white might not meet WCAG AA (4.5:1 ratio)
|
||||
- Test all color combinations with contrast checker
|
||||
|
||||
**Tool:** https://webaim.org/resources/contrastchecker/
|
||||
|
||||
---
|
||||
|
||||
## 3. SEO Optimization
|
||||
|
||||
### ⚠️ **Missing SEO Elements**
|
||||
|
||||
#### **3.1 Missing Meta Tags**
|
||||
|
||||
**Add to `<head>`:**
|
||||
```html
|
||||
<!-- Essential SEO -->
|
||||
<meta name="author" content="{{.CV.Personal.Name}}">
|
||||
<meta name="robots" content="index, follow">
|
||||
<link rel="canonical" href="{{.CV.Personal.Website}}">
|
||||
|
||||
<!-- Open Graph (Social Media) -->
|
||||
<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}}">
|
||||
<meta property="og:image" content="{{.CV.Personal.Website}}/static/og-image.jpg">
|
||||
|
||||
<!-- Twitter Card -->
|
||||
<meta name="twitter:card" content="summary">
|
||||
<meta name="twitter:title" content="{{.CV.Personal.Name}}">
|
||||
<meta name="twitter:description" content="{{.CV.Personal.Title}}">
|
||||
|
||||
<!-- Professional Profile -->
|
||||
<meta property="profile:first_name" content="Juan Andrés">
|
||||
<meta property="profile:last_name" content="Moreno Rubio">
|
||||
<meta property="profile:username" content="txeo">
|
||||
```
|
||||
|
||||
#### **3.2 Missing Structured Data (JSON-LD)**
|
||||
|
||||
**Add before `</head>`:**
|
||||
```html
|
||||
<script type="application/ld+json">
|
||||
{
|
||||
"@context": "https://schema.org",
|
||||
"@type": "Person",
|
||||
"name": "{{.CV.Personal.Name}}",
|
||||
"jobTitle": "{{.CV.Personal.Title}}",
|
||||
"url": "{{.CV.Personal.Website}}",
|
||||
"sameAs": [
|
||||
"{{.CV.Personal.LinkedIn}}",
|
||||
"{{.CV.Personal.GitHub}}"
|
||||
],
|
||||
"address": {
|
||||
"@type": "PostalAddress",
|
||||
"addressLocality": "{{.CV.Personal.Location}}"
|
||||
},
|
||||
"email": "{{.CV.Personal.Email}}",
|
||||
"telephone": "{{.CV.Personal.Phone}}"
|
||||
}
|
||||
</script>
|
||||
```
|
||||
|
||||
#### **3.3 Missing Sitemap**
|
||||
|
||||
**Create `/sitemap.xml`:**
|
||||
```xml
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
|
||||
<url>
|
||||
<loc>https://yoursite.com/?lang=en</loc>
|
||||
<lastmod>2025-10-18</lastmod>
|
||||
<changefreq>monthly</changefreq>
|
||||
<priority>1.0</priority>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://yoursite.com/?lang=es</loc>
|
||||
<lastmod>2025-10-18</lastmod>
|
||||
<changefreq>monthly</changefreq>
|
||||
<priority>1.0</priority>
|
||||
</url>
|
||||
</urlset>
|
||||
```
|
||||
|
||||
#### **3.4 Missing robots.txt**
|
||||
|
||||
**Create `/static/robots.txt`:**
|
||||
```
|
||||
User-agent: *
|
||||
Allow: /
|
||||
Sitemap: https://yoursite.com/sitemap.xml
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. Security Enhancements
|
||||
|
||||
### ⚠️ **Missing Security Headers**
|
||||
|
||||
#### **4.1 Add Security Middleware in Go**
|
||||
|
||||
**Create `middleware/security.go`:**
|
||||
```go
|
||||
func SecurityHeaders(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
// Prevent clickjacking
|
||||
w.Header().Set("X-Frame-Options", "DENY")
|
||||
w.Header().Set("X-Content-Type-Options", "nosniff")
|
||||
w.Header().Set("X-XSS-Protection", "1; mode=block")
|
||||
|
||||
// Content Security Policy
|
||||
w.Header().Set("Content-Security-Policy",
|
||||
"default-src 'self'; "+
|
||||
"script-src 'self' 'unsafe-inline' https://unpkg.com; "+
|
||||
"style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; "+
|
||||
"font-src 'self' https://fonts.gstatic.com; "+
|
||||
"img-src 'self' data:; "+
|
||||
"connect-src 'self'")
|
||||
|
||||
// Referrer Policy
|
||||
w.Header().Set("Referrer-Policy", "strict-origin-when-cross-origin")
|
||||
|
||||
// Permissions Policy
|
||||
w.Header().Set("Permissions-Policy",
|
||||
"geolocation=(), microphone=(), camera=()")
|
||||
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
```
|
||||
|
||||
#### **4.2 Add SRI (Subresource Integrity) for HTMX**
|
||||
|
||||
**Update HTMX script tag:**
|
||||
```html
|
||||
<script src="https://unpkg.com/htmx.org@1.9.10"
|
||||
integrity="sha384-D1Kt99CQMDuVetoL1lrYwg5t+9QdHe7NLX/SoJYkXDFfX37iInKRy5xLSi8nO7UC"
|
||||
crossorigin="anonymous"></script>
|
||||
```
|
||||
|
||||
#### **4.3 Rate Limiting**
|
||||
|
||||
**Add to Go middleware:**
|
||||
```go
|
||||
// Simple rate limiter (use golang.org/x/time/rate in production)
|
||||
func RateLimit(next http.Handler) http.Handler {
|
||||
limiter := rate.NewLimiter(10, 20) // 10 requests/sec, burst of 20
|
||||
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if !limiter.Allow() {
|
||||
http.Error(w, "Rate limit exceeded", http.StatusTooManyRequests)
|
||||
return
|
||||
}
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5. Performance Optimizations
|
||||
|
||||
### ✅ **Already Excellent**
|
||||
|
||||
- Sub-millisecond response times
|
||||
- Minimal JavaScript
|
||||
- Clean HTML structure
|
||||
|
||||
### 🔧 **Additional Improvements**
|
||||
|
||||
#### **5.1 Add Resource Hints**
|
||||
|
||||
```html
|
||||
<head>
|
||||
<!-- Preconnect to external domains -->
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link rel="preconnect" href="https://unpkg.com">
|
||||
|
||||
<!-- DNS prefetch -->
|
||||
<link rel="dns-prefetch" href="https://fonts.googleapis.com">
|
||||
</head>
|
||||
```
|
||||
|
||||
#### **5.2 Add Cache Control Headers**
|
||||
|
||||
**In Go handler:**
|
||||
```go
|
||||
// Static files
|
||||
w.Header().Set("Cache-Control", "public, max-age=31536000, immutable")
|
||||
|
||||
// HTML pages
|
||||
w.Header().Set("Cache-Control", "public, max-age=3600, must-revalidate")
|
||||
|
||||
// HTMX partials
|
||||
w.Header().Set("Cache-Control", "private, max-age=300")
|
||||
```
|
||||
|
||||
#### **5.3 Compress Responses**
|
||||
|
||||
```go
|
||||
import "compress/gzip"
|
||||
|
||||
func GzipHandler(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if !strings.Contains(r.Header.Get("Accept-Encoding"), "gzip") {
|
||||
next.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Encoding", "gzip")
|
||||
gz := gzip.NewWriter(w)
|
||||
defer gz.Close()
|
||||
|
||||
gzw := gzipResponseWriter{Writer: gz, ResponseWriter: w}
|
||||
next.ServeHTTP(gzw, r)
|
||||
})
|
||||
}
|
||||
```
|
||||
|
||||
#### **5.4 Optimize Font Loading**
|
||||
|
||||
```html
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap"
|
||||
rel="stylesheet"
|
||||
media="print"
|
||||
onload="this.media='all'">
|
||||
<noscript>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap"
|
||||
rel="stylesheet">
|
||||
</noscript>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 6. Enhanced User Experience
|
||||
|
||||
### 🔧 **Recommended Enhancements**
|
||||
|
||||
#### **6.1 Add Smooth Scroll to Top**
|
||||
|
||||
```javascript
|
||||
document.body.addEventListener('htmx:afterSwap', function(evt) {
|
||||
if (evt.detail.target.id === 'cv-content') {
|
||||
window.scrollTo({ top: 0, behavior: 'smooth' });
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
#### **6.2 Add PDF Download with Custom Filename**
|
||||
|
||||
**Update Go handler:**
|
||||
```go
|
||||
func handlePDFExport(w http.ResponseWriter, r *http.Request) {
|
||||
lang := r.URL.Query().Get("lang")
|
||||
filename := fmt.Sprintf("CV-%s-%s.pdf", "Juan-Andres-Moreno", lang)
|
||||
|
||||
w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=%s", filename))
|
||||
|
||||
// Redirect to print view
|
||||
http.Redirect(w, r, fmt.Sprintf("/?lang=%s&print=true", lang), http.StatusSeeOther)
|
||||
}
|
||||
```
|
||||
|
||||
#### **6.3 Add Keyboard Shortcuts**
|
||||
|
||||
```javascript
|
||||
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
|
||||
if ((evt.ctrlKey || evt.metaKey) && evt.key === 'e') {
|
||||
evt.preventDefault();
|
||||
document.querySelector('[hx-get="/cv?lang=en"]').click();
|
||||
}
|
||||
|
||||
// Ctrl/Cmd + Shift + S for Spanish
|
||||
if ((evt.ctrlKey || evt.metaKey) && evt.shiftKey && evt.key === 's') {
|
||||
evt.preventDefault();
|
||||
document.querySelector('[hx-get="/cv?lang=es"]').click();
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
#### **6.4 Add Loading Skeleton**
|
||||
|
||||
**During HTMX swap, show skeleton:**
|
||||
```css
|
||||
.cv-content-loading {
|
||||
background: linear-gradient(90deg, #f0f0f0 25%, #e0e0e0 50%, #f0f0f0 75%);
|
||||
background-size: 200% 100%;
|
||||
animation: loading 1.5s infinite;
|
||||
}
|
||||
|
||||
@keyframes loading {
|
||||
0% { background-position: 200% 0; }
|
||||
100% { background-position: -200% 0; }
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 7. Testing & Monitoring
|
||||
|
||||
### 🧪 **Testing Checklist**
|
||||
|
||||
#### **7.1 Functional Testing**
|
||||
|
||||
- [ ] Language switching works without page reload
|
||||
- [ ] Browser back/forward buttons work correctly
|
||||
- [ ] Bookmarks preserve language selection
|
||||
- [ ] PDF export generates correct filename
|
||||
- [ ] All links are functional
|
||||
- [ ] Form validation (if any future forms)
|
||||
|
||||
#### **7.2 Accessibility Testing**
|
||||
|
||||
**Tools:**
|
||||
- [ ] WAVE Browser Extension
|
||||
- [ ] axe DevTools
|
||||
- [ ] Lighthouse Accessibility Audit (target: 100)
|
||||
- [ ] Screen reader testing (NVDA, JAWS, VoiceOver)
|
||||
- [ ] Keyboard-only navigation
|
||||
|
||||
**Checklist:**
|
||||
- [ ] All images have alt text
|
||||
- [ ] Form labels are associated
|
||||
- [ ] Color contrast meets WCAG AA (4.5:1)
|
||||
- [ ] Focus indicators are visible
|
||||
- [ ] ARIA attributes are correct
|
||||
- [ ] Headings are hierarchical (h1 → h2 → h3)
|
||||
|
||||
#### **7.3 Performance Testing**
|
||||
|
||||
**Tools:**
|
||||
- [ ] Lighthouse Performance (target: 95+)
|
||||
- [ ] WebPageTest
|
||||
- [ ] Chrome DevTools Network tab
|
||||
|
||||
**Metrics:**
|
||||
- [ ] First Contentful Paint (FCP): <1.8s
|
||||
- [ ] Largest Contentful Paint (LCP): <2.5s
|
||||
- [ ] First Input Delay (FID): <100ms
|
||||
- [ ] Cumulative Layout Shift (CLS): <0.1
|
||||
- [ ] Time to Interactive (TTI): <3.8s
|
||||
|
||||
#### **7.4 Cross-Browser Testing**
|
||||
|
||||
- [ ] Chrome (latest)
|
||||
- [ ] Firefox (latest)
|
||||
- [ ] Safari (latest)
|
||||
- [ ] Edge (latest)
|
||||
- [ ] Mobile Safari (iOS)
|
||||
- [ ] Chrome Mobile (Android)
|
||||
|
||||
#### **7.5 Security Testing**
|
||||
|
||||
**Tools:**
|
||||
- [ ] Mozilla Observatory
|
||||
- [ ] Security Headers (securityheaders.com)
|
||||
- [ ] OWASP ZAP
|
||||
|
||||
**Checklist:**
|
||||
- [ ] HTTPS enforced
|
||||
- [ ] Security headers present
|
||||
- [ ] No XSS vulnerabilities
|
||||
- [ ] No SQL injection (if database used)
|
||||
- [ ] CSRF protection (if forms added)
|
||||
|
||||
---
|
||||
|
||||
## 8. Production Deployment Checklist
|
||||
|
||||
### 📦 **Pre-Deployment**
|
||||
|
||||
#### **8.1 Code Quality**
|
||||
|
||||
- [ ] All console.log statements removed
|
||||
- [ ] Error handling implemented
|
||||
- [ ] Code comments for complex logic
|
||||
- [ ] No hardcoded credentials
|
||||
- [ ] Environment variables configured
|
||||
|
||||
#### **8.2 Build Process**
|
||||
|
||||
```bash
|
||||
# Minify CSS
|
||||
npm install -g csso-cli
|
||||
csso static/css/main.css -o static/css/main.min.css
|
||||
|
||||
# Minify JavaScript (if custom JS added)
|
||||
npm install -g terser
|
||||
terser static/js/main.js -o static/js/main.min.js -c -m
|
||||
```
|
||||
|
||||
#### **8.3 Environment Configuration**
|
||||
|
||||
**Create `.env.production`:**
|
||||
```env
|
||||
GO_ENV=production
|
||||
PORT=8080
|
||||
HOST=0.0.0.0
|
||||
ALLOWED_ORIGINS=https://yoursite.com
|
||||
CACHE_CONTROL_MAX_AGE=86400
|
||||
```
|
||||
|
||||
#### **8.4 Monitoring Setup**
|
||||
|
||||
**Add health check endpoint:**
|
||||
```go
|
||||
func handleHealth(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(map[string]interface{}{
|
||||
"status": "healthy",
|
||||
"version": "1.0.0",
|
||||
"timestamp": time.Now().Unix(),
|
||||
})
|
||||
}
|
||||
```
|
||||
|
||||
**Add logging:**
|
||||
```go
|
||||
import "log/slog"
|
||||
|
||||
logger := slog.New(slog.NewJSONHandler(os.Stdout, nil))
|
||||
logger.Info("Request received", "path", r.URL.Path, "method", r.Method)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 9. Priority Implementation Plan
|
||||
|
||||
### Phase 1: Critical (Week 1)
|
||||
|
||||
1. **Accessibility**
|
||||
- Add ARIA attributes (4 hours)
|
||||
- Implement keyboard navigation (2 hours)
|
||||
- Test with screen readers (2 hours)
|
||||
|
||||
2. **Error Handling**
|
||||
- Global HTMX error handler (2 hours)
|
||||
- Error toast component (2 hours)
|
||||
- Timeout configuration (1 hour)
|
||||
|
||||
3. **Browser History**
|
||||
- Add `hx-push-url` (1 hour)
|
||||
- Test back/forward navigation (1 hour)
|
||||
|
||||
**Total:** ~15 hours
|
||||
|
||||
### Phase 2: Important (Week 2)
|
||||
|
||||
1. **SEO**
|
||||
- Meta tags and Open Graph (2 hours)
|
||||
- Structured data (2 hours)
|
||||
- Sitemap and robots.txt (1 hour)
|
||||
|
||||
2. **Security**
|
||||
- Security headers middleware (2 hours)
|
||||
- SRI for external scripts (1 hour)
|
||||
- Rate limiting (2 hours)
|
||||
|
||||
3. **UX Enhancements**
|
||||
- Transition effects (2 hours)
|
||||
- Language preference storage (2 hours)
|
||||
- Keyboard shortcuts (1 hour)
|
||||
|
||||
**Total:** ~15 hours
|
||||
|
||||
### Phase 3: Nice-to-Have (Week 3)
|
||||
|
||||
1. **Performance**
|
||||
- Resource hints (1 hour)
|
||||
- Gzip compression (2 hours)
|
||||
- Font optimization (1 hour)
|
||||
|
||||
2. **Testing**
|
||||
- Automated accessibility tests (4 hours)
|
||||
- Performance testing (2 hours)
|
||||
- Cross-browser testing (4 hours)
|
||||
|
||||
**Total:** ~14 hours
|
||||
|
||||
---
|
||||
|
||||
## 10. Files to Update/Create
|
||||
|
||||
### Update Existing Files
|
||||
|
||||
1. **`/Users/txeo/Git/yo/cv/templates/index.html`**
|
||||
- Add ARIA attributes
|
||||
- Add `hx-push-url`
|
||||
- Add error toast HTML
|
||||
- Add meta tags
|
||||
|
||||
2. **`/Users/txeo/Git/yo/cv/static/css/main.css`**
|
||||
- Add transition effects
|
||||
- Add error toast styles
|
||||
- Add focus styles
|
||||
- Add reduced motion support
|
||||
|
||||
3. **`/Users/txeo/Git/yo/cv/main.go`**
|
||||
- Add security headers
|
||||
- Add rate limiting
|
||||
- Add gzip compression
|
||||
- Add health check endpoint
|
||||
|
||||
### Create New Files
|
||||
|
||||
1. **`/Users/txeo/Git/yo/cv/static/js/htmx-enhancements.js`** (optional)
|
||||
- Error handling
|
||||
- Keyboard shortcuts
|
||||
- Language preference storage
|
||||
- Analytics events
|
||||
|
||||
2. **`/Users/txeo/Git/yo/cv/static/robots.txt`**
|
||||
- Search engine directives
|
||||
|
||||
3. **`/Users/txeo/Git/yo/cv/sitemap.xml`**
|
||||
- Site structure for SEO
|
||||
|
||||
4. **`/Users/txeo/Git/yo/cv/middleware/security.go`**
|
||||
- Security headers
|
||||
- Rate limiting
|
||||
|
||||
---
|
||||
|
||||
## 11. Enhanced Templates (Ready to Use)
|
||||
|
||||
I've created two enhanced template files for you:
|
||||
|
||||
1. **`/Users/txeo/Git/yo/cv/templates/index-improved.html`**
|
||||
- All accessibility improvements
|
||||
- Error handling
|
||||
- Browser history management
|
||||
- Keyboard shortcuts
|
||||
- Language preference storage
|
||||
- Loading states
|
||||
- Meta tags and SEO
|
||||
|
||||
2. **`/Users/txeo/Git/yo/cv/static/css/main-enhanced.css`**
|
||||
- Smooth transitions
|
||||
- Error toast styles
|
||||
- Enhanced focus states
|
||||
- Reduced motion support
|
||||
- High contrast mode support
|
||||
- Improved responsive design
|
||||
|
||||
**To apply these improvements:**
|
||||
```bash
|
||||
# Backup current files
|
||||
cp templates/index.html templates/index.html.backup
|
||||
cp static/css/main.css static/css/main.css.backup
|
||||
|
||||
# Apply improvements
|
||||
mv templates/index-improved.html templates/index.html
|
||||
mv static/css/main-enhanced.css static/css/main.css
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 12. Testing Commands
|
||||
|
||||
### Run the site
|
||||
```bash
|
||||
go run main.go
|
||||
```
|
||||
|
||||
### Test HTMX endpoints
|
||||
```bash
|
||||
# Test initial load
|
||||
curl -s 'http://localhost:8080/?lang=en' | head -50
|
||||
|
||||
# Test HTMX partial
|
||||
curl -s 'http://localhost:8080/cv?lang=es' | head -50
|
||||
|
||||
# Test performance
|
||||
curl -o /dev/null -s -w "Time: %{time_total}s\n" 'http://localhost:8080/cv?lang=en'
|
||||
```
|
||||
|
||||
### Run Lighthouse audit
|
||||
```bash
|
||||
# Install if needed
|
||||
npm install -g lighthouse
|
||||
|
||||
# Run audit
|
||||
lighthouse http://localhost:8080/?lang=en --view
|
||||
```
|
||||
|
||||
### Test accessibility
|
||||
```bash
|
||||
# Install axe-cli
|
||||
npm install -g @axe-core/cli
|
||||
|
||||
# Run audit
|
||||
axe http://localhost:8080/?lang=en
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 13. Summary
|
||||
|
||||
### Current Score: 85/100
|
||||
|
||||
**Breakdown:**
|
||||
- **Performance:** 100/100 ✅ (Exceptional sub-ms responses)
|
||||
- **HTMX Patterns:** 90/100 ✅ (Clean, well-structured)
|
||||
- **Accessibility:** 60/100 ⚠️ (Missing ARIA, keyboard nav)
|
||||
- **SEO:** 50/100 ⚠️ (Missing meta tags, structured data)
|
||||
- **Security:** 70/100 ⚠️ (Missing headers, SRI)
|
||||
- **Error Handling:** 40/100 ⚠️ (No user feedback)
|
||||
- **UX:** 80/100 ✅ (Good, needs transitions)
|
||||
|
||||
### Target Score: 100/100
|
||||
|
||||
**With recommended improvements:**
|
||||
- **Performance:** 100/100 ✅
|
||||
- **HTMX Patterns:** 100/100 ✅
|
||||
- **Accessibility:** 95/100 ✅
|
||||
- **SEO:** 95/100 ✅
|
||||
- **Security:** 95/100 ✅
|
||||
- **Error Handling:** 90/100 ✅
|
||||
- **UX:** 95/100 ✅
|
||||
|
||||
---
|
||||
|
||||
## 14. Questions?
|
||||
|
||||
If you need help implementing any of these recommendations:
|
||||
|
||||
1. **Accessibility:** Focus on WCAG 2.1 Level AA compliance
|
||||
2. **HTMX:** Follow htmx.org best practices
|
||||
3. **Go Backend:** Use standard library middleware patterns
|
||||
4. **Testing:** Prioritize automated accessibility and performance tests
|
||||
|
||||
**Resources:**
|
||||
- HTMX Docs: https://htmx.org/docs/
|
||||
- WCAG Guidelines: https://www.w3.org/WAI/WCAG21/quickref/
|
||||
- Go Security: https://go.dev/doc/security/
|
||||
- Lighthouse: https://developers.google.com/web/tools/lighthouse
|
||||
|
||||
---
|
||||
|
||||
**Next Steps:**
|
||||
1. Review this document
|
||||
2. Apply Phase 1 improvements (critical)
|
||||
3. Test with real users
|
||||
4. Iterate based on feedback
|
||||
5. Deploy to production
|
||||
|
||||
Your CV site has an excellent foundation. With these enhancements, it will be a best-in-class example of HTMX implementation! 🚀
|
||||
@@ -0,0 +1,71 @@
|
||||
.PHONY: run build test clean dev prod docker-build docker-run
|
||||
|
||||
# Development
|
||||
dev:
|
||||
@echo "🚀 Starting development server..."
|
||||
GO_ENV=development go run main.go
|
||||
|
||||
# Production
|
||||
prod:
|
||||
@echo "🚀 Starting production server..."
|
||||
GO_ENV=production go run main.go
|
||||
|
||||
# Build
|
||||
build:
|
||||
@echo "🔨 Building binary..."
|
||||
go build -o cv-server -ldflags="-s -w" .
|
||||
@echo "✓ Binary created: ./cv-server"
|
||||
|
||||
# Run built binary
|
||||
run: build
|
||||
./cv-server
|
||||
|
||||
# Test
|
||||
test:
|
||||
@echo "🧪 Testing endpoints..."
|
||||
@echo "\n1. Health check:"
|
||||
@curl -s http://localhost:8080/health | jq .
|
||||
@echo "\n2. English CV (first 50 chars):"
|
||||
@curl -s "http://localhost:8080/?lang=en" | head -c 50
|
||||
@echo "\n\n3. Spanish CV content (first 50 chars):"
|
||||
@curl -s "http://localhost:8080/cv?lang=es" | head -c 50
|
||||
@echo "\n\n4. Security headers:"
|
||||
@curl -I http://localhost:8080/ 2>&1 | grep -E "^(X-|Content-Security)"
|
||||
@echo "\n✓ All tests complete"
|
||||
|
||||
# Test error handling
|
||||
test-errors:
|
||||
@echo "🧪 Testing error handling..."
|
||||
@echo "\n1. Invalid language:"
|
||||
@curl -i "http://localhost:8080/?lang=invalid" 2>&1 | head -15
|
||||
@echo "\n2. Error logging check"
|
||||
@echo "✓ Error tests complete"
|
||||
|
||||
# Clean
|
||||
clean:
|
||||
@echo "🧹 Cleaning build artifacts..."
|
||||
rm -f cv-server
|
||||
@echo "✓ Clean complete"
|
||||
|
||||
# Docker
|
||||
docker-build:
|
||||
@echo "🐳 Building Docker image..."
|
||||
docker build -t cv-server:latest .
|
||||
@echo "✓ Docker image built"
|
||||
|
||||
docker-run:
|
||||
@echo "🐳 Running Docker container..."
|
||||
docker run -p 8080:8080 cv-server:latest
|
||||
|
||||
# Help
|
||||
help:
|
||||
@echo "Available commands:"
|
||||
@echo " make dev - Run in development mode (hot-reload enabled)"
|
||||
@echo " make prod - Run in production mode"
|
||||
@echo " make build - Build production binary"
|
||||
@echo " make run - Build and run binary"
|
||||
@echo " make test - Test all endpoints"
|
||||
@echo " make test-errors - Test error handling"
|
||||
@echo " make clean - Remove build artifacts"
|
||||
@echo " make docker-build - Build Docker image"
|
||||
@echo " make docker-run - Run Docker container"
|
||||
@@ -0,0 +1,516 @@
|
||||
# Quick Start: Critical Improvements
|
||||
|
||||
This guide shows you the fastest path to production-ready status (85% → 95% in ~2 hours).
|
||||
|
||||
## 🚀 30-Minute Priority Fixes
|
||||
|
||||
### 1. Browser History & Transitions (5 minutes)
|
||||
|
||||
**File:** `/Users/txeo/Git/yo/cv/templates/index.html`
|
||||
|
||||
**Find lines 27-42 (language buttons) and update:**
|
||||
|
||||
```html
|
||||
<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">
|
||||
🇬🇧 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">
|
||||
🇪🇸 Español
|
||||
</button>
|
||||
```
|
||||
|
||||
**Changes:**
|
||||
- Added `hx-swap="innerHTML swap:200ms settle:200ms"` (smooth transitions)
|
||||
- Added `hx-push-url="/?lang=XX"` (browser history)
|
||||
|
||||
---
|
||||
|
||||
### 2. ARIA Attributes (10 minutes)
|
||||
|
||||
**File:** `/Users/txeo/Git/yo/cv/templates/index.html`
|
||||
|
||||
**Update the action bar section (lines 24-57):**
|
||||
|
||||
```html
|
||||
<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-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>
|
||||
|
||||
<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"></span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
```
|
||||
|
||||
**Update CV content container (lines 60-64):**
|
||||
|
||||
```html
|
||||
<div class="cv-container">
|
||||
<main id="cv-content"
|
||||
class="cv-paper"
|
||||
role="main"
|
||||
aria-live="polite">
|
||||
{{template "cv-content.html" .}}
|
||||
</main>
|
||||
</div>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 3. Error Handling (10 minutes)
|
||||
|
||||
**File:** `/Users/txeo/Git/yo/cv/templates/index.html`
|
||||
|
||||
**Add before closing `</body>` tag (after footer):**
|
||||
|
||||
```html
|
||||
<!-- Error Toast -->
|
||||
<div id="error-toast" class="error-toast no-print" role="alert" style="display: none;">
|
||||
<span id="error-message"></span>
|
||||
<button onclick="this.parentElement.style.display='none'" aria-label="Close error message">×</button>
|
||||
</div>
|
||||
|
||||
<!-- HTMX Error Handler -->
|
||||
<script>
|
||||
// Global error handler
|
||||
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';
|
||||
|
||||
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' });
|
||||
}
|
||||
});
|
||||
</script>
|
||||
```
|
||||
|
||||
**File:** `/Users/txeo/Git/yo/cv/static/css/main.css`
|
||||
|
||||
**Add at the end of the file:**
|
||||
|
||||
```css
|
||||
/* Error Toast */
|
||||
.error-toast {
|
||||
position: fixed;
|
||||
bottom: 2rem;
|
||||
right: 2rem;
|
||||
background: #fee2e2;
|
||||
color: #dc2626;
|
||||
padding: 1rem 1.5rem;
|
||||
border-radius: 8px;
|
||||
border-left: 4px solid #dc2626;
|
||||
box-shadow: var(--shadow-lg);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
max-width: 400px;
|
||||
z-index: 1000;
|
||||
animation: slideIn 0.2s ease-out;
|
||||
}
|
||||
|
||||
@keyframes slideIn {
|
||||
from {
|
||||
transform: translateX(100%);
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
transform: translateX(0);
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.error-toast button {
|
||||
background: none;
|
||||
border: none;
|
||||
font-size: 1.5rem;
|
||||
color: #dc2626;
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: opacity 0.2s;
|
||||
}
|
||||
|
||||
.error-toast button:hover {
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
/* Smooth transitions */
|
||||
.cv-paper {
|
||||
transition: opacity 200ms;
|
||||
}
|
||||
|
||||
.cv-paper.htmx-swapping {
|
||||
opacity: 0;
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 4. HTMX Configuration (5 minutes)
|
||||
|
||||
**File:** `/Users/txeo/Git/yo/cv/templates/index.html`
|
||||
|
||||
**Add in `<head>` section after meta viewport:**
|
||||
|
||||
```html
|
||||
<!-- HTMX Configuration -->
|
||||
<meta name="htmx-config" content='{"timeout":5000,"defaultSwapStyle":"innerHTML","defaultSwapDelay":0,"defaultSettleDelay":20}'>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## ⏱️ 1-Hour Enhancement: SEO & Meta Tags
|
||||
|
||||
**File:** `/Users/txeo/Git/yo/cv/templates/index.html`
|
||||
|
||||
**Replace entire `<head>` section:**
|
||||
|
||||
```html
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
|
||||
<!-- SEO Meta Tags -->
|
||||
<meta name="description" content="{{.CV.Personal.Name}} - {{.CV.Personal.Title}}">
|
||||
<meta name="keywords" content="CV, Resume, {{.CV.Personal.Name}}, Developer, SAP, AI, HTMX, Go, FullStack">
|
||||
<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}}">
|
||||
|
||||
<!-- Twitter Card -->
|
||||
<meta name="twitter:card" content="summary">
|
||||
<meta name="twitter:title" content="{{.CV.Personal.Name}}">
|
||||
<meta name="twitter:description" content="{{.CV.Personal.Title}}">
|
||||
|
||||
<title>{{.CV.Personal.Name}} - Curriculum Vitae</title>
|
||||
|
||||
<!-- HTMX Configuration -->
|
||||
<meta name="htmx-config" content='{"timeout":5000,"defaultSwapStyle":"innerHTML","defaultSwapDelay":0,"defaultSettleDelay":20}'>
|
||||
|
||||
<!-- HTMX with SRI -->
|
||||
<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">
|
||||
|
||||
<!-- 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}}",
|
||||
"sameAs": [
|
||||
"{{.CV.Personal.LinkedIn}}",
|
||||
"{{.CV.Personal.GitHub}}"
|
||||
],
|
||||
"address": {
|
||||
"@type": "PostalAddress",
|
||||
"addressLocality": "{{.CV.Personal.Location}}"
|
||||
},
|
||||
"email": "{{.CV.Personal.Email}}",
|
||||
"telephone": "{{.CV.Personal.Phone}}"
|
||||
}
|
||||
</script>
|
||||
</head>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🔒 2-Hour Enhancement: Security Headers
|
||||
|
||||
**Create file:** `/Users/txeo/Git/yo/cv/middleware/security.go`
|
||||
|
||||
```go
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"os"
|
||||
)
|
||||
|
||||
// SecurityHeaders adds security headers to all responses
|
||||
func SecurityHeaders(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
// Prevent clickjacking
|
||||
w.Header().Set("X-Frame-Options", "DENY")
|
||||
w.Header().Set("X-Content-Type-Options", "nosniff")
|
||||
w.Header().Set("X-XSS-Protection", "1; mode=block")
|
||||
|
||||
// Content Security Policy
|
||||
csp := "default-src 'self'; " +
|
||||
"script-src 'self' 'unsafe-inline' https://unpkg.com; " +
|
||||
"style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; " +
|
||||
"font-src 'self' https://fonts.gstatic.com; " +
|
||||
"img-src 'self' data:; " +
|
||||
"connect-src 'self'"
|
||||
w.Header().Set("Content-Security-Policy", csp)
|
||||
|
||||
// Referrer Policy
|
||||
w.Header().Set("Referrer-Policy", "strict-origin-when-cross-origin")
|
||||
|
||||
// Permissions Policy
|
||||
w.Header().Set("Permissions-Policy", "geolocation=(), microphone=(), camera=()")
|
||||
|
||||
// HTTPS-only in production
|
||||
if os.Getenv("GO_ENV") == "production" {
|
||||
w.Header().Set("Strict-Transport-Security", "max-age=31536000; includeSubDomains")
|
||||
}
|
||||
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
|
||||
// CORS allows cross-origin requests (if needed)
|
||||
func CORS(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
origin := os.Getenv("ALLOWED_ORIGIN")
|
||||
if origin == "" {
|
||||
origin = "*" // Development only
|
||||
}
|
||||
|
||||
w.Header().Set("Access-Control-Allow-Origin", origin)
|
||||
w.Header().Set("Access-Control-Allow-Methods", "GET, OPTIONS")
|
||||
w.Header().Set("Access-Control-Allow-Headers", "Content-Type")
|
||||
|
||||
if r.Method == "OPTIONS" {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
return
|
||||
}
|
||||
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
```
|
||||
|
||||
**Update file:** `/Users/txeo/Git/yo/cv/main.go`
|
||||
|
||||
**Add imports:**
|
||||
```go
|
||||
import (
|
||||
// ... existing imports
|
||||
"yourproject/middleware" // Update with your module path
|
||||
)
|
||||
```
|
||||
|
||||
**Update main() function to use middleware:**
|
||||
```go
|
||||
func main() {
|
||||
// ... existing setup code
|
||||
|
||||
// Apply middleware
|
||||
http.Handle("/", middleware.SecurityHeaders(http.HandlerFunc(handleHome)))
|
||||
http.Handle("/cv", middleware.SecurityHeaders(http.HandlerFunc(handleCV)))
|
||||
http.Handle("/export/pdf", middleware.SecurityHeaders(http.HandlerFunc(handlePDFExport)))
|
||||
|
||||
// ... rest of main()
|
||||
}
|
||||
```
|
||||
|
||||
**Or create a middleware chain:**
|
||||
```go
|
||||
func main() {
|
||||
// ... existing setup code
|
||||
|
||||
// Create base handler
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("/", handleHome)
|
||||
mux.HandleFunc("/cv", handleCV)
|
||||
mux.HandleFunc("/export/pdf", handlePDFExport)
|
||||
|
||||
// Static files
|
||||
mux.Handle("/static/", http.StripPrefix("/static/", http.FileServer(http.Dir("static"))))
|
||||
|
||||
// Apply middleware chain
|
||||
handler := middleware.SecurityHeaders(
|
||||
middleware.CORS(mux),
|
||||
)
|
||||
|
||||
// ... start server with handler
|
||||
log.Fatal(http.ListenAndServe(":8080", handler))
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## ✅ Testing Your Improvements
|
||||
|
||||
### 1. Test Browser History
|
||||
```bash
|
||||
# Start server
|
||||
go run main.go
|
||||
|
||||
# Open browser, click language buttons
|
||||
# Press browser back button - should work!
|
||||
```
|
||||
|
||||
### 2. Test Error Handling
|
||||
```bash
|
||||
# Stop the server
|
||||
# In browser, click language button
|
||||
# Should see error toast!
|
||||
```
|
||||
|
||||
### 3. Test Accessibility
|
||||
```bash
|
||||
# Use keyboard only:
|
||||
# Tab to language buttons
|
||||
# Press Enter to activate
|
||||
# Tab to export button
|
||||
# Press Enter to print
|
||||
```
|
||||
|
||||
### 4. Test Security Headers
|
||||
```bash
|
||||
curl -I http://localhost:8080/
|
||||
# Should see security headers in response
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📊 Before vs After
|
||||
|
||||
### Before (Current)
|
||||
- ❌ No browser history on language change
|
||||
- ❌ No error handling
|
||||
- ❌ Limited accessibility
|
||||
- ⚠️ Missing SEO meta tags
|
||||
- ⚠️ No security headers
|
||||
- ✅ Excellent performance
|
||||
|
||||
### After (30 minutes)
|
||||
- ✅ Browser history works
|
||||
- ✅ Error handling with toast
|
||||
- ✅ ARIA attributes for accessibility
|
||||
- ✅ Smooth transitions
|
||||
- ✅ HTMX timeout configured
|
||||
- ✅ Still excellent performance
|
||||
|
||||
### After (2 hours)
|
||||
- ✅ All of the above PLUS:
|
||||
- ✅ Complete SEO meta tags
|
||||
- ✅ Structured data (JSON-LD)
|
||||
- ✅ Security headers
|
||||
- ✅ SRI for external scripts
|
||||
- ✅ Production-ready!
|
||||
|
||||
---
|
||||
|
||||
## 🎯 Next Steps
|
||||
|
||||
1. **Apply 30-minute fixes** ← Start here!
|
||||
2. **Test in browser**
|
||||
3. **Apply 1-hour SEO enhancements**
|
||||
4. **Apply 2-hour security enhancements**
|
||||
5. **Run Lighthouse audit**
|
||||
6. **Deploy to production!**
|
||||
|
||||
---
|
||||
|
||||
## 💡 Pro Tips
|
||||
|
||||
1. **Backup first:**
|
||||
```bash
|
||||
cp templates/index.html templates/index.html.backup
|
||||
cp static/css/main.css static/css/main.css.backup
|
||||
```
|
||||
|
||||
2. **Test incrementally:**
|
||||
- Apply one fix at a time
|
||||
- Test in browser
|
||||
- Commit to git
|
||||
- Move to next fix
|
||||
|
||||
3. **Use the enhanced templates:**
|
||||
```bash
|
||||
# I've already created fully enhanced versions:
|
||||
mv templates/index-improved.html templates/index.html
|
||||
mv static/css/main-enhanced.css static/css/main.css
|
||||
```
|
||||
|
||||
4. **Validate with tools:**
|
||||
- Lighthouse: `lighthouse http://localhost:8080`
|
||||
- WAVE: Install browser extension
|
||||
- axe DevTools: Install browser extension
|
||||
|
||||
---
|
||||
|
||||
## 🚀 Ready to Go!
|
||||
|
||||
These quick fixes will take you from **85% → 95% production-ready** in just 30 minutes!
|
||||
|
||||
For the complete guide, see: `HTMX-PRODUCTION-RECOMMENDATIONS.md`
|
||||
@@ -0,0 +1,57 @@
|
||||
# CV Site - Go + HTMX
|
||||
|
||||
**Modern, minimal curriculum vitae website** for Juan Andrés Moreno Rubio built with **Go** and **HTMX**.
|
||||
|
||||
## 🚀 Features
|
||||
|
||||
- ✅ **Bilingual Support** - Spanish and English with instant switching (no page reload)
|
||||
- ✅ **PDF Export** - Print-optimized design for PDF generation via browser
|
||||
- ✅ **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
|
||||
|
||||
## 📋 Quick Start
|
||||
|
||||
### Prerequisites
|
||||
|
||||
- **Go 1.21+** installed
|
||||
|
||||
### Run
|
||||
|
||||
\`\`\`bash
|
||||
# Build and run
|
||||
go build -o cv-server && ./cv-server
|
||||
\`\`\`
|
||||
|
||||
Open **http://localhost:8080**
|
||||
|
||||
- 🇬🇧 English: http://localhost:8080/?lang=en
|
||||
- 🇪🇸 Spanish: http://localhost:8080/?lang=es
|
||||
|
||||
## 📄 Updating Your CV
|
||||
|
||||
Edit JSON files in `data/`:
|
||||
- **English**: `data/cv-en.json`
|
||||
- **Spanish**: `data/cv-es.json`
|
||||
|
||||
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
|
||||
|
||||
## 🎯 Key Technologies
|
||||
|
||||
- Backend: **Go** (stdlib net/http)
|
||||
- Frontend: **HTMX** 1.9.10
|
||||
- Styling: Custom **CSS**
|
||||
- Data: **JSON** files
|
||||
|
||||
---
|
||||
|
||||
**Built with ❤️ using Go, HTMX, and AI assistance**
|
||||
+550
@@ -0,0 +1,550 @@
|
||||
{
|
||||
"personal": {
|
||||
"name": "Juan Andrés Moreno Rubio",
|
||||
"title": "Lead Technical Consultant, FullStack Developer",
|
||||
"location": "Arrecife, Las Palmas de Gran Canaria, Spain",
|
||||
"email": "txeo.msx@gmail.com",
|
||||
"phone": "+34 676875420",
|
||||
"dateOfBirth": "1980-03-02",
|
||||
"placeOfBirth": "Plasencia (Cáceres), Spain",
|
||||
"citizenship": "Spanish",
|
||||
"linkedin": "https://www.linkedin.com/in/juan-andres-moreno-rubio",
|
||||
"github": "https://github.com/juanatsap",
|
||||
"behance": "https://www.behance.net/txeo",
|
||||
"website": "https://juan.andres.morenoyrubio.com",
|
||||
"photo": "/static/images/profile.jpg"
|
||||
},
|
||||
"summary": "Technical Consultant, Fullstack Developer, and AI enthusiast with 18 years of experience in the IT industry, specializing in SAP Customer Data Cloud, web technologies (mainly React and Node ecosystem), and AI integrations. Proven track record of leading technical projects and providing guidance to over 35 international clients. Seeking opportunities to apply and expand my skills in a challenging and rewarding environment.",
|
||||
"experience": [
|
||||
{
|
||||
"position": "Senior SAP Technical Consultant",
|
||||
"company": "Olympic Broadcasting Services",
|
||||
"location": "Madrid, Spain",
|
||||
"startDate": "2021-01",
|
||||
"endDate": "present",
|
||||
"current": true,
|
||||
"responsibilities": [
|
||||
"Assessed business requirements to create focused solutions, mainly with SAP Customer Data Cloud (CDC)",
|
||||
"Custom implementations and data treatment for international broadcasting events",
|
||||
"Meetings, guidance and troubleshooting for technical teams",
|
||||
"Led integration of SAP CDC across multiple Olympic event platforms"
|
||||
],
|
||||
"technologies": [
|
||||
"SAP CDC",
|
||||
"JavaScript",
|
||||
"React",
|
||||
"Node.js",
|
||||
"API Integration"
|
||||
]
|
||||
},
|
||||
{
|
||||
"position": "Senior Technical Consultant",
|
||||
"company": "AENA (via Accenture Spain)",
|
||||
"location": "Madrid, Spain",
|
||||
"startDate": "2021-10",
|
||||
"endDate": "2023-07",
|
||||
"current": false,
|
||||
"responsibilities": [
|
||||
"Analyzed client business processes to propose optimal software applications for unique requirements",
|
||||
"Provided expertise for SAP Customer Data Cloud and integrated the product into AENA ecosystem",
|
||||
"Enhanced interfaces to promote better functionality for users across all Spanish airports",
|
||||
"Developed diagrams to describe and lay out logical operational steps",
|
||||
"Developed software for web and mobile operating systems",
|
||||
"Lead Technical Consultant & Main Developer for AENA Airports Authentication System (https://usuarios.aena.es)",
|
||||
"Implemented identity user-related flows for main websites and apps serving millions of passengers"
|
||||
],
|
||||
"technologies": [
|
||||
"SAP CDC",
|
||||
"React",
|
||||
"Node.js",
|
||||
"API Development",
|
||||
"Authentication Systems",
|
||||
"Mobile Development"
|
||||
],
|
||||
"highlights": [
|
||||
"Successfully deployed authentication system for all AENA airports in Spain",
|
||||
"Managed identity flows for millions of users across web and mobile platforms"
|
||||
]
|
||||
},
|
||||
{
|
||||
"position": "Senior Technical Consultant",
|
||||
"company": "SAP",
|
||||
"location": "Barcelona, Spain",
|
||||
"startDate": "2019-10",
|
||||
"endDate": "2021-10",
|
||||
"current": false,
|
||||
"responsibilities": [
|
||||
"Analyzed client business processes to propose optimal software applications for unique requirements",
|
||||
"Assessed business requirements to create focused solutions",
|
||||
"Troubleshot incidents reported by end-users to schedule system changes and identify permanent solutions",
|
||||
"Educated stakeholders on data protection tactics to reduce breaches (GDPR compliance)",
|
||||
"Offered input for complex documents to support client-ready final versions",
|
||||
"Provided technical consulting for SAP Customer Data Cloud implementations"
|
||||
],
|
||||
"technologies": [
|
||||
"SAP CDC",
|
||||
"GDPR Compliance",
|
||||
"JavaScript",
|
||||
"Cloud Platforms",
|
||||
"Technical Documentation"
|
||||
]
|
||||
},
|
||||
{
|
||||
"position": "Junior Technical Consultant",
|
||||
"company": "Gigya",
|
||||
"location": "Barcelona, Spain",
|
||||
"startDate": "2017-10",
|
||||
"endDate": "2019-10",
|
||||
"current": false,
|
||||
"responsibilities": [
|
||||
"Responded to customer inquiries and provided technical assistance over the phone and in person",
|
||||
"Monitored system performance to identify potential issues",
|
||||
"Offered assistance in implementing and developing training programs",
|
||||
"Researched and identified solutions to technical problems",
|
||||
"Collaborated with vendors to locate replacement components and resolve advanced problems",
|
||||
"Assisted in the development of system security protocols"
|
||||
],
|
||||
"technologies": [
|
||||
"Gigya Platform",
|
||||
"JavaScript",
|
||||
"Customer Support",
|
||||
"System Monitoring"
|
||||
]
|
||||
},
|
||||
{
|
||||
"position": "Fullstack Developer",
|
||||
"company": "Megabanner",
|
||||
"location": "Barcelona, Spain",
|
||||
"startDate": "2016-12",
|
||||
"endDate": "2017-08",
|
||||
"current": false,
|
||||
"responsibilities": [
|
||||
"Rapidly prototyped new data processing capabilities to confirm integration feasibility into existing systems",
|
||||
"Integrated with a video system for the inclusion of advertisements into gas station networks",
|
||||
"Built databases and table structures for web applications",
|
||||
"Translated technical concepts and information into terms parties could easily comprehend"
|
||||
],
|
||||
"technologies": [
|
||||
"React",
|
||||
"Node.js",
|
||||
"Video Processing",
|
||||
"Database Design",
|
||||
"PostgreSQL"
|
||||
]
|
||||
},
|
||||
{
|
||||
"position": "Fullstack Developer",
|
||||
"company": "Ebantic",
|
||||
"location": "Barcelona, Spain",
|
||||
"startDate": "2016-09",
|
||||
"endDate": "2017-04",
|
||||
"current": false,
|
||||
"responsibilities": [
|
||||
"Worked with back-end developers to design APIs",
|
||||
"Oversaw and implemented automated build and deployment pipelines",
|
||||
"Analyzed existing software implementations to identify areas requiring improvement",
|
||||
"Tested functional compliance of company products",
|
||||
"Tested and deployed scalable and highly available software products"
|
||||
],
|
||||
"technologies": [
|
||||
"React",
|
||||
"Node.js",
|
||||
"API Design",
|
||||
"CI/CD",
|
||||
"DevOps"
|
||||
]
|
||||
},
|
||||
{
|
||||
"position": "FullStack Developer",
|
||||
"company": "Everis",
|
||||
"location": "Barcelona, Spain",
|
||||
"startDate": "2016-04",
|
||||
"endDate": "2016-11",
|
||||
"current": false,
|
||||
"responsibilities": [
|
||||
"Created two React applications for two different clients",
|
||||
"Implemented modern frontend architectures with React ecosystem"
|
||||
],
|
||||
"technologies": [
|
||||
"React",
|
||||
"JavaScript",
|
||||
"Redux",
|
||||
"Webpack"
|
||||
]
|
||||
},
|
||||
{
|
||||
"position": "Fullstack Developer",
|
||||
"company": "Indra",
|
||||
"location": "Barcelona, Spain",
|
||||
"startDate": "2015-09",
|
||||
"endDate": "2016-02",
|
||||
"current": false,
|
||||
"responsibilities": [
|
||||
"Discussed project progress with customers, collected feedback on different stages",
|
||||
"Directly addressed customer concerns and implemented solutions"
|
||||
],
|
||||
"technologies": [
|
||||
"Java",
|
||||
"JavaScript",
|
||||
"Web Development"
|
||||
]
|
||||
},
|
||||
{
|
||||
"position": "Technical Director / Programmer",
|
||||
"company": "Emailing Network S.R.L.",
|
||||
"location": "Barcelona, Spain",
|
||||
"startDate": "2012-11",
|
||||
"endDate": "2015-06",
|
||||
"current": false,
|
||||
"responsibilities": [
|
||||
"Development of a backend and 5 satellite websites to allow online sales and email marketing communications",
|
||||
"Guided, coached and led project teams, delegating tasks and evaluating performance",
|
||||
"Oversaw product pipeline development, reducing production times by 75%",
|
||||
"Collaborated with leadership staff to determine appropriate budgets"
|
||||
],
|
||||
"technologies": [
|
||||
"PHP",
|
||||
"MySQL",
|
||||
"JavaScript",
|
||||
"Email Marketing Systems",
|
||||
"E-commerce"
|
||||
],
|
||||
"highlights": [
|
||||
"Reduced production times by 75% through optimized pipelines",
|
||||
"Successfully managed technical team and product development"
|
||||
]
|
||||
},
|
||||
{
|
||||
"position": "Programmer Analyst (Freelance)",
|
||||
"company": "TwenTiC + ALTEN",
|
||||
"location": "Barcelona, Spain",
|
||||
"startDate": "2012-05",
|
||||
"endDate": "2012-10",
|
||||
"current": false,
|
||||
"responsibilities": [
|
||||
"Construction of several websites using WordPress and PHP",
|
||||
"Custom theme and plugin development"
|
||||
],
|
||||
"technologies": [
|
||||
"WordPress",
|
||||
"PHP",
|
||||
"MySQL",
|
||||
"JavaScript"
|
||||
]
|
||||
},
|
||||
{
|
||||
"position": "Analyst Programmer / Expert Technician",
|
||||
"company": "Penta MSI",
|
||||
"location": "Barcelona, Spain",
|
||||
"startDate": "2010-10",
|
||||
"endDate": "2011-11",
|
||||
"current": false,
|
||||
"responsibilities": [
|
||||
"Configured and tested new software and hardware",
|
||||
"Researched and identified solutions to technical problems",
|
||||
"Mentored new co-workers"
|
||||
],
|
||||
"technologies": [
|
||||
"Java",
|
||||
"System Configuration",
|
||||
"Technical Support"
|
||||
]
|
||||
},
|
||||
{
|
||||
"position": "Senior Programmer",
|
||||
"company": "Homeria + WebRatio S.R.L.",
|
||||
"location": "Cáceres (Spain) / Como (Italy)",
|
||||
"startDate": "2008-01",
|
||||
"endDate": "2008-12",
|
||||
"current": false,
|
||||
"responsibilities": [
|
||||
"Worked on a European project in a revolutionary search engine",
|
||||
"Skilled at working independently and collaboratively in a team environment",
|
||||
"Learned and adapted quickly to new technology and software applications"
|
||||
],
|
||||
"technologies": [
|
||||
"Java",
|
||||
"Search Engine Technology",
|
||||
"European R&D Projects"
|
||||
]
|
||||
},
|
||||
{
|
||||
"position": "Junior Programmer",
|
||||
"company": "Insa",
|
||||
"location": "Cáceres, Spain",
|
||||
"startDate": "2006-09",
|
||||
"endDate": "2008-01",
|
||||
"current": false,
|
||||
"responsibilities": [
|
||||
"Wrote applications in JAVA architecture for various industries, being specialized in data chart generation",
|
||||
"Developed 3 different types of JAVA applets",
|
||||
"Debugged and modified JAVA software components"
|
||||
],
|
||||
"technologies": [
|
||||
"Java",
|
||||
"Java Applets",
|
||||
"Data Visualization",
|
||||
"Chart Generation"
|
||||
]
|
||||
}
|
||||
],
|
||||
"education": [
|
||||
{
|
||||
"degree": "Computing Engineering, Bachelor's Degree",
|
||||
"institution": "Universidad de Extremadura",
|
||||
"location": "Cáceres, Spain",
|
||||
"startDate": "1999-09",
|
||||
"endDate": "2009-02",
|
||||
"field": "Computer Science and Engineering"
|
||||
}
|
||||
],
|
||||
"skills": {
|
||||
"technical": [
|
||||
{
|
||||
"category": "AI & Modern Development",
|
||||
"proficiency": 5,
|
||||
"items": [
|
||||
"AI-Assisted Development (Claude Code, Copilot, GPT-4)",
|
||||
"Prompt Engineering & AI Workflows",
|
||||
"HTMX (Hypermedia Applications)",
|
||||
"Tailwind CSS",
|
||||
"Go (Golang)",
|
||||
"OpenAI & Anthropic APIs"
|
||||
]
|
||||
},
|
||||
{
|
||||
"category": "JavaScript Ecosystem",
|
||||
"proficiency": 5,
|
||||
"items": [
|
||||
"Advanced JavaScript (ES6+)",
|
||||
"React & React Ecosystem",
|
||||
"Node.js & Express",
|
||||
"Webpack, Vite, Modern Build Tools"
|
||||
]
|
||||
},
|
||||
{
|
||||
"category": "Web Development",
|
||||
"proficiency": 5,
|
||||
"items": [
|
||||
"HTML5, CSS3, Semantic Web",
|
||||
"REST API Design & Development",
|
||||
"LESS, SASS, CSS Preprocessors",
|
||||
"Responsive & Mobile-First Design"
|
||||
]
|
||||
},
|
||||
{
|
||||
"category": "Backend Technologies",
|
||||
"proficiency": 4,
|
||||
"items": [
|
||||
"Node.js (Express, Modern frameworks)",
|
||||
"Go (Golang)",
|
||||
"Java & J2EE",
|
||||
"Spring Framework, Struts, Hibernate",
|
||||
"PHP"
|
||||
]
|
||||
},
|
||||
{
|
||||
"category": "Databases",
|
||||
"proficiency": 4,
|
||||
"items": [
|
||||
"PostgreSQL",
|
||||
"MySQL",
|
||||
"Oracle",
|
||||
"MongoDB (NoSQL)",
|
||||
"Database Design & Optimization"
|
||||
]
|
||||
},
|
||||
{
|
||||
"category": "SAP Technologies",
|
||||
"proficiency": 5,
|
||||
"items": [
|
||||
"SAP Customer Data Cloud (CDC)",
|
||||
"SAP Cloud Platform",
|
||||
"GDPR Compliance & Data Protection"
|
||||
]
|
||||
},
|
||||
{
|
||||
"category": "DevOps & Tools",
|
||||
"proficiency": 4,
|
||||
"items": [
|
||||
"Git (Version Control)",
|
||||
"CI/CD Pipelines",
|
||||
"Docker",
|
||||
"Automated Testing",
|
||||
"Agile Methodologies"
|
||||
]
|
||||
}
|
||||
],
|
||||
"soft_skills": [
|
||||
"Leadership & Team Management",
|
||||
"Technical Documentation",
|
||||
"Problem-Solving & Critical Thinking",
|
||||
"Business Consulting",
|
||||
"On-Site Technical Support",
|
||||
"Training & Mentoring",
|
||||
"Client Relationship Management",
|
||||
"Flexibility & Adaptability",
|
||||
"Marketing & Resource Management"
|
||||
]
|
||||
},
|
||||
"languages": [
|
||||
{
|
||||
"language": "Spanish",
|
||||
"proficiency": "Native",
|
||||
"level": 5
|
||||
},
|
||||
{
|
||||
"language": "English",
|
||||
"proficiency": "Professional Working Proficiency",
|
||||
"level": 4
|
||||
},
|
||||
{
|
||||
"language": "Italian",
|
||||
"proficiency": "Intermediate",
|
||||
"level": 3
|
||||
}
|
||||
],
|
||||
"projects": [
|
||||
{
|
||||
"name": "AENA Airports Authentication System",
|
||||
"role": "Lead Technical Consultant & Main Developer",
|
||||
"url": "https://usuarios.aena.es",
|
||||
"period": "2021-2023",
|
||||
"description": "Complete authentication and identity management system for all AENA airports in Spain. Handles millions of users across web and mobile platforms.",
|
||||
"technologies": [
|
||||
"SAP CDC",
|
||||
"React",
|
||||
"Node.js",
|
||||
"Authentication",
|
||||
"Mobile"
|
||||
],
|
||||
"highlights": [
|
||||
"Deployed across all Spanish airports",
|
||||
"Handles millions of user authentications",
|
||||
"Integrated with multiple AENA digital platforms"
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "SAP Customer Data Cloud Starter Kit",
|
||||
"role": "Main Contributor",
|
||||
"url": "https://github.com/gigya/cdc-starter-kit",
|
||||
"period": "2019-2021",
|
||||
"description": "Simple front-end template for building fast, robust, and adaptable web apps or sites, including SAP CDC capabilities. Open-source contribution.",
|
||||
"technologies": [
|
||||
"SAP CDC",
|
||||
"React",
|
||||
"JavaScript",
|
||||
"Template Development"
|
||||
],
|
||||
"highlights": [
|
||||
"Open-source contribution to SAP ecosystem",
|
||||
"Used by developers worldwide",
|
||||
"Simplifies SAP CDC integration"
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "AI-Powered Development Workflows",
|
||||
"role": "Independent Research & Development",
|
||||
"period": "2023 - Present",
|
||||
"description": "Pioneered AI-assisted development workflows using Claude Code and modern tools. Successfully experimented with migrating projects from React to HTMX+Go architecture, reducing complexity while maintaining functionality.",
|
||||
"technologies": [
|
||||
"Claude Code",
|
||||
"HTMX",
|
||||
"Go",
|
||||
"Tailwind CSS",
|
||||
"AI APIs",
|
||||
"Prompt Engineering"
|
||||
],
|
||||
"highlights": [
|
||||
"Reduced development time by 60% using AI-assisted workflows",
|
||||
"Modernized legacy applications with AI guidance",
|
||||
"Created reusable patterns for HTMX + Go development"
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "React & Node.js Projects",
|
||||
"role": "Technical Lead & Developer",
|
||||
"period": "2015-2017",
|
||||
"description": "Multiple projects for clients including Megabanner, Cepsa, Cazatucasa",
|
||||
"technologies": [
|
||||
"React",
|
||||
"Node.js",
|
||||
"JavaScript",
|
||||
"API Development"
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "Java Enterprise Projects",
|
||||
"role": "Technical Lead & Developer",
|
||||
"period": "2008-2015",
|
||||
"description": "Enterprise applications including Portic.net Regular Lines, III and IV Awards of Music in Extremadura",
|
||||
"technologies": [
|
||||
"Java",
|
||||
"J2EE",
|
||||
"Spring",
|
||||
"Hibernate"
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "PHP & WordPress Projects",
|
||||
"role": "Web Developer",
|
||||
"period": "2012-2015",
|
||||
"description": "Multiple web projects including Oferting, Emailing Network, Coupon&Go, Clicplan, Lidering, Delivery Bikes BCN, Jorpack, Gourmet Bus, Moreno y Rubio, Mobbeel, Las Peruchas",
|
||||
"technologies": [
|
||||
"PHP",
|
||||
"WordPress",
|
||||
"MySQL",
|
||||
"JavaScript"
|
||||
]
|
||||
}
|
||||
],
|
||||
"awards": [
|
||||
{
|
||||
"title": "Best Comparison Service with Clicplan",
|
||||
"issuer": "eAwards",
|
||||
"date": "2013-05",
|
||||
"description": "Recognition for excellence in comparison service development"
|
||||
},
|
||||
{
|
||||
"title": "Project Construction Scholarship for drolosoft",
|
||||
"issuer": "Junta de Extremadura",
|
||||
"date": "2009-08",
|
||||
"description": "Scholarship for innovative software project development"
|
||||
},
|
||||
{
|
||||
"title": "Scholarship to work at TESEO Software Factory",
|
||||
"issuer": "Universidad de Extremadura",
|
||||
"date": "2004-04",
|
||||
"description": "Academic scholarship for software development work"
|
||||
}
|
||||
],
|
||||
"certifications": [
|
||||
{
|
||||
"name": "SAP CDC Full Training",
|
||||
"issuer": "SAP",
|
||||
"date": "2019-05",
|
||||
"description": "Complete training on SAP Customer Data Cloud platform"
|
||||
},
|
||||
{
|
||||
"name": "SAP Cloud Platform Learning Program",
|
||||
"issuer": "SAP",
|
||||
"date": "2019-02",
|
||||
"description": "Comprehensive SAP Cloud Platform certification"
|
||||
},
|
||||
{
|
||||
"name": "GDPR Compliance and Regulations Training",
|
||||
"issuer": "Gigya",
|
||||
"date": "2018-03",
|
||||
"description": "Data protection and GDPR compliance certification"
|
||||
}
|
||||
],
|
||||
"other": {
|
||||
"driverLicense": "Type C"
|
||||
},
|
||||
"meta": {
|
||||
"version": "2024",
|
||||
"lastUpdated": "2024-10-18",
|
||||
"format": "JSON Resume Extended",
|
||||
"language": "en"
|
||||
}
|
||||
}
|
||||
+550
@@ -0,0 +1,550 @@
|
||||
{
|
||||
"personal": {
|
||||
"name": "Juan Andrés Moreno Rubio",
|
||||
"title": "Consultor Técnico Senior, Desarrollador FullStack",
|
||||
"location": "Arrecife, Las Palmas de Gran Canaria, España",
|
||||
"email": "txeo.msx@gmail.com",
|
||||
"phone": "+34 676875420",
|
||||
"dateOfBirth": "1980-03-02",
|
||||
"placeOfBirth": "Plasencia (Cáceres), España",
|
||||
"citizenship": "Española",
|
||||
"linkedin": "https://www.linkedin.com/in/juan-andres-moreno-rubio",
|
||||
"github": "https://github.com/juanatsap",
|
||||
"behance": "https://www.behance.net/txeo",
|
||||
"website": "https://juan.andres.morenoyrubio.com",
|
||||
"photo": "/static/images/profile.jpg"
|
||||
},
|
||||
"summary": "Consultor Técnico, Desarrollador Fullstack, y entusiasta de IA con 18 años de experiencia en la industria IT, especializado en SAP Customer Data Cloud, tecnologías web (principalmente React y el ecosistema Node), e integraciones de IA. Historial comprobado liderando proyectos técnicos y proporcionando orientación a más de 35 clientes internacionales. Buscando oportunidades para aplicar y expandir mis habilidades en un entorno desafiante y gratificante.",
|
||||
"experience": [
|
||||
{
|
||||
"position": "Consultor Técnico Senior SAP",
|
||||
"company": "Olympic Broadcasting Services",
|
||||
"location": "Madrid, España",
|
||||
"startDate": "2021-01",
|
||||
"endDate": "presente",
|
||||
"current": true,
|
||||
"responsibilities": [
|
||||
"Evaluación de requisitos de negocio para crear soluciones enfocadas, principalmente con SAP Customer Data Cloud (CDC)",
|
||||
"Implementaciones personalizadas y tratamiento de datos para eventos de transmisión internacional",
|
||||
"Reuniones, orientación y resolución de problemas para equipos técnicos",
|
||||
"Lideré la integración de SAP CDC en múltiples plataformas de eventos olímpicos"
|
||||
],
|
||||
"technologies": [
|
||||
"SAP CDC",
|
||||
"JavaScript",
|
||||
"React",
|
||||
"Node.js",
|
||||
"Integración de APIs"
|
||||
]
|
||||
},
|
||||
{
|
||||
"position": "Consultor Técnico Senior",
|
||||
"company": "AENA (vía Accenture Spain)",
|
||||
"location": "Madrid, España",
|
||||
"startDate": "2021-10",
|
||||
"endDate": "2023-07",
|
||||
"current": false,
|
||||
"responsibilities": [
|
||||
"Analicé procesos de negocio del cliente para proponer aplicaciones de software óptimas para requisitos únicos",
|
||||
"Proporcioné experiencia en SAP Customer Data Cloud e integré el producto en el ecosistema AENA",
|
||||
"Mejoré interfaces para promover mejor funcionalidad para usuarios en todos los aeropuertos españoles",
|
||||
"Desarrollé diagramas para describir y detallar pasos operacionales lógicos",
|
||||
"Desarrollé software para sistemas operativos web y móviles",
|
||||
"Consultor Técnico Principal y Desarrollador Principal del Sistema de Autenticación de Aeropuertos AENA (https://usuarios.aena.es)",
|
||||
"Implementé flujos relacionados con identidad de usuarios para sitios web y aplicaciones principales que sirven a millones de pasajeros"
|
||||
],
|
||||
"technologies": [
|
||||
"SAP CDC",
|
||||
"React",
|
||||
"Node.js",
|
||||
"Desarrollo de APIs",
|
||||
"Sistemas de Autenticación",
|
||||
"Desarrollo Móvil"
|
||||
],
|
||||
"highlights": [
|
||||
"Despliegue exitoso del sistema de autenticación para todos los aeropuertos AENA en España",
|
||||
"Gestión de flujos de identidad para millones de usuarios en plataformas web y móviles"
|
||||
]
|
||||
},
|
||||
{
|
||||
"position": "Consultor Técnico Senior",
|
||||
"company": "SAP",
|
||||
"location": "Barcelona, España",
|
||||
"startDate": "2019-10",
|
||||
"endDate": "2021-10",
|
||||
"current": false,
|
||||
"responsibilities": [
|
||||
"Analicé procesos de negocio del cliente para proponer aplicaciones de software óptimas para requisitos únicos",
|
||||
"Evalué requisitos de negocio para crear soluciones enfocadas",
|
||||
"Resolví incidentes reportados por usuarios finales para programar cambios de sistema e identificar soluciones permanentes",
|
||||
"Eduqué a las partes interesadas sobre tácticas de protección de datos para reducir brechas (cumplimiento GDPR)",
|
||||
"Ofrecí aporte para documentos complejos para apoyar versiones finales listas para clientes",
|
||||
"Proporcioné consultoría técnica para implementaciones de SAP Customer Data Cloud"
|
||||
],
|
||||
"technologies": [
|
||||
"SAP CDC",
|
||||
"Cumplimiento GDPR",
|
||||
"JavaScript",
|
||||
"Plataformas Cloud",
|
||||
"Documentación Técnica"
|
||||
]
|
||||
},
|
||||
{
|
||||
"position": "Consultor Técnico Junior",
|
||||
"company": "Gigya",
|
||||
"location": "Barcelona, España",
|
||||
"startDate": "2017-10",
|
||||
"endDate": "2019-10",
|
||||
"current": false,
|
||||
"responsibilities": [
|
||||
"Respondí a consultas de clientes y proporcioné asistencia técnica por teléfono y en persona",
|
||||
"Monitoricé el rendimiento del sistema para identificar problemas potenciales",
|
||||
"Ofrecí asistencia en la implementación y desarrollo de programas de formación",
|
||||
"Investigué e identifiqué soluciones a problemas técnicos",
|
||||
"Colaboré con proveedores para localizar componentes de reemplazo y resolver problemas avanzados",
|
||||
"Asistí en el desarrollo de protocolos de seguridad del sistema"
|
||||
],
|
||||
"technologies": [
|
||||
"Plataforma Gigya",
|
||||
"JavaScript",
|
||||
"Soporte al Cliente",
|
||||
"Monitoreo de Sistemas"
|
||||
]
|
||||
},
|
||||
{
|
||||
"position": "Desarrollador Fullstack",
|
||||
"company": "Megabanner",
|
||||
"location": "Barcelona, España",
|
||||
"startDate": "2016-12",
|
||||
"endDate": "2017-08",
|
||||
"current": false,
|
||||
"responsibilities": [
|
||||
"Prototipé rápidamente nuevas capacidades de procesamiento de datos para confirmar viabilidad de integración en sistemas existentes",
|
||||
"Integré con un sistema de video para la inclusión de anuncios en redes de estaciones de servicio",
|
||||
"Construí bases de datos y estructuras de tablas para aplicaciones web",
|
||||
"Traduje conceptos técnicos e información en términos que las partes pudieran comprender fácilmente"
|
||||
],
|
||||
"technologies": [
|
||||
"React",
|
||||
"Node.js",
|
||||
"Procesamiento de Video",
|
||||
"Diseño de Bases de Datos",
|
||||
"PostgreSQL"
|
||||
]
|
||||
},
|
||||
{
|
||||
"position": "Desarrollador Fullstack",
|
||||
"company": "Ebantic",
|
||||
"location": "Barcelona, España",
|
||||
"startDate": "2016-09",
|
||||
"endDate": "2017-04",
|
||||
"current": false,
|
||||
"responsibilities": [
|
||||
"Trabajé con desarrolladores back-end para diseñar APIs",
|
||||
"Supervisé e implementé pipelines de construcción y despliegue automatizados",
|
||||
"Analicé implementaciones de software existentes para identificar áreas que requieren mejora",
|
||||
"Probé el cumplimiento funcional de productos de la empresa",
|
||||
"Probé y desplegué productos de software escalables y altamente disponibles"
|
||||
],
|
||||
"technologies": [
|
||||
"React",
|
||||
"Node.js",
|
||||
"Diseño de APIs",
|
||||
"CI/CD",
|
||||
"DevOps"
|
||||
]
|
||||
},
|
||||
{
|
||||
"position": "Desarrollador FullStack",
|
||||
"company": "Everis",
|
||||
"location": "Barcelona, España",
|
||||
"startDate": "2016-04",
|
||||
"endDate": "2016-11",
|
||||
"current": false,
|
||||
"responsibilities": [
|
||||
"Creé dos aplicaciones React para dos clientes diferentes",
|
||||
"Implementé arquitecturas frontend modernas con el ecosistema React"
|
||||
],
|
||||
"technologies": [
|
||||
"React",
|
||||
"JavaScript",
|
||||
"Redux",
|
||||
"Webpack"
|
||||
]
|
||||
},
|
||||
{
|
||||
"position": "Desarrollador Fullstack",
|
||||
"company": "Indra",
|
||||
"location": "Barcelona, España",
|
||||
"startDate": "2015-09",
|
||||
"endDate": "2016-02",
|
||||
"current": false,
|
||||
"responsibilities": [
|
||||
"Discutí el progreso del proyecto con clientes, recopilé comentarios en diferentes etapas",
|
||||
"Abordé directamente las preocupaciones del cliente e implementé soluciones"
|
||||
],
|
||||
"technologies": [
|
||||
"Java",
|
||||
"JavaScript",
|
||||
"Desarrollo Web"
|
||||
]
|
||||
},
|
||||
{
|
||||
"position": "Director Técnico / Programador",
|
||||
"company": "Emailing Network S.R.L.",
|
||||
"location": "Barcelona, España",
|
||||
"startDate": "2012-11",
|
||||
"endDate": "2015-06",
|
||||
"current": false,
|
||||
"responsibilities": [
|
||||
"Desarrollo de un backend y 5 sitios web satélite para permitir ventas en línea y comunicaciones de email marketing",
|
||||
"Guié, asesoré y lideré equipos de proyecto, delegando tareas y evaluando el rendimiento",
|
||||
"Supervisé el desarrollo de pipeline de productos, reduciendo los tiempos de producción en un 75%",
|
||||
"Colaboré con el personal de liderazgo para determinar presupuestos apropiados"
|
||||
],
|
||||
"technologies": [
|
||||
"PHP",
|
||||
"MySQL",
|
||||
"JavaScript",
|
||||
"Sistemas de Email Marketing",
|
||||
"E-commerce"
|
||||
],
|
||||
"highlights": [
|
||||
"Reducción del 75% en tiempos de producción mediante pipelines optimizados",
|
||||
"Gestión exitosa de equipo técnico y desarrollo de productos"
|
||||
]
|
||||
},
|
||||
{
|
||||
"position": "Analista Programador (Freelance)",
|
||||
"company": "TwenTiC + ALTEN",
|
||||
"location": "Barcelona, España",
|
||||
"startDate": "2012-05",
|
||||
"endDate": "2012-10",
|
||||
"current": false,
|
||||
"responsibilities": [
|
||||
"Construcción de varios sitios web usando WordPress y PHP",
|
||||
"Desarrollo de temas y plugins personalizados"
|
||||
],
|
||||
"technologies": [
|
||||
"WordPress",
|
||||
"PHP",
|
||||
"MySQL",
|
||||
"JavaScript"
|
||||
]
|
||||
},
|
||||
{
|
||||
"position": "Analista Programador / Técnico Experto",
|
||||
"company": "Penta MSI",
|
||||
"location": "Barcelona, España",
|
||||
"startDate": "2010-10",
|
||||
"endDate": "2011-11",
|
||||
"current": false,
|
||||
"responsibilities": [
|
||||
"Configuré y probé nuevo software y hardware",
|
||||
"Investigué e identifiqué soluciones a problemas técnicos",
|
||||
"Asesoré a nuevos compañeros de trabajo"
|
||||
],
|
||||
"technologies": [
|
||||
"Java",
|
||||
"Configuración de Sistemas",
|
||||
"Soporte Técnico"
|
||||
]
|
||||
},
|
||||
{
|
||||
"position": "Programador Senior",
|
||||
"company": "Homeria + WebRatio S.R.L.",
|
||||
"location": "Cáceres (España) / Como (Italia)",
|
||||
"startDate": "2008-01",
|
||||
"endDate": "2008-12",
|
||||
"current": false,
|
||||
"responsibilities": [
|
||||
"Trabajé en un proyecto europeo en un motor de búsqueda revolucionario",
|
||||
"Habilidad para trabajar independientemente y colaborativamente en un entorno de equipo",
|
||||
"Aprendí y me adapté rápidamente a nuevas tecnologías y aplicaciones de software"
|
||||
],
|
||||
"technologies": [
|
||||
"Java",
|
||||
"Tecnología de Motores de Búsqueda",
|
||||
"Proyectos Europeos I+D"
|
||||
]
|
||||
},
|
||||
{
|
||||
"position": "Programador Junior",
|
||||
"company": "Insa",
|
||||
"location": "Cáceres, España",
|
||||
"startDate": "2006-09",
|
||||
"endDate": "2008-01",
|
||||
"current": false,
|
||||
"responsibilities": [
|
||||
"Escribí aplicaciones en arquitectura JAVA para varias industrias, especializándome en generación de gráficos de datos",
|
||||
"Desarrollé 3 tipos diferentes de applets JAVA",
|
||||
"Depuré y modifiqué componentes de software JAVA"
|
||||
],
|
||||
"technologies": [
|
||||
"Java",
|
||||
"Applets Java",
|
||||
"Visualización de Datos",
|
||||
"Generación de Gráficos"
|
||||
]
|
||||
}
|
||||
],
|
||||
"education": [
|
||||
{
|
||||
"degree": "Ingeniería Informática, Grado",
|
||||
"institution": "Universidad de Extremadura",
|
||||
"location": "Cáceres, España",
|
||||
"startDate": "1999-09",
|
||||
"endDate": "2009-02",
|
||||
"field": "Ciencias de la Computación e Ingeniería"
|
||||
}
|
||||
],
|
||||
"skills": {
|
||||
"technical": [
|
||||
{
|
||||
"category": "IA y Desarrollo Moderno",
|
||||
"proficiency": 5,
|
||||
"items": [
|
||||
"Desarrollo Asistido por IA (Claude Code, Copilot, GPT-4)",
|
||||
"Ingeniería de Prompts y Flujos de Trabajo con IA",
|
||||
"HTMX (Aplicaciones Hipermedia)",
|
||||
"Tailwind CSS",
|
||||
"Go (Golang)",
|
||||
"APIs OpenAI y Anthropic"
|
||||
]
|
||||
},
|
||||
{
|
||||
"category": "Ecosistema JavaScript",
|
||||
"proficiency": 5,
|
||||
"items": [
|
||||
"JavaScript Avanzado (ES6+)",
|
||||
"React y Ecosistema React",
|
||||
"Node.js y Express",
|
||||
"Webpack, Vite, Herramientas de Build Modernas"
|
||||
]
|
||||
},
|
||||
{
|
||||
"category": "Desarrollo Web",
|
||||
"proficiency": 5,
|
||||
"items": [
|
||||
"HTML5, CSS3, Web Semántica",
|
||||
"Diseño y Desarrollo de APIs REST",
|
||||
"LESS, SASS, Preprocesadores CSS",
|
||||
"Diseño Responsive y Mobile-First"
|
||||
]
|
||||
},
|
||||
{
|
||||
"category": "Tecnologías Backend",
|
||||
"proficiency": 4,
|
||||
"items": [
|
||||
"Node.js (Express, frameworks modernos)",
|
||||
"Go (Golang)",
|
||||
"Java y J2EE",
|
||||
"Spring Framework, Struts, Hibernate",
|
||||
"PHP"
|
||||
]
|
||||
},
|
||||
{
|
||||
"category": "Bases de Datos",
|
||||
"proficiency": 4,
|
||||
"items": [
|
||||
"PostgreSQL",
|
||||
"MySQL",
|
||||
"Oracle",
|
||||
"MongoDB (NoSQL)",
|
||||
"Diseño y Optimización de Bases de Datos"
|
||||
]
|
||||
},
|
||||
{
|
||||
"category": "Tecnologías SAP",
|
||||
"proficiency": 5,
|
||||
"items": [
|
||||
"SAP Customer Data Cloud (CDC)",
|
||||
"SAP Cloud Platform",
|
||||
"Cumplimiento GDPR y Protección de Datos"
|
||||
]
|
||||
},
|
||||
{
|
||||
"category": "DevOps y Herramientas",
|
||||
"proficiency": 4,
|
||||
"items": [
|
||||
"Git (Control de Versiones)",
|
||||
"Pipelines CI/CD",
|
||||
"Docker",
|
||||
"Testing Automatizado",
|
||||
"Metodologías Ágiles"
|
||||
]
|
||||
}
|
||||
],
|
||||
"soft_skills": [
|
||||
"Liderazgo y Gestión de Equipos",
|
||||
"Documentación Técnica",
|
||||
"Resolución de Problemas y Pensamiento Crítico",
|
||||
"Consultoría de Negocio",
|
||||
"Soporte Técnico On-Site",
|
||||
"Formación y Mentoría",
|
||||
"Gestión de Relaciones con Clientes",
|
||||
"Flexibilidad y Adaptabilidad",
|
||||
"Marketing y Gestión de Recursos"
|
||||
]
|
||||
},
|
||||
"languages": [
|
||||
{
|
||||
"language": "Español",
|
||||
"proficiency": "Nativo",
|
||||
"level": 5
|
||||
},
|
||||
{
|
||||
"language": "Inglés",
|
||||
"proficiency": "Profesional Avanzado",
|
||||
"level": 4
|
||||
},
|
||||
{
|
||||
"language": "Italiano",
|
||||
"proficiency": "Intermedio",
|
||||
"level": 3
|
||||
}
|
||||
],
|
||||
"projects": [
|
||||
{
|
||||
"name": "Sistema de Autenticación de Aeropuertos AENA",
|
||||
"role": "Consultor Técnico Principal y Desarrollador Principal",
|
||||
"url": "https://usuarios.aena.es",
|
||||
"period": "2021-2023",
|
||||
"description": "Sistema completo de autenticación y gestión de identidad para todos los aeropuertos AENA en España. Gestiona millones de usuarios en plataformas web y móviles.",
|
||||
"technologies": [
|
||||
"SAP CDC",
|
||||
"React",
|
||||
"Node.js",
|
||||
"Autenticación",
|
||||
"Móvil"
|
||||
],
|
||||
"highlights": [
|
||||
"Desplegado en todos los aeropuertos españoles",
|
||||
"Gestiona millones de autenticaciones de usuarios",
|
||||
"Integrado con múltiples plataformas digitales AENA"
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "SAP Customer Data Cloud Starter Kit",
|
||||
"role": "Contribuidor Principal",
|
||||
"url": "https://github.com/gigya/cdc-starter-kit",
|
||||
"period": "2019-2021",
|
||||
"description": "Plantilla front-end simple para construir aplicaciones o sitios web rápidos, robustos y adaptables, incluyendo capacidades SAP CDC. Contribución de código abierto.",
|
||||
"technologies": [
|
||||
"SAP CDC",
|
||||
"React",
|
||||
"JavaScript",
|
||||
"Desarrollo de Plantillas"
|
||||
],
|
||||
"highlights": [
|
||||
"Contribución de código abierto al ecosistema SAP",
|
||||
"Usado por desarrolladores en todo el mundo",
|
||||
"Simplifica la integración de SAP CDC"
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "Flujos de Trabajo de Desarrollo Potenciados por IA",
|
||||
"role": "Investigación y Desarrollo Independiente",
|
||||
"period": "2023 - Presente",
|
||||
"description": "Desarrollo pionero de flujos de trabajo asistidos por IA usando Claude Code y herramientas modernas. Experimentación exitosa con migración de proyectos de arquitectura React a HTMX+Go, reduciendo complejidad mientras se mantiene funcionalidad.",
|
||||
"technologies": [
|
||||
"Claude Code",
|
||||
"HTMX",
|
||||
"Go",
|
||||
"Tailwind CSS",
|
||||
"APIs IA",
|
||||
"Ingeniería de Prompts"
|
||||
],
|
||||
"highlights": [
|
||||
"Reducción del 60% en tiempo de desarrollo usando flujos de trabajo asistidos por IA",
|
||||
"Modernización de aplicaciones legacy con guía de IA",
|
||||
"Creación de patrones reutilizables para desarrollo HTMX + Go"
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "Proyectos React y Node.js",
|
||||
"role": "Líder Técnico y Desarrollador",
|
||||
"period": "2015-2017",
|
||||
"description": "Múltiples proyectos para clientes incluyendo Megabanner, Cepsa, Cazatucasa",
|
||||
"technologies": [
|
||||
"React",
|
||||
"Node.js",
|
||||
"JavaScript",
|
||||
"Desarrollo de APIs"
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "Proyectos Java Enterprise",
|
||||
"role": "Líder Técnico y Desarrollador",
|
||||
"period": "2008-2015",
|
||||
"description": "Aplicaciones empresariales incluyendo Portic.net Regular Lines, III y IV Premios de Música en Extremadura",
|
||||
"technologies": [
|
||||
"Java",
|
||||
"J2EE",
|
||||
"Spring",
|
||||
"Hibernate"
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "Proyectos PHP y WordPress",
|
||||
"role": "Desarrollador Web",
|
||||
"period": "2012-2015",
|
||||
"description": "Múltiples proyectos web incluyendo Oferting, Emailing Network, Coupon&Go, Clicplan, Lidering, Delivery Bikes BCN, Jorpack, Gourmet Bus, Moreno y Rubio, Mobbeel, Las Peruchas",
|
||||
"technologies": [
|
||||
"PHP",
|
||||
"WordPress",
|
||||
"MySQL",
|
||||
"JavaScript"
|
||||
]
|
||||
}
|
||||
],
|
||||
"awards": [
|
||||
{
|
||||
"title": "Mejor Servicio de Comparación con Clicplan",
|
||||
"issuer": "eAwards",
|
||||
"date": "2013-05",
|
||||
"description": "Reconocimiento por excelencia en desarrollo de servicio de comparación"
|
||||
},
|
||||
{
|
||||
"title": "Beca de Construcción de Proyecto para drolosoft",
|
||||
"issuer": "Junta de Extremadura",
|
||||
"date": "2009-08",
|
||||
"description": "Beca para desarrollo de proyecto de software innovador"
|
||||
},
|
||||
{
|
||||
"title": "Beca para trabajar en Fábrica de Software TESEO",
|
||||
"issuer": "Universidad de Extremadura",
|
||||
"date": "2004-04",
|
||||
"description": "Beca académica para trabajo de desarrollo de software"
|
||||
}
|
||||
],
|
||||
"certifications": [
|
||||
{
|
||||
"name": "SAP CDC Full Training",
|
||||
"issuer": "SAP",
|
||||
"date": "2019-05",
|
||||
"description": "Formación completa en plataforma SAP Customer Data Cloud"
|
||||
},
|
||||
{
|
||||
"name": "SAP Cloud Platform Learning Program",
|
||||
"issuer": "SAP",
|
||||
"date": "2019-02",
|
||||
"description": "Certificación integral de SAP Cloud Platform"
|
||||
},
|
||||
{
|
||||
"name": "GDPR Compliance and Regulations Training",
|
||||
"issuer": "Gigya",
|
||||
"date": "2018-03",
|
||||
"description": "Certificación de protección de datos y cumplimiento GDPR"
|
||||
}
|
||||
],
|
||||
"other": {
|
||||
"driverLicense": "Tipo C"
|
||||
},
|
||||
"meta": {
|
||||
"version": "2024",
|
||||
"lastUpdated": "2024-10-18",
|
||||
"format": "JSON Resume Extended",
|
||||
"language": "es"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"strconv"
|
||||
)
|
||||
|
||||
// Config holds all application configuration
|
||||
type Config struct {
|
||||
Server ServerConfig
|
||||
Template TemplateConfig
|
||||
Data DataConfig
|
||||
}
|
||||
|
||||
// ServerConfig contains server-specific settings
|
||||
type ServerConfig struct {
|
||||
Port string
|
||||
Host string
|
||||
ReadTimeout int
|
||||
WriteTimeout int
|
||||
}
|
||||
|
||||
// TemplateConfig contains template-specific settings
|
||||
type TemplateConfig struct {
|
||||
Dir string
|
||||
PartialsDir string
|
||||
HotReload bool
|
||||
}
|
||||
|
||||
// DataConfig contains data directory settings
|
||||
type DataConfig struct {
|
||||
Dir string
|
||||
}
|
||||
|
||||
// Load creates a new Config with values from environment or defaults
|
||||
func Load() *Config {
|
||||
return &Config{
|
||||
Server: ServerConfig{
|
||||
Port: getEnv("PORT", "8080"),
|
||||
Host: getEnv("HOST", "localhost"),
|
||||
ReadTimeout: getEnvAsInt("READ_TIMEOUT", 15),
|
||||
WriteTimeout: getEnvAsInt("WRITE_TIMEOUT", 15),
|
||||
},
|
||||
Template: TemplateConfig{
|
||||
Dir: getEnv("TEMPLATE_DIR", "templates"),
|
||||
PartialsDir: getEnv("PARTIALS_DIR", "templates/partials"),
|
||||
HotReload: getEnvAsBool("TEMPLATE_HOT_RELOAD", isDevelopment()),
|
||||
},
|
||||
Data: DataConfig{
|
||||
Dir: getEnv("DATA_DIR", "data"),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// Address returns the server address in host:port format
|
||||
func (c *Config) Address() string {
|
||||
return fmt.Sprintf("%s:%s", c.Server.Host, c.Server.Port)
|
||||
}
|
||||
|
||||
// Helper functions
|
||||
|
||||
func getEnv(key, defaultValue string) string {
|
||||
if value := os.Getenv(key); value != "" {
|
||||
return value
|
||||
}
|
||||
return defaultValue
|
||||
}
|
||||
|
||||
func getEnvAsInt(key string, defaultValue int) int {
|
||||
valueStr := os.Getenv(key)
|
||||
if value, err := strconv.Atoi(valueStr); err == nil {
|
||||
return value
|
||||
}
|
||||
return defaultValue
|
||||
}
|
||||
|
||||
func getEnvAsBool(key string, defaultValue bool) bool {
|
||||
valueStr := os.Getenv(key)
|
||||
if value, err := strconv.ParseBool(valueStr); err == nil {
|
||||
return value
|
||||
}
|
||||
return defaultValue
|
||||
}
|
||||
|
||||
func isDevelopment() bool {
|
||||
env := getEnv("GO_ENV", "development")
|
||||
return env == "development" || env == "dev"
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/juanatsap/cv-site/internal/models"
|
||||
"github.com/juanatsap/cv-site/internal/templates"
|
||||
)
|
||||
|
||||
// CVHandler handles CV-related requests
|
||||
type CVHandler struct {
|
||||
templates *templates.Manager
|
||||
}
|
||||
|
||||
// NewCVHandler creates a new CV handler
|
||||
func NewCVHandler(tmpl *templates.Manager) *CVHandler {
|
||||
return &CVHandler{
|
||||
templates: tmpl,
|
||||
}
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
|
||||
// Prepare template data
|
||||
data := map[string]interface{}{
|
||||
"CV": cv,
|
||||
"Lang": lang,
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
|
||||
// Prepare template data
|
||||
data := map[string]interface{}{
|
||||
"CV": cv,
|
||||
"Lang": lang,
|
||||
}
|
||||
|
||||
// Render template
|
||||
tmpl, err := h.templates.Render("cv-content.html")
|
||||
if err != nil {
|
||||
HandleError(w, r, TemplateError(err, "cv-content.html"))
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
if err := tmpl.Execute(w, data); err != nil {
|
||||
HandleError(w, r, TemplateError(err, "cv-content.html"))
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// ExportPDF handles PDF export requests
|
||||
// For now, redirects to print-friendly version
|
||||
// In production, integrate with chromedp or similar for actual PDF generation
|
||||
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"
|
||||
}
|
||||
|
||||
// Redirect to print-friendly version
|
||||
// The browser's print dialog will handle PDF generation
|
||||
http.Redirect(w, r, "/?lang="+lang+"&print=true", http.StatusSeeOther)
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"log"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
// ErrorResponse represents a structured error response
|
||||
type ErrorResponse struct {
|
||||
Error string `json:"error"`
|
||||
Message string `json:"message,omitempty"`
|
||||
Code int `json:"code"`
|
||||
}
|
||||
|
||||
// AppError represents an application error with context
|
||||
type AppError struct {
|
||||
Err error
|
||||
Message string
|
||||
StatusCode int
|
||||
Internal bool // If true, don't expose details to client
|
||||
}
|
||||
|
||||
// Error implements the error interface
|
||||
func (e *AppError) Error() string {
|
||||
if e.Err != nil {
|
||||
return e.Err.Error()
|
||||
}
|
||||
return e.Message
|
||||
}
|
||||
|
||||
// NewAppError creates a new application error
|
||||
func NewAppError(err error, message string, statusCode int, internal bool) *AppError {
|
||||
return &AppError{
|
||||
Err: err,
|
||||
Message: message,
|
||||
StatusCode: statusCode,
|
||||
Internal: internal,
|
||||
}
|
||||
}
|
||||
|
||||
// HandleError handles errors consistently across the application
|
||||
func HandleError(w http.ResponseWriter, r *http.Request, err error) {
|
||||
var appErr *AppError
|
||||
|
||||
// Check if it's an AppError
|
||||
switch e := err.(type) {
|
||||
case *AppError:
|
||||
appErr = e
|
||||
default:
|
||||
// Unknown error - treat as internal server error
|
||||
appErr = NewAppError(err, "Internal Server Error", http.StatusInternalServerError, true)
|
||||
}
|
||||
|
||||
// Log the error
|
||||
if appErr.Internal {
|
||||
log.Printf("ERROR [%s %s]: %v", r.Method, r.URL.Path, appErr.Err)
|
||||
} else {
|
||||
log.Printf("CLIENT ERROR [%s %s]: %s (status: %d)", r.Method, r.URL.Path, appErr.Message, appErr.StatusCode)
|
||||
}
|
||||
|
||||
// Determine response based on Accept header
|
||||
accept := r.Header.Get("Accept")
|
||||
isJSON := accept == "application/json"
|
||||
isHTMX := r.Header.Get("HX-Request") != ""
|
||||
|
||||
if isJSON {
|
||||
// JSON response
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(appErr.StatusCode)
|
||||
|
||||
response := ErrorResponse{
|
||||
Error: http.StatusText(appErr.StatusCode),
|
||||
Code: appErr.StatusCode,
|
||||
}
|
||||
|
||||
// Only include message if not internal
|
||||
if !appErr.Internal {
|
||||
response.Message = appErr.Message
|
||||
}
|
||||
|
||||
json.NewEncoder(w).Encode(response)
|
||||
return
|
||||
}
|
||||
|
||||
if isHTMX {
|
||||
// HTMX response - return simple HTML fragment
|
||||
w.Header().Set("Content-Type", "text/html")
|
||||
w.WriteHeader(appErr.StatusCode)
|
||||
|
||||
message := appErr.Message
|
||||
if appErr.Internal {
|
||||
message = "An error occurred. Please try again later."
|
||||
}
|
||||
|
||||
w.Write([]byte("<div class='error'>" + message + "</div>"))
|
||||
return
|
||||
}
|
||||
|
||||
// Standard HTTP error response
|
||||
message := appErr.Message
|
||||
if appErr.Internal {
|
||||
message = "Internal Server Error"
|
||||
}
|
||||
|
||||
http.Error(w, message, appErr.StatusCode)
|
||||
}
|
||||
|
||||
// Common error constructors
|
||||
|
||||
func NotFoundError(message string) *AppError {
|
||||
return NewAppError(nil, message, http.StatusNotFound, false)
|
||||
}
|
||||
|
||||
func BadRequestError(message string) *AppError {
|
||||
return NewAppError(nil, message, http.StatusBadRequest, false)
|
||||
}
|
||||
|
||||
func InternalError(err error) *AppError {
|
||||
return NewAppError(err, "Internal Server Error", http.StatusInternalServerError, true)
|
||||
}
|
||||
|
||||
func TemplateError(err error, templateName string) *AppError {
|
||||
return NewAppError(
|
||||
err,
|
||||
"Error rendering template: "+templateName,
|
||||
http.StatusInternalServerError,
|
||||
true,
|
||||
)
|
||||
}
|
||||
|
||||
func DataLoadError(err error, dataType string) *AppError {
|
||||
return NewAppError(
|
||||
err,
|
||||
"Error loading "+dataType+" data",
|
||||
http.StatusInternalServerError,
|
||||
true,
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"time"
|
||||
)
|
||||
|
||||
// HealthResponse represents the health check response
|
||||
type HealthResponse struct {
|
||||
Status string `json:"status"`
|
||||
Timestamp time.Time `json:"timestamp"`
|
||||
Version string `json:"version"`
|
||||
}
|
||||
|
||||
// HealthHandler handles health check requests
|
||||
type HealthHandler struct {
|
||||
version string
|
||||
}
|
||||
|
||||
// NewHealthHandler creates a new health handler
|
||||
func NewHealthHandler(version string) *HealthHandler {
|
||||
return &HealthHandler{
|
||||
version: version,
|
||||
}
|
||||
}
|
||||
|
||||
// Check performs a health check
|
||||
func (h *HealthHandler) Check(w http.ResponseWriter, r *http.Request) {
|
||||
response := HealthResponse{
|
||||
Status: "ok",
|
||||
Timestamp: time.Now(),
|
||||
Version: h.version,
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
json.NewEncoder(w).Encode(response)
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"log"
|
||||
"net/http"
|
||||
"time"
|
||||
)
|
||||
|
||||
// responseWriter wraps http.ResponseWriter to capture status code
|
||||
type responseWriter struct {
|
||||
http.ResponseWriter
|
||||
status int
|
||||
written int64
|
||||
wroteHeader bool
|
||||
}
|
||||
|
||||
func (rw *responseWriter) WriteHeader(code int) {
|
||||
if !rw.wroteHeader {
|
||||
rw.status = code
|
||||
rw.ResponseWriter.WriteHeader(code)
|
||||
rw.wroteHeader = true
|
||||
}
|
||||
}
|
||||
|
||||
func (rw *responseWriter) Write(b []byte) (int, error) {
|
||||
if !rw.wroteHeader {
|
||||
rw.WriteHeader(http.StatusOK)
|
||||
}
|
||||
n, err := rw.ResponseWriter.Write(b)
|
||||
rw.written += int64(n)
|
||||
return n, err
|
||||
}
|
||||
|
||||
// Logger logs HTTP requests with method, path, status, and duration
|
||||
func Logger(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
start := time.Now()
|
||||
|
||||
// Wrap response writer
|
||||
wrapped := &responseWriter{
|
||||
ResponseWriter: w,
|
||||
status: http.StatusOK,
|
||||
}
|
||||
|
||||
// Process request
|
||||
next.ServeHTTP(wrapped, r)
|
||||
|
||||
// Log request
|
||||
duration := time.Since(start)
|
||||
log.Printf(
|
||||
"[%s] %s %s - %d (%v)",
|
||||
r.Method,
|
||||
r.URL.Path,
|
||||
r.RemoteAddr,
|
||||
wrapped.status,
|
||||
duration,
|
||||
)
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"log"
|
||||
"net/http"
|
||||
"runtime/debug"
|
||||
)
|
||||
|
||||
// Recovery recovers from panics and logs the error with stack trace
|
||||
func Recovery(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
defer func() {
|
||||
if err := recover(); err != nil {
|
||||
// Log the panic with stack trace
|
||||
log.Printf("PANIC: %v\n%s", err, debug.Stack())
|
||||
|
||||
// Return 500 Internal Server Error
|
||||
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
|
||||
}
|
||||
}()
|
||||
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
package middleware
|
||||
|
||||
import "net/http"
|
||||
|
||||
// SecurityHeaders adds common security headers to responses
|
||||
func SecurityHeaders(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
// Prevent clickjacking
|
||||
w.Header().Set("X-Frame-Options", "SAMEORIGIN")
|
||||
|
||||
// Prevent MIME type sniffing
|
||||
w.Header().Set("X-Content-Type-Options", "nosniff")
|
||||
|
||||
// XSS Protection (legacy but still useful)
|
||||
w.Header().Set("X-XSS-Protection", "1; mode=block")
|
||||
|
||||
// Referrer policy
|
||||
w.Header().Set("Referrer-Policy", "strict-origin-when-cross-origin")
|
||||
|
||||
// Content Security Policy (adjust as needed)
|
||||
w.Header().Set("Content-Security-Policy",
|
||||
"default-src 'self'; "+
|
||||
"script-src 'self' 'unsafe-inline' https://unpkg.com; "+
|
||||
"style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; "+
|
||||
"font-src 'self' https://fonts.gstatic.com; "+
|
||||
"connect-src 'self'")
|
||||
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,151 @@
|
||||
package models
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
)
|
||||
|
||||
// CV represents the complete curriculum vitae structure
|
||||
type CV struct {
|
||||
Personal Personal `json:"personal"`
|
||||
Summary string `json:"summary"`
|
||||
Experience []Experience `json:"experience"`
|
||||
AIDevelopment AIDevelopment `json:"ai_development"`
|
||||
Education []Education `json:"education"`
|
||||
Skills Skills `json:"skills"`
|
||||
Languages []Language `json:"languages"`
|
||||
Projects []Project `json:"projects"`
|
||||
Awards []Award `json:"awards"`
|
||||
Certifications []Certification `json:"certifications"`
|
||||
Other Other `json:"other"`
|
||||
Meta Meta `json:"meta"`
|
||||
}
|
||||
|
||||
type Personal struct {
|
||||
Name string `json:"name"`
|
||||
Title string `json:"title"`
|
||||
Location string `json:"location"`
|
||||
Email string `json:"email"`
|
||||
Phone string `json:"phone"`
|
||||
DateOfBirth string `json:"dateOfBirth"`
|
||||
PlaceOfBirth string `json:"placeOfBirth"`
|
||||
Citizenship string `json:"citizenship"`
|
||||
LinkedIn string `json:"linkedin"`
|
||||
GitHub string `json:"github"`
|
||||
Behance string `json:"behance"`
|
||||
Website string `json:"website"`
|
||||
Photo string `json:"photo"`
|
||||
}
|
||||
|
||||
type Experience struct {
|
||||
Position string `json:"position"`
|
||||
Company string `json:"company"`
|
||||
Location string `json:"location"`
|
||||
StartDate string `json:"startDate"`
|
||||
EndDate string `json:"endDate"`
|
||||
Current bool `json:"current"`
|
||||
Responsibilities []string `json:"responsibilities"`
|
||||
Technologies []string `json:"technologies"`
|
||||
Highlights []string `json:"highlights"`
|
||||
}
|
||||
|
||||
type AIDevelopment struct {
|
||||
Title string `json:"title"`
|
||||
Period string `json:"period"`
|
||||
Description string `json:"description"`
|
||||
Skills []AISkill `json:"skills"`
|
||||
Achievements []string `json:"achievements"`
|
||||
}
|
||||
|
||||
type AISkill struct {
|
||||
Category string `json:"category"`
|
||||
Proficiency string `json:"proficiency"`
|
||||
Items []string `json:"items"`
|
||||
}
|
||||
|
||||
type Education struct {
|
||||
Degree string `json:"degree"`
|
||||
Institution string `json:"institution"`
|
||||
Location string `json:"location"`
|
||||
StartDate string `json:"startDate"`
|
||||
EndDate string `json:"endDate"`
|
||||
Field string `json:"field"`
|
||||
}
|
||||
|
||||
type Skills struct {
|
||||
Technical []SkillCategory `json:"technical"`
|
||||
SoftSkills []string `json:"soft_skills"`
|
||||
}
|
||||
|
||||
type SkillCategory struct {
|
||||
Category string `json:"category"`
|
||||
Proficiency int `json:"proficiency"`
|
||||
Items []string `json:"items"`
|
||||
}
|
||||
|
||||
type Language struct {
|
||||
Language string `json:"language"`
|
||||
Proficiency string `json:"proficiency"`
|
||||
Level int `json:"level"`
|
||||
}
|
||||
|
||||
type Project struct {
|
||||
Name string `json:"name"`
|
||||
Role string `json:"role"`
|
||||
URL string `json:"url"`
|
||||
Period string `json:"period"`
|
||||
Description string `json:"description"`
|
||||
Technologies []string `json:"technologies"`
|
||||
Highlights []string `json:"highlights"`
|
||||
}
|
||||
|
||||
type Award struct {
|
||||
Title string `json:"title"`
|
||||
Issuer string `json:"issuer"`
|
||||
Date string `json:"date"`
|
||||
Description string `json:"description"`
|
||||
}
|
||||
|
||||
type Certification struct {
|
||||
Name string `json:"name"`
|
||||
Issuer string `json:"issuer"`
|
||||
Date string `json:"date"`
|
||||
Description string `json:"description"`
|
||||
}
|
||||
|
||||
type Other struct {
|
||||
DriverLicense string `json:"driverLicense"`
|
||||
}
|
||||
|
||||
type Meta struct {
|
||||
Version string `json:"version"`
|
||||
LastUpdated string `json:"lastUpdated"`
|
||||
Format string `json:"format"`
|
||||
Language string `json:"language"`
|
||||
}
|
||||
|
||||
// LoadCV loads CV data from a JSON file for the specified language
|
||||
func LoadCV(lang string) (*CV, error) {
|
||||
// Validate language
|
||||
if lang != "en" && lang != "es" {
|
||||
return nil, fmt.Errorf("unsupported language: %s", lang)
|
||||
}
|
||||
|
||||
// Determine which JSON file to load
|
||||
filename := fmt.Sprintf("data/cv-%s.json", lang)
|
||||
|
||||
// Read the JSON file
|
||||
data, err := os.ReadFile(filename)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("error reading file %s: %w", filename, err)
|
||||
}
|
||||
|
||||
// Parse JSON
|
||||
var cv CV
|
||||
if err := json.Unmarshal(data, &cv); err != nil {
|
||||
return nil, fmt.Errorf("error parsing JSON: %w", err)
|
||||
}
|
||||
|
||||
return &cv, nil
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
package templates
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"html/template"
|
||||
"log"
|
||||
"path/filepath"
|
||||
"sync"
|
||||
|
||||
"github.com/juanatsap/cv-site/internal/config"
|
||||
)
|
||||
|
||||
// Manager handles template parsing and rendering
|
||||
type Manager struct {
|
||||
templates *template.Template
|
||||
config *config.TemplateConfig
|
||||
mu sync.RWMutex
|
||||
}
|
||||
|
||||
// NewManager creates a new template manager
|
||||
func NewManager(cfg *config.TemplateConfig) (*Manager, error) {
|
||||
m := &Manager{
|
||||
config: cfg,
|
||||
}
|
||||
|
||||
if err := m.loadTemplates(); err != nil {
|
||||
return nil, fmt.Errorf("failed to load templates: %w", err)
|
||||
}
|
||||
|
||||
return m, nil
|
||||
}
|
||||
|
||||
// loadTemplates parses all templates from the configured directory
|
||||
func (m *Manager) loadTemplates() error {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
|
||||
// Create template with custom functions
|
||||
funcMap := template.FuncMap{
|
||||
"iterate": func(count int) []int {
|
||||
var result []int
|
||||
for i := 0; i < count; i++ {
|
||||
result = append(result, i)
|
||||
}
|
||||
return result
|
||||
},
|
||||
"eq": func(a, b string) bool {
|
||||
return a == b
|
||||
},
|
||||
}
|
||||
|
||||
// Parse main templates
|
||||
pattern := filepath.Join(m.config.Dir, "*.html")
|
||||
tmpl, err := template.New("").Funcs(funcMap).ParseGlob(pattern)
|
||||
if err != nil {
|
||||
return fmt.Errorf("error parsing templates from %s: %w", pattern, err)
|
||||
}
|
||||
|
||||
// Try to parse partials if they exist
|
||||
partialsPattern := filepath.Join(m.config.PartialsDir, "*.html")
|
||||
partials, _ := filepath.Glob(partialsPattern)
|
||||
if len(partials) > 0 {
|
||||
tmpl, err = tmpl.ParseGlob(partialsPattern)
|
||||
if err != nil {
|
||||
log.Printf("Warning: error parsing partials: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
m.templates = tmpl
|
||||
log.Printf("✓ Templates loaded successfully from %s", m.config.Dir)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Reload reloads all templates (useful for hot-reload in development)
|
||||
func (m *Manager) Reload() error {
|
||||
return m.loadTemplates()
|
||||
}
|
||||
|
||||
// Render executes a template with the given data
|
||||
func (m *Manager) Render(name string) (*template.Template, error) {
|
||||
// Hot reload in development mode
|
||||
if m.config.HotReload {
|
||||
if err := m.Reload(); err != nil {
|
||||
log.Printf("Warning: template reload failed: %v", err)
|
||||
// Continue with cached templates
|
||||
}
|
||||
}
|
||||
|
||||
m.mu.RLock()
|
||||
defer m.mu.RUnlock()
|
||||
|
||||
tmpl := m.templates.Lookup(name)
|
||||
if tmpl == nil {
|
||||
return nil, fmt.Errorf("template %q not found", name)
|
||||
}
|
||||
|
||||
return tmpl, nil
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/signal"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"github.com/juanatsap/cv-site/internal/config"
|
||||
"github.com/juanatsap/cv-site/internal/handlers"
|
||||
"github.com/juanatsap/cv-site/internal/middleware"
|
||||
"github.com/juanatsap/cv-site/internal/templates"
|
||||
)
|
||||
|
||||
const version = "1.0.0"
|
||||
|
||||
func main() {
|
||||
// Initialize logger
|
||||
log.SetFlags(log.LstdFlags | log.Lshortfile)
|
||||
log.Println("🚀 Starting CV Server v" + version)
|
||||
|
||||
// Load configuration
|
||||
cfg := config.Load()
|
||||
log.Printf("✓ Configuration loaded (env: %s)", os.Getenv("GO_ENV"))
|
||||
|
||||
// Initialize template manager
|
||||
templateMgr, err := templates.NewManager(&cfg.Template)
|
||||
if err != nil {
|
||||
log.Fatalf("❌ Failed to initialize templates: %v", err)
|
||||
}
|
||||
|
||||
// Initialize handlers
|
||||
cvHandler := handlers.NewCVHandler(templateMgr)
|
||||
healthHandler := handlers.NewHealthHandler(version)
|
||||
|
||||
// Setup router
|
||||
mux := http.NewServeMux()
|
||||
|
||||
// Routes
|
||||
mux.HandleFunc("/", cvHandler.Home)
|
||||
mux.HandleFunc("/cv", cvHandler.CVContent)
|
||||
mux.HandleFunc("/export/pdf", cvHandler.ExportPDF)
|
||||
mux.HandleFunc("/health", healthHandler.Check)
|
||||
|
||||
// Static files with cache control
|
||||
staticHandler := http.StripPrefix("/static/", http.FileServer(http.Dir("static")))
|
||||
mux.Handle("/static/", cacheControl(staticHandler))
|
||||
|
||||
// Apply middleware chain
|
||||
handler := middleware.Recovery(
|
||||
middleware.Logger(
|
||||
middleware.SecurityHeaders(mux),
|
||||
),
|
||||
)
|
||||
|
||||
// Create server with timeouts
|
||||
server := &http.Server{
|
||||
Addr: ":" + cfg.Server.Port,
|
||||
Handler: handler,
|
||||
ReadTimeout: time.Duration(cfg.Server.ReadTimeout) * time.Second,
|
||||
WriteTimeout: time.Duration(cfg.Server.WriteTimeout) * time.Second,
|
||||
IdleTimeout: 120 * time.Second,
|
||||
}
|
||||
|
||||
// Start server in goroutine
|
||||
serverErrors := make(chan error, 1)
|
||||
go func() {
|
||||
log.Printf("✓ Server listening on http://%s:%s", cfg.Server.Host, cfg.Server.Port)
|
||||
log.Printf("📄 English: http://%s:%s/?lang=en", cfg.Server.Host, cfg.Server.Port)
|
||||
log.Printf("📄 Spanish: http://%s:%s/?lang=es", cfg.Server.Host, cfg.Server.Port)
|
||||
log.Printf("❤️ Health: http://%s:%s/health", cfg.Server.Host, cfg.Server.Port)
|
||||
log.Println("Press Ctrl+C to shutdown")
|
||||
|
||||
serverErrors <- server.ListenAndServe()
|
||||
}()
|
||||
|
||||
// Setup graceful shutdown
|
||||
shutdown := make(chan os.Signal, 1)
|
||||
signal.Notify(shutdown, os.Interrupt, syscall.SIGTERM)
|
||||
|
||||
// Wait for shutdown signal or server error
|
||||
select {
|
||||
case err := <-serverErrors:
|
||||
if !errors.Is(err, http.ErrServerClosed) {
|
||||
log.Fatalf("❌ Server error: %v", err)
|
||||
}
|
||||
|
||||
case sig := <-shutdown:
|
||||
log.Printf("🛑 Shutdown signal received: %v", sig)
|
||||
|
||||
// Create shutdown context with timeout
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
defer cancel()
|
||||
|
||||
// Attempt graceful shutdown
|
||||
if err := server.Shutdown(ctx); err != nil {
|
||||
log.Printf("⚠️ Graceful shutdown failed, forcing: %v", err)
|
||||
if err := server.Close(); err != nil {
|
||||
log.Fatalf("❌ Failed to close server: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
log.Println("✓ Server stopped gracefully")
|
||||
}
|
||||
}
|
||||
|
||||
// cacheControl adds cache headers to static files
|
||||
func cacheControl(h http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
// Cache static files for 1 hour in development, 1 day in production
|
||||
maxAge := "3600" // 1 hour
|
||||
if os.Getenv("GO_ENV") == "production" {
|
||||
maxAge = "86400" // 1 day
|
||||
}
|
||||
|
||||
w.Header().Set("Cache-Control", "public, max-age="+maxAge)
|
||||
h.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,849 @@
|
||||
/* Root Variables */
|
||||
:root {
|
||||
--bg-gray: #525659;
|
||||
--paper-white: #ffffff;
|
||||
--text-dark: #1a1a1a;
|
||||
--text-gray: #4a4a4a;
|
||||
--text-light: #6a6a6a;
|
||||
--accent-blue: #2563eb;
|
||||
--accent-blue-hover: #1d4ed8;
|
||||
--border-gray: #e5e5e5;
|
||||
--error-red: #dc2626;
|
||||
--error-bg: #fee2e2;
|
||||
--success-green: #10b981;
|
||||
--shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06);
|
||||
--shadow-lg: 0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -2px rgba(0, 0, 0, 0.05);
|
||||
|
||||
/* Transition timings */
|
||||
--transition-fast: 150ms;
|
||||
--transition-base: 200ms;
|
||||
--transition-slow: 300ms;
|
||||
}
|
||||
|
||||
/* Reset & Base Styles */
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||
background-color: var(--bg-gray);
|
||||
color: var(--text-dark);
|
||||
line-height: 1.6;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
/* Screen reader only text */
|
||||
.sr-only {
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
padding: 0;
|
||||
margin: -1px;
|
||||
overflow: hidden;
|
||||
clip: rect(0, 0, 0, 0);
|
||||
white-space: nowrap;
|
||||
border-width: 0;
|
||||
}
|
||||
|
||||
a {
|
||||
color: var(--accent-blue);
|
||||
text-decoration: none;
|
||||
transition: color var(--transition-base);
|
||||
}
|
||||
|
||||
a:hover {
|
||||
color: var(--accent-blue-hover);
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
a:focus-visible {
|
||||
outline: 2px solid var(--accent-blue);
|
||||
outline-offset: 2px;
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
/* Action Bar */
|
||||
.action-bar {
|
||||
background: rgba(255, 255, 255, 0.95);
|
||||
border-bottom: 1px solid var(--border-gray);
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 100;
|
||||
backdrop-filter: blur(10px);
|
||||
box-shadow: var(--shadow);
|
||||
}
|
||||
|
||||
.action-bar-content {
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
padding: 1rem 2rem;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 2rem;
|
||||
}
|
||||
|
||||
.language-toggle {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.lang-btn {
|
||||
padding: 0.5rem 1rem;
|
||||
border: 2px solid var(--border-gray);
|
||||
background: white;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
font-size: 0.9rem;
|
||||
font-weight: 500;
|
||||
transition: all var(--transition-base);
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.lang-btn:hover:not(.active) {
|
||||
border-color: var(--accent-blue);
|
||||
background: #f0f9ff;
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.lang-btn:focus-visible {
|
||||
outline: 2px solid var(--accent-blue);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.lang-btn.active {
|
||||
border-color: var(--accent-blue);
|
||||
background: var(--accent-blue);
|
||||
color: white;
|
||||
}
|
||||
|
||||
/* Loading state for buttons */
|
||||
.lang-btn[aria-busy="true"] {
|
||||
opacity: 0.7;
|
||||
cursor: wait;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.export-actions {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.export-btn {
|
||||
padding: 0.5rem 1.5rem;
|
||||
background: var(--accent-blue);
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
font-size: 0.9rem;
|
||||
font-weight: 600;
|
||||
transition: all var(--transition-base);
|
||||
box-shadow: var(--shadow);
|
||||
}
|
||||
|
||||
.export-btn:hover {
|
||||
background: var(--accent-blue-hover);
|
||||
transform: translateY(-1px);
|
||||
box-shadow: var(--shadow-lg);
|
||||
}
|
||||
|
||||
.export-btn:focus-visible {
|
||||
outline: 2px solid white;
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
/* HTMX Indicator */
|
||||
.htmx-indicator {
|
||||
display: none;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.htmx-indicator.htmx-request {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.loader {
|
||||
border: 3px solid #f3f3f3;
|
||||
border-top: 3px solid var(--accent-blue);
|
||||
border-radius: 50%;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
animation: spin 1s linear infinite;
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
0% { transform: rotate(0deg); }
|
||||
100% { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
/* CV Container - Paper Effect */
|
||||
.cv-container {
|
||||
max-width: 900px;
|
||||
margin: 2rem auto;
|
||||
padding: 0 1rem;
|
||||
}
|
||||
|
||||
.cv-paper {
|
||||
background: var(--paper-white);
|
||||
padding: 3rem;
|
||||
box-shadow: var(--shadow-lg);
|
||||
border-radius: 8px;
|
||||
min-height: 11in; /* A4 height */
|
||||
/* HTMX swap transitions */
|
||||
transition: opacity var(--transition-base);
|
||||
}
|
||||
|
||||
/* HTMX swap animation */
|
||||
.cv-paper.htmx-swapping {
|
||||
opacity: 0;
|
||||
transition: opacity var(--transition-fast);
|
||||
}
|
||||
|
||||
.cv-paper.htmx-settling {
|
||||
opacity: 1;
|
||||
transition: opacity var(--transition-base);
|
||||
}
|
||||
|
||||
/* Error Toast */
|
||||
.error-toast {
|
||||
position: fixed;
|
||||
bottom: 2rem;
|
||||
right: 2rem;
|
||||
background: var(--error-bg);
|
||||
color: var(--error-red);
|
||||
padding: 1rem 1.5rem;
|
||||
border-radius: 8px;
|
||||
border-left: 4px solid var(--error-red);
|
||||
box-shadow: var(--shadow-lg);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
max-width: 400px;
|
||||
z-index: 1000;
|
||||
animation: slideIn var(--transition-base) ease-out;
|
||||
}
|
||||
|
||||
@keyframes slideIn {
|
||||
from {
|
||||
transform: translateX(100%);
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
transform: translateX(0);
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.error-toast button {
|
||||
background: none;
|
||||
border: none;
|
||||
font-size: 1.5rem;
|
||||
color: var(--error-red);
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: opacity var(--transition-base);
|
||||
}
|
||||
|
||||
.error-toast button:hover {
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
/* CV Header */
|
||||
.cv-header {
|
||||
border-bottom: 3px solid var(--accent-blue);
|
||||
padding-bottom: 2rem;
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.cv-header-main {
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.cv-name {
|
||||
font-size: 2.5rem;
|
||||
font-weight: 700;
|
||||
color: var(--text-dark);
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.cv-title {
|
||||
font-size: 1.5rem;
|
||||
font-weight: 400;
|
||||
color: var(--text-gray);
|
||||
}
|
||||
|
||||
.cv-contact {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
||||
gap: 0.75rem;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.contact-item {
|
||||
color: var(--text-gray);
|
||||
}
|
||||
|
||||
/* Sections */
|
||||
.cv-section {
|
||||
margin-bottom: 2.5rem;
|
||||
}
|
||||
|
||||
.section-title {
|
||||
font-size: 1.5rem;
|
||||
font-weight: 700;
|
||||
color: var(--text-dark);
|
||||
margin-bottom: 1.5rem;
|
||||
padding-bottom: 0.5rem;
|
||||
border-bottom: 2px solid var(--border-gray);
|
||||
}
|
||||
|
||||
.summary-text {
|
||||
font-size: 1rem;
|
||||
line-height: 1.8;
|
||||
color: var(--text-gray);
|
||||
text-align: justify;
|
||||
}
|
||||
|
||||
/* AI Development Section */
|
||||
.ai-section {
|
||||
background: linear-gradient(135deg, #f0f9ff 0%, #e0f2fe 100%);
|
||||
padding: 2rem;
|
||||
border-radius: 8px;
|
||||
border-left: 4px solid var(--accent-blue);
|
||||
}
|
||||
|
||||
.ai-period {
|
||||
font-size: 0.9rem;
|
||||
color: var(--text-gray);
|
||||
margin-bottom: 1rem;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.ai-description {
|
||||
margin-bottom: 1.5rem;
|
||||
line-height: 1.8;
|
||||
}
|
||||
|
||||
.ai-skills {
|
||||
display: grid;
|
||||
gap: 1.5rem;
|
||||
}
|
||||
|
||||
.ai-skill-category {
|
||||
background: white;
|
||||
padding: 1.5rem;
|
||||
border-radius: 6px;
|
||||
box-shadow: var(--shadow);
|
||||
}
|
||||
|
||||
.ai-skill-title {
|
||||
font-size: 1.1rem;
|
||||
font-weight: 600;
|
||||
margin-bottom: 1rem;
|
||||
color: var(--text-dark);
|
||||
}
|
||||
|
||||
.proficiency-badge {
|
||||
background: var(--accent-blue);
|
||||
color: white;
|
||||
padding: 0.25rem 0.75rem;
|
||||
border-radius: 12px;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 500;
|
||||
text-transform: uppercase;
|
||||
margin-left: 0.5rem;
|
||||
}
|
||||
|
||||
.ai-skill-list {
|
||||
list-style: none;
|
||||
display: grid;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.ai-skill-list li:before {
|
||||
content: "✨ ";
|
||||
margin-right: 0.5rem;
|
||||
}
|
||||
|
||||
.ai-achievements {
|
||||
margin-top: 1.5rem;
|
||||
background: white;
|
||||
padding: 1.5rem;
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.ai-achievements h4 {
|
||||
margin-bottom: 1rem;
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
|
||||
.achievement-list {
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.achievement-list li {
|
||||
padding: 0.5rem 0;
|
||||
border-bottom: 1px solid var(--border-gray);
|
||||
}
|
||||
|
||||
.achievement-list li:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.achievement-list li:before {
|
||||
content: "🏆 ";
|
||||
margin-right: 0.5rem;
|
||||
}
|
||||
|
||||
/* Experience Items */
|
||||
.experience-item {
|
||||
margin-bottom: 2rem;
|
||||
padding-bottom: 2rem;
|
||||
border-bottom: 1px solid var(--border-gray);
|
||||
}
|
||||
|
||||
.experience-item:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.experience-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
margin-bottom: 1rem;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.experience-title {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.position {
|
||||
font-size: 1.2rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-dark);
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
|
||||
.company {
|
||||
font-size: 0.95rem;
|
||||
color: var(--text-gray);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.experience-period {
|
||||
font-size: 0.9rem;
|
||||
color: var(--text-light);
|
||||
white-space: nowrap;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.responsibilities {
|
||||
list-style: none;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.responsibilities li {
|
||||
padding-left: 1.5rem;
|
||||
margin-bottom: 0.5rem;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.responsibilities li:before {
|
||||
content: "▸";
|
||||
position: absolute;
|
||||
left: 0;
|
||||
color: var(--accent-blue);
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.technologies {
|
||||
font-size: 0.9rem;
|
||||
color: var(--text-gray);
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
.highlights {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem;
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
.highlight-badge {
|
||||
background: #fef3c7;
|
||||
color: #92400e;
|
||||
padding: 0.25rem 0.75rem;
|
||||
border-radius: 12px;
|
||||
font-size: 0.85rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
/* Skills Grid */
|
||||
.skills-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
|
||||
gap: 1.5rem;
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.skill-category {
|
||||
background: #f9fafb;
|
||||
padding: 1.5rem;
|
||||
border-radius: 6px;
|
||||
border-left: 3px solid var(--accent-blue);
|
||||
}
|
||||
|
||||
.skill-category-title {
|
||||
font-size: 1.1rem;
|
||||
font-weight: 600;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.proficiency-stars {
|
||||
color: #fbbf24;
|
||||
margin-bottom: 1rem;
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
|
||||
.skill-items {
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.skill-items li {
|
||||
padding: 0.25rem 0;
|
||||
padding-left: 1rem;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.skill-items li:before {
|
||||
content: "•";
|
||||
position: absolute;
|
||||
left: 0;
|
||||
color: var(--accent-blue);
|
||||
}
|
||||
|
||||
.soft-skills {
|
||||
margin-top: 2rem;
|
||||
}
|
||||
|
||||
.soft-skills h4 {
|
||||
margin-bottom: 1rem;
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
|
||||
.soft-skills-list {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.soft-skill-tag {
|
||||
background: #e0e7ff;
|
||||
color: #3730a3;
|
||||
padding: 0.5rem 1rem;
|
||||
border-radius: 20px;
|
||||
font-size: 0.85rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
/* Projects */
|
||||
.project-item {
|
||||
margin-bottom: 2rem;
|
||||
padding-bottom: 2rem;
|
||||
border-bottom: 1px solid var(--border-gray);
|
||||
}
|
||||
|
||||
.project-item:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.project-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.project-name {
|
||||
font-size: 1.2rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-dark);
|
||||
}
|
||||
|
||||
.project-period {
|
||||
font-size: 0.9rem;
|
||||
color: var(--text-light);
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.project-role {
|
||||
font-size: 0.9rem;
|
||||
color: var(--text-gray);
|
||||
margin-bottom: 0.5rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.project-url {
|
||||
font-size: 0.85rem;
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
.project-description {
|
||||
margin-bottom: 1rem;
|
||||
line-height: 1.7;
|
||||
}
|
||||
|
||||
.project-highlights {
|
||||
list-style: none;
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
.project-highlights li {
|
||||
padding-left: 1.5rem;
|
||||
margin-bottom: 0.25rem;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.project-highlights li:before {
|
||||
content: "✓";
|
||||
position: absolute;
|
||||
left: 0;
|
||||
color: #10b981;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
/* Education */
|
||||
.education-item {
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.education-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.degree {
|
||||
font-size: 1.1rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-dark);
|
||||
}
|
||||
|
||||
.education-period {
|
||||
font-size: 0.9rem;
|
||||
color: var(--text-light);
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.institution {
|
||||
font-size: 0.95rem;
|
||||
color: var(--text-gray);
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
|
||||
.field {
|
||||
font-size: 0.9rem;
|
||||
color: var(--text-light);
|
||||
}
|
||||
|
||||
/* Certifications */
|
||||
.certifications-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.certification-item {
|
||||
background: #f9fafb;
|
||||
padding: 1.25rem;
|
||||
border-radius: 6px;
|
||||
border-left: 3px solid #10b981;
|
||||
}
|
||||
|
||||
.certification-name {
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
margin-bottom: 0.5rem;
|
||||
color: var(--text-dark);
|
||||
}
|
||||
|
||||
.certification-issuer {
|
||||
font-size: 0.9rem;
|
||||
color: var(--text-gray);
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
|
||||
.certification-date {
|
||||
font-size: 0.85rem;
|
||||
color: var(--text-light);
|
||||
}
|
||||
|
||||
/* Awards */
|
||||
.award-item {
|
||||
margin-bottom: 1.5rem;
|
||||
padding-bottom: 1.5rem;
|
||||
border-bottom: 1px solid var(--border-gray);
|
||||
}
|
||||
|
||||
.award-item:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.award-title {
|
||||
font-size: 1.1rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-dark);
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.award-issuer {
|
||||
font-size: 0.9rem;
|
||||
color: var(--text-gray);
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.award-description {
|
||||
font-size: 0.95rem;
|
||||
color: var(--text-gray);
|
||||
}
|
||||
|
||||
/* Languages */
|
||||
.languages-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
||||
gap: 1.5rem;
|
||||
}
|
||||
|
||||
.language-item {
|
||||
text-align: center;
|
||||
padding: 1.5rem;
|
||||
background: #f9fafb;
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.language-name {
|
||||
font-size: 1.1rem;
|
||||
font-weight: 600;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.language-proficiency {
|
||||
font-size: 0.9rem;
|
||||
color: var(--text-gray);
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
/* Footer */
|
||||
footer {
|
||||
text-align: center;
|
||||
padding: 2rem;
|
||||
color: rgba(255, 255, 255, 0.8);
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
/* Responsive */
|
||||
@media (max-width: 768px) {
|
||||
.cv-paper {
|
||||
padding: 1.5rem;
|
||||
}
|
||||
|
||||
.cv-name {
|
||||
font-size: 2rem;
|
||||
}
|
||||
|
||||
.cv-title {
|
||||
font-size: 1.2rem;
|
||||
}
|
||||
|
||||
.action-bar-content {
|
||||
flex-direction: column;
|
||||
padding: 1rem;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.language-toggle {
|
||||
width: 100%;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.lang-btn {
|
||||
flex: 1;
|
||||
min-width: 120px;
|
||||
}
|
||||
|
||||
.export-actions {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.export-btn {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.error-toast {
|
||||
bottom: 1rem;
|
||||
right: 1rem;
|
||||
left: 1rem;
|
||||
max-width: none;
|
||||
}
|
||||
|
||||
.experience-header,
|
||||
.project-header,
|
||||
.education-header {
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.experience-period,
|
||||
.project-period,
|
||||
.education-period {
|
||||
align-self: flex-start;
|
||||
}
|
||||
|
||||
.skills-grid,
|
||||
.certifications-grid,
|
||||
.languages-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
/* Reduced motion support */
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
*,
|
||||
*::before,
|
||||
*::after {
|
||||
animation-duration: 0.01ms !important;
|
||||
animation-iteration-count: 1 !important;
|
||||
transition-duration: 0.01ms !important;
|
||||
}
|
||||
}
|
||||
|
||||
/* Print-specific hiding */
|
||||
.no-print {
|
||||
/* Will be hidden in print.css */
|
||||
}
|
||||
|
||||
/* High contrast mode support */
|
||||
@media (prefers-contrast: high) {
|
||||
.lang-btn {
|
||||
border-width: 3px;
|
||||
}
|
||||
|
||||
.lang-btn:focus-visible,
|
||||
.export-btn:focus-visible,
|
||||
a:focus-visible {
|
||||
outline-width: 3px;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,351 @@
|
||||
/* Minimal CV Design - Clean & Professional */
|
||||
|
||||
:root {
|
||||
--bg-gray: #525659;
|
||||
--paper-white: #ffffff;
|
||||
--text-dark: #2d2d2d;
|
||||
--text-gray: #555555;
|
||||
--accent-blue: #0066cc;
|
||||
--border-gray: #dddddd;
|
||||
}
|
||||
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: 'Inter', Arial, sans-serif;
|
||||
background-color: var(--bg-gray);
|
||||
color: var(--text-dark);
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
a {
|
||||
color: var(--accent-blue);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
a:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
/* Action Bar */
|
||||
.action-bar {
|
||||
background: white;
|
||||
border-bottom: 1px solid var(--border-gray);
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 100;
|
||||
}
|
||||
|
||||
.action-bar-content {
|
||||
max-width: 900px;
|
||||
margin: 0 auto;
|
||||
padding: 1rem 2rem;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.language-toggle {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.lang-btn {
|
||||
padding: 0.4rem 1rem;
|
||||
border: 1px solid var(--border-gray);
|
||||
background: white;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.lang-btn:hover {
|
||||
background: #f5f5f5;
|
||||
}
|
||||
|
||||
.lang-btn.active {
|
||||
background: var(--accent-blue);
|
||||
color: white;
|
||||
border-color: var(--accent-blue);
|
||||
}
|
||||
|
||||
.export-btn {
|
||||
padding: 0.4rem 1.2rem;
|
||||
background: var(--accent-blue);
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.export-btn:hover {
|
||||
background: #0052a3;
|
||||
}
|
||||
|
||||
/* Loading Indicator */
|
||||
.htmx-indicator {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.htmx-indicator.htmx-request {
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.loader {
|
||||
border: 2px solid #f3f3f3;
|
||||
border-top: 2px solid var(--accent-blue);
|
||||
border-radius: 50%;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
0% { transform: rotate(0deg); }
|
||||
100% { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
/* CV Container - Paper Design */
|
||||
.cv-container {
|
||||
max-width: 900px;
|
||||
margin: 2rem auto;
|
||||
padding: 0 1rem;
|
||||
}
|
||||
|
||||
.cv-paper {
|
||||
background: var(--paper-white);
|
||||
padding: 3rem 4rem;
|
||||
box-shadow: 0 2px 8px rgba(0,0,0,0.1);
|
||||
min-height: 11in;
|
||||
}
|
||||
|
||||
/* Header */
|
||||
.cv-header {
|
||||
border-bottom: 2px solid var(--text-dark);
|
||||
padding-bottom: 1.5rem;
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.cv-name {
|
||||
font-size: 2.5rem;
|
||||
font-weight: 700;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.cv-title {
|
||||
font-size: 1.3rem;
|
||||
font-weight: 400;
|
||||
color: var(--text-gray);
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.cv-contact {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
||||
gap: 0.5rem;
|
||||
font-size: 0.9rem;
|
||||
color: var(--text-gray);
|
||||
}
|
||||
|
||||
/* Sections */
|
||||
.cv-section {
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.section-title {
|
||||
font-size: 1.3rem;
|
||||
font-weight: 700;
|
||||
margin-bottom: 1rem;
|
||||
padding-bottom: 0.3rem;
|
||||
border-bottom: 1px solid var(--border-gray);
|
||||
}
|
||||
|
||||
.summary-text {
|
||||
line-height: 1.7;
|
||||
text-align: justify;
|
||||
}
|
||||
|
||||
/* Experience */
|
||||
.experience-item {
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.experience-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 0.75rem;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.position {
|
||||
font-size: 1.1rem;
|
||||
font-weight: 600;
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
|
||||
.company {
|
||||
color: var(--text-gray);
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
.experience-period {
|
||||
color: var(--text-gray);
|
||||
font-size: 0.9rem;
|
||||
white-space: nowrap;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.responsibilities {
|
||||
list-style: none;
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
.responsibilities li {
|
||||
padding-left: 1.2rem;
|
||||
margin-bottom: 0.4rem;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.responsibilities li:before {
|
||||
content: "•";
|
||||
position: absolute;
|
||||
left: 0;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.technologies {
|
||||
font-size: 0.85rem;
|
||||
color: var(--text-gray);
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
/* Education */
|
||||
.education-item {
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.education-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.degree {
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.education-period {
|
||||
color: var(--text-gray);
|
||||
font-size: 0.9rem;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.institution {
|
||||
color: var(--text-gray);
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
/* Skills */
|
||||
.skill-block {
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.skill-title {
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
margin-bottom: 0.4rem;
|
||||
}
|
||||
|
||||
.skill-list {
|
||||
color: var(--text-gray);
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
/* Projects */
|
||||
.project-item {
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.project-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.project-name {
|
||||
font-size: 1.1rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.project-period {
|
||||
color: var(--text-gray);
|
||||
font-size: 0.9rem;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.project-role {
|
||||
color: var(--text-gray);
|
||||
font-size: 0.9rem;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.project-description {
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
/* Certifications & Awards */
|
||||
.cert-item,
|
||||
.award-item {
|
||||
margin-bottom: 0.5rem;
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
/* Languages */
|
||||
.languages-list {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.language-item {
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
/* Footer */
|
||||
footer {
|
||||
text-align: center;
|
||||
padding: 2rem;
|
||||
color: rgba(255,255,255,0.7);
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
/* Responsive */
|
||||
@media (max-width: 768px) {
|
||||
.cv-paper {
|
||||
padding: 2rem 1.5rem;
|
||||
}
|
||||
|
||||
.cv-name {
|
||||
font-size: 2rem;
|
||||
}
|
||||
|
||||
.experience-header,
|
||||
.project-header,
|
||||
.education-header {
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.action-bar-content {
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
}
|
||||
}
|
||||
|
||||
.no-print {}
|
||||
@@ -0,0 +1,282 @@
|
||||
/* Print Styles - Optimized for PDF Export */
|
||||
|
||||
@media print {
|
||||
/* Reset for print */
|
||||
* {
|
||||
-webkit-print-color-adjust: exact !important;
|
||||
print-color-adjust: exact !important;
|
||||
color-adjust: exact !important;
|
||||
}
|
||||
|
||||
/* Page setup */
|
||||
@page {
|
||||
size: A4;
|
||||
margin: 1.5cm;
|
||||
}
|
||||
|
||||
body {
|
||||
background: white;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
/* Hide non-print elements */
|
||||
.no-print,
|
||||
.action-bar,
|
||||
footer {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
/* CV Container adjustments */
|
||||
.cv-container {
|
||||
max-width: 100%;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.cv-paper {
|
||||
background: white;
|
||||
box-shadow: none;
|
||||
border-radius: 0;
|
||||
padding: 0;
|
||||
min-height: auto;
|
||||
}
|
||||
|
||||
/* Typography */
|
||||
body {
|
||||
font-size: 10pt;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.cv-name {
|
||||
font-size: 24pt;
|
||||
page-break-after: avoid;
|
||||
}
|
||||
|
||||
.cv-title {
|
||||
font-size: 14pt;
|
||||
page-break-after: avoid;
|
||||
}
|
||||
|
||||
.section-title {
|
||||
font-size: 14pt;
|
||||
page-break-after: avoid;
|
||||
margin-top: 1.5em;
|
||||
}
|
||||
|
||||
/* Prevent page breaks */
|
||||
.cv-header,
|
||||
.cv-section,
|
||||
.experience-item,
|
||||
.project-item,
|
||||
.education-item,
|
||||
.award-item {
|
||||
page-break-inside: avoid;
|
||||
}
|
||||
|
||||
/* Links */
|
||||
a {
|
||||
color: #2563eb;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
a[href]:after {
|
||||
content: none; /* Don't print URLs */
|
||||
}
|
||||
|
||||
/* Compact spacing for print */
|
||||
.cv-header {
|
||||
margin-bottom: 1em;
|
||||
padding-bottom: 0.5em;
|
||||
}
|
||||
|
||||
.cv-section {
|
||||
margin-bottom: 1.5em;
|
||||
}
|
||||
|
||||
.experience-item,
|
||||
.project-item {
|
||||
margin-bottom: 1em;
|
||||
padding-bottom: 0.75em;
|
||||
}
|
||||
|
||||
/* Contact info - make it more compact */
|
||||
.cv-contact {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: 0.3em;
|
||||
font-size: 9pt;
|
||||
}
|
||||
|
||||
/* AI Section - maintain visibility in print */
|
||||
.ai-section {
|
||||
background: #f0f9ff !important;
|
||||
border-left: 3px solid #2563eb !important;
|
||||
padding: 1em !important;
|
||||
margin: 1em 0 !important;
|
||||
}
|
||||
|
||||
.ai-skill-category {
|
||||
background: white !important;
|
||||
padding: 0.75em !important;
|
||||
margin-bottom: 0.5em;
|
||||
box-shadow: none;
|
||||
border: 1px solid #e5e5e5;
|
||||
}
|
||||
|
||||
.ai-achievements {
|
||||
background: white !important;
|
||||
padding: 0.75em !important;
|
||||
border: 1px solid #e5e5e5;
|
||||
}
|
||||
|
||||
/* Skills grid - more compact */
|
||||
.skills-grid {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: 0.75em;
|
||||
}
|
||||
|
||||
.skill-category {
|
||||
background: #f9fafb !important;
|
||||
padding: 0.75em !important;
|
||||
}
|
||||
|
||||
/* Certifications grid */
|
||||
.certifications-grid {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: 0.5em;
|
||||
}
|
||||
|
||||
.certification-item {
|
||||
background: #f9fafb !important;
|
||||
padding: 0.75em !important;
|
||||
}
|
||||
|
||||
/* Languages */
|
||||
.languages-grid {
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: 0.5em;
|
||||
}
|
||||
|
||||
.language-item {
|
||||
background: #f9fafb !important;
|
||||
padding: 0.75em !important;
|
||||
}
|
||||
|
||||
/* Badges and tags */
|
||||
.proficiency-badge,
|
||||
.highlight-badge,
|
||||
.soft-skill-tag {
|
||||
-webkit-print-color-adjust: exact;
|
||||
print-color-adjust: exact;
|
||||
}
|
||||
|
||||
.proficiency-badge {
|
||||
background: #2563eb !important;
|
||||
color: white !important;
|
||||
}
|
||||
|
||||
.highlight-badge {
|
||||
background: #fef3c7 !important;
|
||||
color: #92400e !important;
|
||||
}
|
||||
|
||||
.soft-skill-tag {
|
||||
background: #e0e7ff !important;
|
||||
color: #3730a3 !important;
|
||||
}
|
||||
|
||||
/* Ensure borders print */
|
||||
.cv-header {
|
||||
border-bottom: 2px solid #2563eb !important;
|
||||
}
|
||||
|
||||
.section-title {
|
||||
border-bottom: 1px solid #e5e5e5 !important;
|
||||
}
|
||||
|
||||
.experience-item,
|
||||
.project-item,
|
||||
.award-item {
|
||||
border-bottom: 1px solid #e5e5e5 !important;
|
||||
}
|
||||
|
||||
/* Reduce list spacing */
|
||||
.responsibilities,
|
||||
.ai-skill-list,
|
||||
.achievement-list,
|
||||
.skill-items,
|
||||
.project-highlights {
|
||||
margin: 0.5em 0;
|
||||
}
|
||||
|
||||
.responsibilities li,
|
||||
.ai-skill-list li,
|
||||
.achievement-list li,
|
||||
.skill-items li,
|
||||
.project-highlights li {
|
||||
margin-bottom: 0.25em;
|
||||
}
|
||||
|
||||
/* Font size adjustments for print */
|
||||
.summary-text {
|
||||
font-size: 9.5pt;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.position {
|
||||
font-size: 11pt;
|
||||
}
|
||||
|
||||
.company,
|
||||
.institution {
|
||||
font-size: 9.5pt;
|
||||
}
|
||||
|
||||
.responsibilities,
|
||||
.project-description {
|
||||
font-size: 9pt;
|
||||
}
|
||||
|
||||
.technologies {
|
||||
font-size: 8.5pt;
|
||||
}
|
||||
|
||||
/* Optimize spacing */
|
||||
.ai-description {
|
||||
margin-bottom: 0.75em;
|
||||
}
|
||||
|
||||
.experience-header,
|
||||
.project-header,
|
||||
.education-header {
|
||||
margin-bottom: 0.5em;
|
||||
}
|
||||
|
||||
/* Ensure stars print correctly */
|
||||
.proficiency-stars {
|
||||
color: #fbbf24 !important;
|
||||
}
|
||||
|
||||
/* Compact soft skills */
|
||||
.soft-skills-list {
|
||||
gap: 0.25em;
|
||||
}
|
||||
|
||||
.soft-skill-tag {
|
||||
padding: 0.25em 0.5em;
|
||||
font-size: 8pt;
|
||||
}
|
||||
}
|
||||
|
||||
/* Print button functionality */
|
||||
@media screen {
|
||||
.print-only {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
@media print {
|
||||
.print-only {
|
||||
display: block;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
<!-- CV Content Template - Minimal Design -->
|
||||
<div class="cv-header">
|
||||
<div class="cv-header-main">
|
||||
<h1 class="cv-name">{{.CV.Personal.Name}}</h1>
|
||||
<h2 class="cv-title">{{.CV.Personal.Title}}</h2>
|
||||
</div>
|
||||
<div class="cv-contact">
|
||||
<div class="contact-item">{{.CV.Personal.Location}}</div>
|
||||
<div class="contact-item"><a href="mailto:{{.CV.Personal.Email}}">{{.CV.Personal.Email}}</a></div>
|
||||
<div class="contact-item">{{.CV.Personal.Phone}}</div>
|
||||
<div class="contact-item"><a href="{{.CV.Personal.LinkedIn}}" target="_blank">LinkedIn</a></div>
|
||||
<div class="contact-item"><a href="{{.CV.Personal.GitHub}}" target="_blank">GitHub</a></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Summary -->
|
||||
<section class="cv-section">
|
||||
<h3 class="section-title">{{if eq .Lang "es"}}Resumen{{else}}Summary{{end}}</h3>
|
||||
<p class="summary-text">{{.CV.Summary}}</p>
|
||||
</section>
|
||||
|
||||
<!-- Experience -->
|
||||
<section class="cv-section">
|
||||
<h3 class="section-title">{{if eq .Lang "es"}}Experiencia Laboral{{else}}Work History{{end}}</h3>
|
||||
|
||||
{{range .CV.Experience}}
|
||||
<div class="experience-item">
|
||||
<div class="experience-header">
|
||||
<div class="experience-title">
|
||||
<h4 class="position">{{.Position}}</h4>
|
||||
<div class="company">{{.Company}}, {{.Location}}</div>
|
||||
</div>
|
||||
<div class="experience-period">
|
||||
{{.StartDate}} - {{if .Current}}{{if eq $.Lang "es"}}Presente{{else}}Present{{end}}{{else}}{{.EndDate}}{{end}}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ul class="responsibilities">
|
||||
{{range .Responsibilities}}
|
||||
<li>{{.}}</li>
|
||||
{{end}}
|
||||
</ul>
|
||||
|
||||
{{if .Technologies}}
|
||||
<div class="technologies">
|
||||
{{range $index, $tech := .Technologies}}{{if $index}}, {{end}}{{$tech}}{{end}}
|
||||
</div>
|
||||
{{end}}
|
||||
</div>
|
||||
{{end}}
|
||||
</section>
|
||||
|
||||
<!-- Education -->
|
||||
<section class="cv-section">
|
||||
<h3 class="section-title">{{if eq .Lang "es"}}Formación{{else}}Education{{end}}</h3>
|
||||
|
||||
{{range .CV.Education}}
|
||||
<div class="education-item">
|
||||
<div class="education-header">
|
||||
<h4 class="degree">{{.Degree}}</h4>
|
||||
<div class="education-period">{{.StartDate}} - {{.EndDate}}</div>
|
||||
</div>
|
||||
<div class="institution">{{.Institution}}, {{.Location}}</div>
|
||||
</div>
|
||||
{{end}}
|
||||
</section>
|
||||
|
||||
<!-- Skills -->
|
||||
<section class="cv-section">
|
||||
<h3 class="section-title">{{if eq .Lang "es"}}Competencias{{else}}Skills{{end}}</h3>
|
||||
|
||||
{{range .CV.Skills.Technical}}
|
||||
<div class="skill-block">
|
||||
<h4 class="skill-title">{{.Category}}</h4>
|
||||
<p class="skill-list">
|
||||
{{range $index, $item := .Items}}{{if $index}}, {{end}}{{$item}}{{end}}
|
||||
</p>
|
||||
</div>
|
||||
{{end}}
|
||||
</section>
|
||||
|
||||
<!-- Projects -->
|
||||
{{if .CV.Projects}}
|
||||
<section class="cv-section">
|
||||
<h3 class="section-title">{{if eq .Lang "es"}}Proyectos{{else}}Projects{{end}}</h3>
|
||||
|
||||
{{range .CV.Projects}}
|
||||
<div class="project-item">
|
||||
<div class="project-header">
|
||||
<h4 class="project-name">{{.Name}}</h4>
|
||||
<div class="project-period">{{.Period}}</div>
|
||||
</div>
|
||||
<div class="project-role">{{.Role}}</div>
|
||||
<p class="project-description">{{.Description}}</p>
|
||||
</div>
|
||||
{{end}}
|
||||
</section>
|
||||
{{end}}
|
||||
|
||||
<!-- Certifications -->
|
||||
{{if .CV.Certifications}}
|
||||
<section class="cv-section">
|
||||
<h3 class="section-title">{{if eq .Lang "es"}}Certificaciones{{else}}Certifications{{end}}</h3>
|
||||
|
||||
{{range .CV.Certifications}}
|
||||
<div class="cert-item">
|
||||
<strong>{{.Name}}</strong> - {{.Issuer}} ({{.Date}})
|
||||
</div>
|
||||
{{end}}
|
||||
</section>
|
||||
{{end}}
|
||||
|
||||
<!-- Awards -->
|
||||
{{if .CV.Awards}}
|
||||
<section class="cv-section">
|
||||
<h3 class="section-title">{{if eq .Lang "es"}}Premios{{else}}Awards{{end}}</h3>
|
||||
|
||||
{{range .CV.Awards}}
|
||||
<div class="award-item">
|
||||
<strong>{{.Title}}</strong> - {{.Issuer}} ({{.Date}})
|
||||
</div>
|
||||
{{end}}
|
||||
</section>
|
||||
{{end}}
|
||||
|
||||
<!-- Languages -->
|
||||
<section class="cv-section">
|
||||
<h3 class="section-title">{{if eq .Lang "es"}}Idiomas{{else}}Languages{{end}}</h3>
|
||||
|
||||
<div class="languages-list">
|
||||
{{range .CV.Languages}}
|
||||
<div class="language-item">
|
||||
<strong>{{.Language}}</strong>: {{.Proficiency}}
|
||||
</div>
|
||||
{{end}}
|
||||
</div>
|
||||
</section>
|
||||
@@ -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,72 @@
|
||||
<!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">
|
||||
<title>{{.CV.Personal.Name}} - Curriculum Vitae</title>
|
||||
|
||||
<!-- HTMX -->
|
||||
<script src="https://unpkg.com/htmx.org@1.9.10"></script>
|
||||
|
||||
<!-- CSS -->
|
||||
<link rel="stylesheet" href="/static/css/main.css">
|
||||
<link rel="stylesheet" href="/static/css/print.css" media="print">
|
||||
|
||||
<!-- Fonts -->
|
||||
<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">
|
||||
</head>
|
||||
<body>
|
||||
<!-- Language & Export Bar (hidden in print) -->
|
||||
<div class="action-bar no-print">
|
||||
<div class="action-bar-content">
|
||||
<div class="language-toggle">
|
||||
<button
|
||||
class="lang-btn {{if eq .Lang "en"}}active{{end}}"
|
||||
hx-get="/cv?lang=en"
|
||||
hx-target="#cv-content"
|
||||
hx-swap="innerHTML"
|
||||
hx-indicator="#loading">
|
||||
🇬🇧 English
|
||||
</button>
|
||||
<button
|
||||
class="lang-btn {{if eq .Lang "es"}}active{{end}}"
|
||||
hx-get="/cv?lang=es"
|
||||
hx-target="#cv-content"
|
||||
hx-swap="innerHTML"
|
||||
hx-indicator="#loading">
|
||||
🇪🇸 Español
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="export-actions">
|
||||
<button
|
||||
class="export-btn"
|
||||
onclick="window.print()">
|
||||
📄 {{if eq .Lang "es"}}Descargar PDF{{else}}Download PDF{{end}}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<span id="loading" class="htmx-indicator">
|
||||
<span class="loader"></span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- CV Content Container -->
|
||||
<div class="cv-container">
|
||||
<div id="cv-content" class="cv-paper">
|
||||
{{template "cv-content.html" .}}
|
||||
</div>
|
||||
</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>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1 @@
|
||||
# Placeholder for future partial templates
|
||||
Reference in New Issue
Block a user