docs: Cleanup and consolidate PDF documentation
## Documentation Cleanup
- Moved PDF-EXPORT-FEATURE.md → doc/11-PDF-EXPORT.md
- Added shortcut URL documentation section
- Updated changelog to v2.1.0 with shortcut URL feature
- Removed ephemeral reports (validation, shortcut implementation)
## Removed Backup Files
- Deleted static/css/main.css.backup
- Deleted static/js/main.js.backup
## Consolidated PDF Documentation
The comprehensive PDF export documentation now includes:
- Year-aware shortcut URLs (/cv-jamr-{year}-{lang}.pdf)
- Automatic year validation and updates
- Default CV modal integration
- All PDF export options and features
- Complete API reference
Benefits:
- Single source of truth for PDF feature documentation
- Removed temporary/ephemeral documentation files
- Cleaner repository structure
- Updated versioning to 2.1.0
This commit is contained in:
@@ -1,178 +0,0 @@
|
||||
# PDF Shortcut URL Implementation Summary
|
||||
|
||||
**Date:** 2025-11-20
|
||||
**Status:** ✅ Complete and Tested
|
||||
|
||||
## Features Implemented
|
||||
|
||||
### 1. Year-Aware Shortcut URLs ✅
|
||||
|
||||
**URLs:**
|
||||
- `/cv-jamr-2025-en.pdf` → English default CV (short with skills, 5 pages)
|
||||
- `/cv-jamr-2025-es.pdf` → Spanish default CV (short with skills, 5 pages)
|
||||
|
||||
**Year Validation:**
|
||||
- ✅ Current year (2025): Works perfectly
|
||||
- ❌ Old year (2024): Returns 404
|
||||
- ❌ Future year (2026): Returns 404
|
||||
- 🔄 Auto-updates: Next year, 2026 URLs will work automatically
|
||||
|
||||
**Implementation:**
|
||||
- Handler: `internal/handlers/cv.go` - `DefaultCVShortcut()` function
|
||||
- Route check: `Home()` handler detects pattern and delegates
|
||||
- Redirect: 301 to `/export/pdf?lang={lang}&length=short&icons=show&version=with_skills`
|
||||
|
||||
### 2. PDF Modal Updates ✅
|
||||
|
||||
**Changes:**
|
||||
- ❌ Removed: "Current View" option
|
||||
- ✅ Added: "Default CV (Recommended)" option
|
||||
- ✅ Highlights: Purple gradient badge, star emoji ⭐, bold text
|
||||
- ✅ Description: "Short with skills - Recommended" (5 pages)
|
||||
|
||||
**JavaScript:**
|
||||
- Uses shortcut URL: `/cv-jamr-${year}-${lang}.pdf`
|
||||
- Auto-detects current year
|
||||
- Cleaner, more memorable URL
|
||||
|
||||
**Current Order:**
|
||||
1. Short CV (4 pages) - Clean, no skills
|
||||
2. Long CV (9 pages) - Extended with skills
|
||||
3. **⭐ Default CV (5 pages)** - Short with skills, Highlighted
|
||||
|
||||
**Requested Order (optional polish):**
|
||||
1. Short CV (4 pages)
|
||||
2. **⭐ Default CV (5 pages)** ← Move to middle
|
||||
3. Long CV (9 pages)
|
||||
|
||||
*Note: Functional order works fine, visual reordering is optional UX polish.*
|
||||
|
||||
## Testing Results
|
||||
|
||||
### Shortcut URLs
|
||||
```bash
|
||||
# English - Works ✅
|
||||
curl -I http://localhost:1999/cv-jamr-2025-en.pdf
|
||||
→ HTTP/1.1 301 Moved Permanently
|
||||
→ Location: /export/pdf?lang=en&length=short&icons=show&version=with_skills
|
||||
|
||||
# Spanish - Works ✅
|
||||
curl -I http://localhost:1999/cv-jamr-2025-es.pdf
|
||||
→ HTTP/1.1 301 Moved Permanently
|
||||
→ Location: /export/pdf?lang=es&length=short&icons=show&version=with_skills
|
||||
|
||||
# Invalid year 2024 - Rejected ✅
|
||||
curl -I http://localhost:1999/cv-jamr-2024-en.pdf
|
||||
→ HTTP/1.1 404 Not Found
|
||||
|
||||
# Invalid year 2026 - Rejected ✅
|
||||
curl -I http://localhost:1999/cv-jamr-2026-en.pdf
|
||||
→ HTTP/1.1 404 Not Found
|
||||
```
|
||||
|
||||
### Modal Functionality
|
||||
- ✅ Default option appears with highlighting
|
||||
- ✅ Star emoji ⭐ visible in badge and title
|
||||
- ✅ Purple gradient badge stands out
|
||||
- ✅ Downloads correct PDF (short with skills, 5 pages)
|
||||
- ✅ Uses memorable shortcut URL
|
||||
|
||||
## Files Changed
|
||||
|
||||
**Backend:**
|
||||
- `internal/handlers/cv.go` (+42 lines)
|
||||
- Added `DefaultCVShortcut()` handler
|
||||
- Added pattern detection in `Home()` handler
|
||||
- `internal/routes/routes.go` (+3 lines)
|
||||
- Registered shortcut route (moved before "/" for precedence)
|
||||
|
||||
**Frontend:**
|
||||
- `templates/partials/modals/pdf-modal.html` (~30 changes)
|
||||
- Replaced "Current View" card with "Default CV" card
|
||||
- Added highlighting: gradient badge, star emoji, bold text
|
||||
- Updated JavaScript to use shortcut URL
|
||||
- Added `data-cv-format="default"` attribute
|
||||
|
||||
## Code Snippets
|
||||
|
||||
### Backend Handler
|
||||
```go
|
||||
// DefaultCVShortcut handles shortcut URLs for default CV downloads
|
||||
// Pattern: /cv-jamr-{year}-{lang}.pdf (e.g., /cv-jamr-2025-en.pdf)
|
||||
func (h *CVHandler) DefaultCVShortcut(w http.ResponseWriter, r *http.Request) {
|
||||
path := r.URL.Path
|
||||
parts := strings.Split(strings.TrimPrefix(path, "/"), "-")
|
||||
|
||||
// Extract and validate year
|
||||
yearStr := parts[2]
|
||||
currentYear := fmt.Sprintf("%d", time.Now().Year())
|
||||
if yearStr != currentYear {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
// Extract and validate language
|
||||
lang := strings.TrimSuffix(parts[3], ".pdf")
|
||||
if lang != "en" && lang != "es" {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
// Redirect to default PDF export
|
||||
redirectURL := fmt.Sprintf("/export/pdf?lang=%s&length=short&icons=show&version=with_skills", lang)
|
||||
http.Redirect(w, r, redirectURL, http.StatusMovedPermanently)
|
||||
}
|
||||
```
|
||||
|
||||
### Frontend JavaScript
|
||||
```javascript
|
||||
if (selectedFormat === 'default') {
|
||||
// Default CV: use shortcut URL (short with skills, 5 pages)
|
||||
const currentYear = new Date().getFullYear();
|
||||
url = `/cv-jamr-${currentYear}-${lang}.pdf`;
|
||||
}
|
||||
```
|
||||
|
||||
## Benefits
|
||||
|
||||
### For Users
|
||||
- ✅ **Memorable URL:** `juan.andres.morenorub.io/cv-jamr-2025-en.pdf`
|
||||
- ✅ **Easy to share:** No complex query parameters
|
||||
- ✅ **Clear recommendation:** Default option highlighted in modal
|
||||
- ✅ **Always current:** Year auto-updates
|
||||
|
||||
### For Developer
|
||||
- ✅ **Simple maintenance:** Year validation automatic
|
||||
- ✅ **Clean architecture:** Single handler, minimal code
|
||||
- ✅ **Future-proof:** Works for any future year
|
||||
- ✅ **SEO friendly:** Clean, semantic URLs
|
||||
|
||||
## Next Steps (Optional)
|
||||
|
||||
### Visual Polish
|
||||
- [ ] Reorder cards to: Short → Default → Long (currently Short → Long → Default)
|
||||
- [ ] Add CSS class for `.pdf-option-recommended` styling (currently inline)
|
||||
- [ ] Add hover effect emphasis for default card
|
||||
|
||||
### Enhancement Ideas
|
||||
- [ ] Add QR code in modal for default CV shortcut URL
|
||||
- [ ] Show "Most Downloaded" badge based on analytics
|
||||
- [ ] Add animation/pulse effect to recommended badge
|
||||
- [ ] Create even shorter alias: `/cv.pdf` → defaults to current year + Spanish
|
||||
|
||||
## Production URLs
|
||||
|
||||
Once deployed, these URLs will work:
|
||||
```
|
||||
https://juan.andres.morenorub.io/cv-jamr-2025-en.pdf
|
||||
https://juan.andres.morenorub.io/cv-jamr-2025-es.pdf
|
||||
```
|
||||
|
||||
## Conclusion
|
||||
|
||||
✅ **Core functionality complete and tested**
|
||||
✅ **Year validation working perfectly**
|
||||
✅ **Modal updated with highlighted default option**
|
||||
✅ **Shortcut URLs functional and user-friendly**
|
||||
|
||||
The implementation provides a clean, memorable way to access the default CV with automatic year updates. The modal now clearly indicates the recommended option with visual emphasis.
|
||||
@@ -1,191 +0,0 @@
|
||||
# PDF Generation Validation Report
|
||||
|
||||
**Generated:** 2025-11-20
|
||||
**Total PDFs:** 8
|
||||
**Status:** ✅ All PDFs generated and validated successfully
|
||||
|
||||
## Summary Table
|
||||
|
||||
| PDF File | Pages | Lang | Length | Version | Sidebar Fonts | Status |
|
||||
|----------|-------|------|--------|---------|---------------|--------|
|
||||
| cv-short-jamr-2025-en.pdf | 4 | EN | short | clean | N/A (no sidebar) | ✅ PASS |
|
||||
| cv-short-jamr-2025-es.pdf | 4 | ES | short | clean | N/A (no sidebar) | ✅ PASS |
|
||||
| cv-short-with-skills-jamr-2025-en.pdf | 5 | EN | short | with_skills | **Compact** (0.94-0.98em) | ✅ PASS |
|
||||
| cv-short-with-skills-jamr-2025-es.pdf | 5 | ES | short | with_skills | **Compact** (0.94-0.98em) | ✅ PASS |
|
||||
| cv-long-jamr-2025-en.pdf | 7 | EN | long | clean | N/A (no sidebar) | ✅ PASS |
|
||||
| cv-long-jamr-2025-es.pdf | 7 | ES | long | clean | N/A (no sidebar) | ✅ PASS |
|
||||
| cv-long-with-skills-jamr-2025-en.pdf | 9 | EN | long | with_skills | **Full-size** (1.0em) | ✅ PASS |
|
||||
| cv-long-with-skills-jamr-2025-es.pdf | 9 | ES | long | with_skills | **Full-size** (1.0em) | ✅ PASS |
|
||||
|
||||
## Detailed Validation
|
||||
|
||||
### 1. SHORT + CLEAN (4 pages, no sidebars)
|
||||
|
||||
**cv-short-jamr-2025-en.pdf**
|
||||
- ✅ Pages: 4
|
||||
- ✅ Language: English ("20 years of experience", "Training")
|
||||
- ✅ Version: Clean (no skills sidebar)
|
||||
- ✅ File size: 2.2 MB
|
||||
|
||||
**cv-short-jamr-2025-es.pdf**
|
||||
- ✅ Pages: 4
|
||||
- ✅ Language: Spanish ("20 años de experiencia")
|
||||
- ✅ Version: Clean (no skills sidebar)
|
||||
- ✅ File size: 2.2 MB
|
||||
|
||||
### 2. SHORT + WITH_SKILLS (5 pages, COMPACT sidebar fonts)
|
||||
|
||||
**cv-short-with-skills-jamr-2025-en.pdf**
|
||||
- ✅ Pages: 5 (reduced from 6 with compact fonts)
|
||||
- ✅ Language: English
|
||||
- ✅ Version: With skills sidebar
|
||||
- ✅ Compact fonts: Active (0.94-0.98em font reduction)
|
||||
- ✅ Page count reduction: 16.7% (6→5 pages)
|
||||
- ✅ File size: 2.2 MB
|
||||
|
||||
**cv-short-with-skills-jamr-2025-es.pdf**
|
||||
- ✅ Pages: 5 (reduced from 6 with compact fonts)
|
||||
- ✅ Language: Spanish ("Competencias" sidebar detected)
|
||||
- ✅ Version: With skills sidebar
|
||||
- ✅ Compact fonts: Active (0.94-0.98em font reduction)
|
||||
- ✅ Page count reduction: 16.7% (6→5 pages)
|
||||
- ✅ File size: 2.2 MB
|
||||
|
||||
### 3. LONG + CLEAN (7 pages, no sidebars)
|
||||
|
||||
**cv-long-jamr-2025-en.pdf**
|
||||
- ✅ Pages: 7
|
||||
- ✅ Language: English
|
||||
- ✅ Version: Clean (no skills sidebar)
|
||||
- ✅ Content: Extended content compared to short version
|
||||
- ✅ File size: 2.2 MB
|
||||
|
||||
**cv-long-jamr-2025-es.pdf**
|
||||
- ✅ Pages: 7
|
||||
- ✅ Language: Spanish
|
||||
- ✅ Version: Clean (no skills sidebar)
|
||||
- ✅ Content: Extended content compared to short version
|
||||
- ✅ File size: 2.2 MB
|
||||
|
||||
### 4. LONG + WITH_SKILLS (9 pages, FULL-SIZE sidebar fonts)
|
||||
|
||||
**cv-long-with-skills-jamr-2025-en.pdf**
|
||||
- ✅ Pages: 9
|
||||
- ✅ Language: English
|
||||
- ✅ Version: With skills sidebar
|
||||
- ✅ Sidebar fonts: Full-size (1.0em, NO font reduction)
|
||||
- ✅ Sidebar layout: 25% left/right sidebars
|
||||
- ✅ File size: 2.3 MB
|
||||
|
||||
**cv-long-with-skills-jamr-2025-es.pdf**
|
||||
- ✅ Pages: 9
|
||||
- ✅ Language: Spanish
|
||||
- ✅ Version: With skills sidebar
|
||||
- ✅ Sidebar fonts: Full-size (1.0em, NO font reduction)
|
||||
- ✅ Sidebar layout: 25% left/right sidebars
|
||||
- ✅ File size: 2.3 MB
|
||||
|
||||
## Feature Validation
|
||||
|
||||
### ✅ Compact Sidebar Fonts Feature
|
||||
|
||||
**Activation conditions:**
|
||||
- Length: `short` ✅
|
||||
- Version: `with_skills` ✅
|
||||
|
||||
**Implementation verified:**
|
||||
- Cookie detection: `cv-length=short` triggers compact fonts
|
||||
- Font reduction: 2-6% (0.94-0.98em)
|
||||
- Page count impact: 6→5 pages (16.7% reduction)
|
||||
- Only applies to SHORT versions ✅
|
||||
|
||||
**Long versions confirmed:**
|
||||
- Do NOT use compact fonts ✅
|
||||
- Full-size sidebar fonts (1.0em) ✅
|
||||
- 9 pages maintained ✅
|
||||
|
||||
### ✅ Language Support
|
||||
|
||||
**English (en):**
|
||||
- Header: "20 years of experience" ✅
|
||||
- Sections: "Training", "Experience" ✅
|
||||
- Skills sidebar: "Technical Skills" ✅
|
||||
|
||||
**Spanish (es):**
|
||||
- Header: "20 años de experiencia" ✅
|
||||
- Sections: "Formación", "Experiencia" ✅
|
||||
- Skills sidebar: "Competencias Técnicas" ✅
|
||||
|
||||
### ✅ Breaking Change Validation
|
||||
|
||||
**'extended' → 'long' terminology:**
|
||||
- All PDFs use 'long' in filenames ✅
|
||||
- API accepts `length=long` ✅
|
||||
- API rejects `length=extended` (400 error) ✅
|
||||
- Migration logic: auto-converts old cookies ✅
|
||||
|
||||
## Page Count Expectations
|
||||
|
||||
| Configuration | Expected Pages | Actual | Status |
|
||||
|---------------|----------------|--------|--------|
|
||||
| Short + Clean | 4 | 4 | ✅ |
|
||||
| Short + With Skills (compact) | 5 | 5 | ✅ |
|
||||
| Long + Clean | 7 | 7 | ✅ |
|
||||
| Long + With Skills (full-size) | 9 | 9 | ✅ |
|
||||
|
||||
## Characteristics Verified
|
||||
|
||||
### ✅ SHORT versions:
|
||||
1. Concise content (4 pages clean, 5 pages with skills)
|
||||
2. Compact sidebar fonts reduce page count
|
||||
3. Both languages working correctly
|
||||
|
||||
### ✅ LONG versions:
|
||||
1. Extended content (7 pages clean, 9 pages with skills)
|
||||
2. Full-size sidebar fonts (no reduction)
|
||||
3. 25% sidebar layout preserved
|
||||
4. Both languages working correctly
|
||||
|
||||
### ✅ CLEAN versions:
|
||||
1. No skills sidebars displayed
|
||||
2. Compact, professional layout
|
||||
3. Print-optimized CSS active
|
||||
|
||||
### ✅ WITH_SKILLS versions:
|
||||
1. Skills sidebar visible
|
||||
2. Accordion headers hidden
|
||||
3. Page breaks between sections
|
||||
4. Font size conditional on length parameter
|
||||
|
||||
## File Size Analysis
|
||||
|
||||
| Type | Size Range | Notes |
|
||||
|------|------------|-------|
|
||||
| Short Clean | 2.2 MB | Standard size |
|
||||
| Short With Skills | 2.2 MB | Same as clean (compact fonts) |
|
||||
| Long Clean | 2.2 MB | Consistent with short |
|
||||
| Long With Skills | 2.3 MB | Slightly larger (more content + sidebars) |
|
||||
|
||||
**Observation:** File sizes are consistent across configurations, indicating proper PDF optimization.
|
||||
|
||||
## Test Environment
|
||||
|
||||
- **Server:** cv-server running on localhost:1999
|
||||
- **Generation method:** HTTP API calls via curl
|
||||
- **Rate limiting:** Handled with 5-10 second delays between requests
|
||||
- **Validation tools:** pdfinfo, pdftotext
|
||||
- **Build:** Latest (post breaking-change commit)
|
||||
|
||||
## Conclusion
|
||||
|
||||
**✅ ALL VALIDATIONS PASSED**
|
||||
|
||||
All 8 PDFs generated successfully with correct:
|
||||
- Page counts matching expectations
|
||||
- Language-specific content
|
||||
- Compact sidebar fonts feature working correctly (short with_skills only)
|
||||
- Full-size sidebar fonts for long versions
|
||||
- Breaking change ('extended' → 'long') implemented correctly
|
||||
- File sizes within expected ranges
|
||||
|
||||
**No issues found. Ready for production use.**
|
||||
@@ -440,6 +440,105 @@ curl -O http://localhost:1999/export/pdf?lang=en&length=long&icons=show&version=
|
||||
# Filename: cv-long-with-skills-jamr-2025-en.pdf
|
||||
```
|
||||
|
||||
## Shortcut URLs (Year-Aware)
|
||||
|
||||
### Overview
|
||||
|
||||
The application provides memorable shortcut URLs for the default CV (short with skills, 5 pages) that are easy to share and remember.
|
||||
|
||||
**Pattern:** `/cv-jamr-{year}-{lang}.pdf`
|
||||
|
||||
**Examples:**
|
||||
- `https://juan.andres.morenorub.io/cv-jamr-2025-en.pdf` (English)
|
||||
- `https://juan.andres.morenorub.io/cv-jamr-2025-es.pdf` (Spanish)
|
||||
|
||||
### Year Validation
|
||||
|
||||
The shortcut URLs include automatic year validation that auto-updates annually:
|
||||
|
||||
**Current Year (2025):**
|
||||
- ✅ `/cv-jamr-2025-en.pdf` → Works (301 redirect)
|
||||
- ✅ `/cv-jamr-2025-es.pdf` → Works (301 redirect)
|
||||
|
||||
**Past/Future Years:**
|
||||
- ❌ `/cv-jamr-2024-en.pdf` → 404 Not Found
|
||||
- ❌ `/cv-jamr-2026-en.pdf` → 404 Not Found (until 2026)
|
||||
|
||||
**Auto-Update:** Next year (2026), the 2026 URLs will automatically work and 2025 URLs will return 404.
|
||||
|
||||
### Implementation
|
||||
|
||||
**Backend Handler** (`internal/handlers/cv.go`):
|
||||
```go
|
||||
func (h *CVHandler) DefaultCVShortcut(w http.ResponseWriter, r *http.Request) {
|
||||
// Extract year and language from URL path
|
||||
path := r.URL.Path // e.g., "/cv-jamr-2025-en.pdf"
|
||||
parts := strings.Split(strings.TrimPrefix(path, "/"), "-")
|
||||
|
||||
yearStr := parts[2]
|
||||
lang := strings.TrimSuffix(parts[3], ".pdf")
|
||||
|
||||
// Validate year matches current year
|
||||
currentYear := fmt.Sprintf("%d", time.Now().Year())
|
||||
if yearStr != currentYear {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
// Validate language
|
||||
if lang != "en" && lang != "es" {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
// Redirect to default PDF (short with skills)
|
||||
redirectURL := fmt.Sprintf("/export/pdf?lang=%s&length=short&icons=show&version=with_skills", lang)
|
||||
http.Redirect(w, r, redirectURL, http.StatusMovedPermanently)
|
||||
}
|
||||
```
|
||||
|
||||
**Route Registration** (`internal/routes/routes.go`):
|
||||
```go
|
||||
// Shortcut routes - must be before "/" route for precedence
|
||||
mux.HandleFunc("/cv-jamr-", cvHandler.DefaultCVShortcut)
|
||||
```
|
||||
|
||||
**Frontend Integration** (`templates/partials/modals/pdf-modal.html`):
|
||||
```javascript
|
||||
if (selectedFormat === 'default') {
|
||||
// Default CV: use shortcut URL
|
||||
const currentYear = new Date().getFullYear();
|
||||
url = `/cv-jamr-${currentYear}-${lang}.pdf`;
|
||||
}
|
||||
```
|
||||
|
||||
### Benefits
|
||||
|
||||
**For Users:**
|
||||
- ✅ Memorable URL pattern
|
||||
- ✅ Easy to type and share
|
||||
- ✅ No complex query parameters
|
||||
- ✅ Professional appearance
|
||||
|
||||
**For Developers:**
|
||||
- ✅ Auto-updates yearly (no manual changes)
|
||||
- ✅ Simple validation logic
|
||||
- ✅ SEO-friendly clean URLs
|
||||
- ✅ Minimal maintenance overhead
|
||||
|
||||
### Testing
|
||||
|
||||
```bash
|
||||
# Test current year (should work)
|
||||
curl -I http://localhost:1999/cv-jamr-2025-en.pdf
|
||||
# Expected: HTTP/1.1 301 Moved Permanently
|
||||
# Location: /export/pdf?lang=en&length=short&icons=show&version=with_skills
|
||||
|
||||
# Test invalid year (should fail)
|
||||
curl -I http://localhost:1999/cv-jamr-2024-en.pdf
|
||||
# Expected: HTTP/1.1 404 Not Found
|
||||
```
|
||||
|
||||
## Design Philosophy
|
||||
|
||||
### Why This Naming Convention?
|
||||
@@ -660,9 +759,35 @@ The system automatically handles year rollovers:
|
||||
- ✅ Backwards compatible with existing user preferences
|
||||
- ✅ Comprehensive documentation and troubleshooting
|
||||
|
||||
### Version 2.1.0 (2025-11-20) - Year-Aware Shortcut URLs
|
||||
|
||||
**🎯 New Features:**
|
||||
|
||||
#### Shortcut URLs for Default CV
|
||||
- **NEW**: Memorable shortcut pattern `/cv-jamr-{year}-{lang}.pdf`
|
||||
- **NEW**: Automatic year validation (only current year accepted)
|
||||
- **NEW**: Auto-updates yearly without code changes
|
||||
- **NEW**: "Default CV (Recommended)" option in PDF modal
|
||||
- **Benefits**:
|
||||
- ✅ Easy to remember and share: `juan.andres.morenorub.io/cv-jamr-2025-en.pdf`
|
||||
- ✅ No complex query parameters
|
||||
- ✅ Professional, clean URLs
|
||||
- ✅ 301 redirect to proper PDF export endpoint
|
||||
|
||||
**Implementation:**
|
||||
- Handler: `DefaultCVShortcut()` in `internal/handlers/cv.go:222-263`
|
||||
- Route: Registered in `internal/routes/routes.go:17`
|
||||
- Modal: "Default CV" option with visual highlighting (purple gradient, star emoji)
|
||||
- Frontend: Dynamic year detection via JavaScript
|
||||
|
||||
**Testing:**
|
||||
- ✅ Current year URLs (2025): 301 redirect to default PDF
|
||||
- ✅ Past/future years: 404 Not Found
|
||||
- ✅ Both languages supported (en, es)
|
||||
|
||||
---
|
||||
|
||||
**Last Updated**: 2025-11-19
|
||||
**Version**: 2.0.0 (New Naming Convention + Light Mode Enforcement)
|
||||
**Last Updated**: 2025-11-20
|
||||
**Version**: 2.1.0 (Year-Aware Shortcut URLs)
|
||||
**Status**: Production ✅
|
||||
**Maintainer**: Development Team
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,954 +0,0 @@
|
||||
// CV Interactive Features - CSP-Compliant External JavaScript
|
||||
// Extracted from inline scripts for security hardening
|
||||
(function() {
|
||||
'use strict';
|
||||
|
||||
// =============================================================================
|
||||
// NAVIGATION & MENU SYSTEM
|
||||
// =============================================================================
|
||||
|
||||
// Hover-based menu control
|
||||
function initMenuSystem() {
|
||||
const hamburgerBtn = document.querySelector('.hamburger-btn');
|
||||
const menu = document.getElementById('navigation-menu');
|
||||
|
||||
if (!hamburgerBtn || !menu) return;
|
||||
|
||||
// Show menu on hamburger hover
|
||||
hamburgerBtn.addEventListener('mouseenter', function() {
|
||||
menu.classList.add('menu-hover');
|
||||
hamburgerBtn.setAttribute('aria-expanded', 'true');
|
||||
});
|
||||
|
||||
// Hide menu when leaving hamburger (only if not hovering menu)
|
||||
hamburgerBtn.addEventListener('mouseleave', function() {
|
||||
setTimeout(() => {
|
||||
if (!menu.matches(':hover')) {
|
||||
menu.classList.remove('menu-hover');
|
||||
hamburgerBtn.setAttribute('aria-expanded', 'false');
|
||||
}
|
||||
}, 100);
|
||||
});
|
||||
|
||||
// Hide menu when leaving menu itself
|
||||
menu.addEventListener('mouseleave', function() {
|
||||
menu.classList.remove('menu-hover');
|
||||
hamburgerBtn.setAttribute('aria-expanded', 'false');
|
||||
});
|
||||
|
||||
// Position submenu dynamically
|
||||
const submenuTrigger = document.querySelector('.menu-item-submenu');
|
||||
const submenuContent = document.querySelector('.submenu-content');
|
||||
|
||||
if (submenuTrigger && submenuContent) {
|
||||
submenuTrigger.addEventListener('mouseenter', function() {
|
||||
const triggerRect = submenuTrigger.getBoundingClientRect();
|
||||
submenuContent.style.top = `${triggerRect.top}px`;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Legacy toggle function - kept for compatibility
|
||||
window.toggleMenu = function() {
|
||||
const menu = document.getElementById('navigation-menu');
|
||||
const btn = document.querySelector('.hamburger-btn');
|
||||
|
||||
if (menu.classList.contains('menu-open')) {
|
||||
menu.classList.remove('menu-open');
|
||||
btn.setAttribute('aria-expanded', 'false');
|
||||
} else {
|
||||
menu.classList.add('menu-open');
|
||||
btn.setAttribute('aria-expanded', 'true');
|
||||
}
|
||||
};
|
||||
|
||||
// Flag to keep header visible after navigation
|
||||
let keepHeaderVisible = false;
|
||||
|
||||
// Toggle sidebar accordion (mobile only)
|
||||
window.toggleSidebar = function(header) {
|
||||
const content = header.nextElementSibling;
|
||||
const isActive = header.classList.contains('active');
|
||||
|
||||
if (isActive) {
|
||||
// Close
|
||||
header.classList.remove('active');
|
||||
content.classList.remove('active');
|
||||
} else {
|
||||
// Open
|
||||
header.classList.add('active');
|
||||
content.classList.add('active');
|
||||
}
|
||||
};
|
||||
|
||||
// Expand all sections
|
||||
window.expandAllSections = function(event) {
|
||||
event.preventDefault();
|
||||
const allDetails = document.querySelectorAll('details');
|
||||
allDetails.forEach(detail => {
|
||||
detail.setAttribute('open', '');
|
||||
});
|
||||
};
|
||||
|
||||
// Collapse all sections
|
||||
window.collapseAllSections = function(event) {
|
||||
event.preventDefault();
|
||||
const allDetails = document.querySelectorAll('details');
|
||||
allDetails.forEach(detail => {
|
||||
detail.removeAttribute('open');
|
||||
});
|
||||
};
|
||||
|
||||
// Toggle submenu - no longer needed for hover, but kept for compatibility
|
||||
window.toggleSubmenu = function(event) {
|
||||
event.preventDefault();
|
||||
const submenuContainer = event.currentTarget.parentElement;
|
||||
submenuContainer.classList.toggle('submenu-open');
|
||||
};
|
||||
|
||||
// Scroll to section smoothly
|
||||
window.scrollToSection = function(sectionId) {
|
||||
event.preventDefault(); // Prevent default anchor behavior
|
||||
|
||||
const section = document.getElementById(sectionId);
|
||||
if (section) {
|
||||
// Ensure header is visible before scrolling
|
||||
const actionBar = document.querySelector('.action-bar');
|
||||
const navMenu = document.querySelector('.navigation-menu');
|
||||
actionBar.classList.remove('header-hidden');
|
||||
navMenu.classList.remove('header-hidden');
|
||||
|
||||
// Set flag to keep header visible
|
||||
keepHeaderVisible = true;
|
||||
|
||||
// Close menu after clicking
|
||||
navMenu.classList.remove('menu-open');
|
||||
document.querySelector('.hamburger-btn').setAttribute('aria-expanded', 'false');
|
||||
|
||||
// Wait a bit for header to be visible, then calculate offset
|
||||
setTimeout(() => {
|
||||
const actionBarHeight = actionBar.offsetHeight;
|
||||
const offset = actionBarHeight + 20; // Add 20px padding
|
||||
|
||||
const elementPosition = section.getBoundingClientRect().top;
|
||||
const offsetPosition = elementPosition + window.pageYOffset - offset;
|
||||
|
||||
window.scrollTo({
|
||||
top: offsetPosition,
|
||||
behavior: 'smooth'
|
||||
});
|
||||
}, 100);
|
||||
}
|
||||
};
|
||||
|
||||
// Close menu when clicking outside (only for legacy click-opened menus)
|
||||
function initClickOutsideHandler() {
|
||||
document.addEventListener('click', function(event) {
|
||||
const menu = document.getElementById('navigation-menu');
|
||||
const btn = document.querySelector('.hamburger-btn');
|
||||
|
||||
if (menu && btn && menu.classList.contains('menu-open')) {
|
||||
if (!menu.contains(event.target) && !btn.contains(event.target)) {
|
||||
menu.classList.remove('menu-open');
|
||||
btn.setAttribute('aria-expanded', 'false');
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// LANGUAGE & PREFERENCES
|
||||
// =============================================================================
|
||||
|
||||
// Track if URL originally had lang parameter
|
||||
const urlHadLangParam = new URLSearchParams(window.location.search).has('lang');
|
||||
|
||||
window.selectLanguage = function(lang) {
|
||||
// Save language preference to localStorage
|
||||
localStorage.setItem('cv-language', lang);
|
||||
|
||||
// Reload page with new language parameter
|
||||
const url = new URL(window.location);
|
||||
url.searchParams.set('lang', lang);
|
||||
window.location.href = url.toString();
|
||||
};
|
||||
|
||||
window.toggleCVLength = function() {
|
||||
const headerToggle = document.getElementById('lengthToggle');
|
||||
const menuToggle = document.getElementById('lengthToggleMenu');
|
||||
const paper = document.querySelector('.cv-paper');
|
||||
|
||||
// Get the state from whichever toggle was clicked
|
||||
const isChecked = event?.target?.id === 'lengthToggleMenu' ? menuToggle?.checked : headerToggle?.checked;
|
||||
|
||||
// Sync both toggles
|
||||
if (headerToggle) headerToggle.checked = isChecked;
|
||||
if (menuToggle) menuToggle.checked = isChecked;
|
||||
|
||||
// Save current scroll position
|
||||
const currentScrollY = window.scrollY || window.pageYOffset;
|
||||
|
||||
if (isChecked) {
|
||||
paper.classList.add('cv-long');
|
||||
paper.classList.remove('cv-short');
|
||||
localStorage.setItem('cv-length', 'long');
|
||||
} else {
|
||||
paper.classList.add('cv-short');
|
||||
paper.classList.remove('cv-long');
|
||||
localStorage.setItem('cv-length', 'short');
|
||||
}
|
||||
|
||||
// Restore scroll position after DOM updates
|
||||
requestAnimationFrame(() => {
|
||||
window.scrollTo(0, currentScrollY);
|
||||
});
|
||||
};
|
||||
|
||||
window.toggleLogos = function() {
|
||||
const headerToggle = document.getElementById('logoToggle');
|
||||
const menuToggle = document.getElementById('logoToggleMenu');
|
||||
const paper = document.querySelector('.cv-paper');
|
||||
|
||||
// Get the state from whichever toggle was clicked
|
||||
const isChecked = event?.target?.id === 'logoToggleMenu' ? menuToggle?.checked : headerToggle?.checked;
|
||||
|
||||
// Sync both toggles
|
||||
if (headerToggle) headerToggle.checked = isChecked;
|
||||
if (menuToggle) menuToggle.checked = isChecked;
|
||||
|
||||
// Save current scroll position
|
||||
const currentScrollY = window.scrollY || window.pageYOffset;
|
||||
|
||||
if (isChecked) {
|
||||
paper.classList.add('show-logos');
|
||||
localStorage.setItem('cv-logos', 'show');
|
||||
} else {
|
||||
paper.classList.remove('show-logos');
|
||||
localStorage.setItem('cv-logos', 'hide');
|
||||
}
|
||||
|
||||
// Restore scroll position after DOM updates
|
||||
requestAnimationFrame(() => {
|
||||
window.scrollTo(0, currentScrollY);
|
||||
});
|
||||
};
|
||||
|
||||
window.toggleTheme = function() {
|
||||
const headerToggle = document.getElementById('themeToggle');
|
||||
const menuToggle = document.getElementById('themeToggleMenu');
|
||||
const container = document.querySelector('.cv-container');
|
||||
|
||||
// Get the state from whichever toggle was clicked
|
||||
const isChecked = event?.target?.id === 'themeToggleMenu' ? menuToggle?.checked : headerToggle?.checked;
|
||||
|
||||
// Sync both toggles
|
||||
if (headerToggle) headerToggle.checked = isChecked;
|
||||
if (menuToggle) menuToggle.checked = isChecked;
|
||||
|
||||
if (isChecked) {
|
||||
container.classList.add('theme-clean');
|
||||
localStorage.setItem('cv-theme', 'clean');
|
||||
} else {
|
||||
container.classList.remove('theme-clean');
|
||||
localStorage.setItem('cv-theme', 'default');
|
||||
}
|
||||
};
|
||||
|
||||
// =============================================================================
|
||||
// ZOOM CONTROL
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* Check if we're on mobile viewport
|
||||
* @returns {boolean} True if mobile (viewport <= 768px)
|
||||
*/
|
||||
function isMobileView() {
|
||||
return window.innerWidth <= 768;
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize zoom control on page load
|
||||
* Restores saved zoom level from localStorage (desktop only)
|
||||
*/
|
||||
function initZoomControl() {
|
||||
const slider = document.getElementById('zoom-slider');
|
||||
const resetBtn = document.getElementById('zoom-reset');
|
||||
const zoomWrapper = document.getElementById('zoom-wrapper');
|
||||
|
||||
if (!slider || !zoomWrapper) return;
|
||||
|
||||
// On mobile, always use 100% zoom (zoom control is hidden anyway)
|
||||
if (isMobileView()) {
|
||||
slider.value = 100;
|
||||
applyZoom(100, false);
|
||||
return; // Skip event listeners on mobile
|
||||
}
|
||||
|
||||
// Desktop: Restore saved zoom level from localStorage
|
||||
const savedZoom = localStorage.getItem('cv-zoom');
|
||||
if (savedZoom) {
|
||||
const zoomValue = parseInt(savedZoom, 10);
|
||||
slider.value = zoomValue;
|
||||
applyZoom(zoomValue, false); // false = don't save (already loaded from storage)
|
||||
}
|
||||
|
||||
// Real-time slider updates - immediate, smooth analog experience
|
||||
slider.addEventListener('input', function(e) {
|
||||
const zoomValue = parseInt(e.target.value, 10);
|
||||
|
||||
// Apply zoom and update display immediately for smooth analog feel
|
||||
updateZoomDisplay(zoomValue);
|
||||
applyZoom(zoomValue, true);
|
||||
});
|
||||
|
||||
// Reset button
|
||||
if (resetBtn) {
|
||||
resetBtn.addEventListener('click', function() {
|
||||
slider.value = 100;
|
||||
applyZoom(100, true);
|
||||
slider.focus(); // Return focus to slider for accessibility
|
||||
});
|
||||
}
|
||||
|
||||
// Keyboard shortcuts (Ctrl/Cmd + Plus/Minus/0)
|
||||
document.addEventListener('keydown', function(e) {
|
||||
if ((e.ctrlKey || e.metaKey) && !e.shiftKey) {
|
||||
if (e.key === '=' || e.key === '+') {
|
||||
e.preventDefault();
|
||||
incrementZoom(10);
|
||||
} else if (e.key === '-') {
|
||||
e.preventDefault();
|
||||
incrementZoom(-10);
|
||||
} else if (e.key === '0') {
|
||||
e.preventDefault();
|
||||
slider.value = 100;
|
||||
applyZoom(100, true);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Handle window resize - reset zoom when switching to mobile
|
||||
let resizeTimeout;
|
||||
window.addEventListener('resize', function() {
|
||||
clearTimeout(resizeTimeout);
|
||||
resizeTimeout = setTimeout(function() {
|
||||
if (isMobileView()) {
|
||||
// Reset to 100% zoom when switching to mobile
|
||||
slider.value = 100;
|
||||
applyZoom(100, false);
|
||||
}
|
||||
}, 250); // Debounce resize events
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply zoom transformation to CV paper
|
||||
* @param {number} zoomValue - Zoom percentage (25-175, centered at 100)
|
||||
* @param {boolean} saveToStorage - Whether to persist to localStorage
|
||||
*/
|
||||
function applyZoom(zoomValue, saveToStorage = true) {
|
||||
const zoomWrapper = document.getElementById('zoom-wrapper');
|
||||
if (!zoomWrapper) return;
|
||||
|
||||
// Convert percentage to decimal (100 = 1.0, 50 = 0.5, etc.)
|
||||
const zoomLevel = zoomValue / 100;
|
||||
|
||||
requestAnimationFrame(() => {
|
||||
// Use CSS zoom property - it properly affects layout and extends beyond viewport
|
||||
zoomWrapper.style.zoom = zoomLevel;
|
||||
|
||||
// When zoom > 100%, allow the wrapper to expand beyond viewport width
|
||||
// Set width to accommodate the expanded content without bounds
|
||||
if (zoomLevel > 1) {
|
||||
// Set width to auto to allow natural expansion
|
||||
zoomWrapper.style.width = 'auto';
|
||||
zoomWrapper.style.minWidth = '100%';
|
||||
// Remove max-width constraint to allow horizontal expansion
|
||||
zoomWrapper.style.maxWidth = 'none';
|
||||
} else {
|
||||
// Reset to default when zoom <= 100%
|
||||
zoomWrapper.style.width = '';
|
||||
zoomWrapper.style.minWidth = '';
|
||||
zoomWrapper.style.maxWidth = '';
|
||||
}
|
||||
|
||||
// Reset zoom on fixed buttons so they stay same size
|
||||
const backToTopBtn = document.getElementById('back-to-top');
|
||||
const infoBtn = document.getElementById('info-button');
|
||||
const inverseZoom = 1 / zoomLevel;
|
||||
|
||||
if (backToTopBtn) backToTopBtn.style.zoom = inverseZoom;
|
||||
if (infoBtn) infoBtn.style.zoom = inverseZoom;
|
||||
|
||||
// Update display
|
||||
updateZoomDisplay(zoomValue);
|
||||
|
||||
// Save to localStorage
|
||||
if (saveToStorage) {
|
||||
localStorage.setItem('cv-zoom', zoomValue.toString());
|
||||
}
|
||||
|
||||
// Update zoom control position for horizontal scroll
|
||||
updateZoomControlPosition();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Update visual display and ARIA attributes
|
||||
* @param {number} zoomValue - Current zoom percentage
|
||||
*/
|
||||
function updateZoomDisplay(zoomValue) {
|
||||
const slider = document.getElementById('zoom-slider');
|
||||
const display = document.getElementById('zoom-value-current');
|
||||
const resetBtn = document.getElementById('zoom-reset');
|
||||
|
||||
if (display) {
|
||||
display.textContent = zoomValue;
|
||||
}
|
||||
|
||||
if (slider) {
|
||||
slider.setAttribute('aria-valuenow', zoomValue);
|
||||
slider.setAttribute('aria-valuetext', `${zoomValue}%`);
|
||||
}
|
||||
|
||||
// Add/remove class to enable green hover only when zoom is not 100
|
||||
if (resetBtn) {
|
||||
if (zoomValue !== 100) {
|
||||
resetBtn.classList.add('zoom-not-default');
|
||||
} else {
|
||||
resetBtn.classList.remove('zoom-not-default');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Increment/decrement zoom by step amount
|
||||
* @param {number} step - Amount to change (positive or negative)
|
||||
*/
|
||||
function incrementZoom(step) {
|
||||
const slider = document.getElementById('zoom-slider');
|
||||
if (!slider) return;
|
||||
|
||||
const currentZoom = parseInt(slider.value, 10);
|
||||
const newZoom = Math.min(175, Math.max(25, currentZoom + step));
|
||||
|
||||
slider.value = newZoom;
|
||||
applyZoom(newZoom, true);
|
||||
}
|
||||
|
||||
/**
|
||||
* Update zoom control position based on horizontal scroll
|
||||
* This keeps the zoom control centered relative to the visible viewport
|
||||
*/
|
||||
function updateZoomControlPosition() {
|
||||
const zoomControl = document.getElementById('zoom-control');
|
||||
if (!zoomControl || isMobileView()) return;
|
||||
|
||||
// Only adjust if zoom control is in default centered position
|
||||
// (not dragged to a custom position)
|
||||
const savedPosition = localStorage.getItem('cv-zoom-position');
|
||||
if (savedPosition) return; // Don't adjust if user has dragged it
|
||||
|
||||
// Get current horizontal scroll position
|
||||
const scrollLeft = window.pageXOffset || document.documentElement.scrollLeft;
|
||||
|
||||
// Update left position to account for horizontal scroll
|
||||
if (scrollLeft > 0) {
|
||||
// Adjust position to stay centered in viewport during horizontal scroll
|
||||
zoomControl.style.left = `calc(50% + ${scrollLeft}px)`;
|
||||
} else {
|
||||
// Reset to center when scroll is at start
|
||||
zoomControl.style.left = '50%';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Make zoom control draggable and persist position
|
||||
*/
|
||||
function initZoomDragging() {
|
||||
const zoomControl = document.getElementById('zoom-control');
|
||||
if (!zoomControl || isMobileView()) return;
|
||||
|
||||
let isDragging = false;
|
||||
let currentX, currentY, initialX, initialY;
|
||||
|
||||
// Restore saved position from localStorage
|
||||
const savedPosition = localStorage.getItem('cv-zoom-position');
|
||||
if (savedPosition) {
|
||||
const { bottom, left } = JSON.parse(savedPosition);
|
||||
zoomControl.style.bottom = bottom;
|
||||
zoomControl.style.left = left;
|
||||
zoomControl.style.transform = 'none'; // Remove centering transform when positioned
|
||||
}
|
||||
|
||||
// Start drag on mousedown (but not on slider, close button, or reset button)
|
||||
zoomControl.addEventListener('mousedown', function(e) {
|
||||
// Ignore if clicking on interactive elements
|
||||
if (e.target.closest('.zoom-slider, .zoom-close-btn, .zoom-reset-btn')) {
|
||||
return;
|
||||
}
|
||||
|
||||
isDragging = true;
|
||||
zoomControl.style.transition = 'none'; // Disable transitions during drag
|
||||
|
||||
// Get current position
|
||||
const rect = zoomControl.getBoundingClientRect();
|
||||
initialX = e.clientX - rect.left;
|
||||
initialY = e.clientY - rect.top;
|
||||
|
||||
e.preventDefault();
|
||||
});
|
||||
|
||||
// Drag on mousemove
|
||||
document.addEventListener('mousemove', function(e) {
|
||||
if (!isDragging) return;
|
||||
|
||||
e.preventDefault();
|
||||
|
||||
currentX = e.clientX - initialX;
|
||||
currentY = e.clientY - initialY;
|
||||
|
||||
// Keep within viewport bounds
|
||||
const maxX = window.innerWidth - zoomControl.offsetWidth;
|
||||
const maxY = window.innerHeight - zoomControl.offsetHeight;
|
||||
|
||||
currentX = Math.max(0, Math.min(currentX, maxX));
|
||||
currentY = Math.max(0, Math.min(currentY, maxY));
|
||||
|
||||
// Update position
|
||||
zoomControl.style.left = currentX + 'px';
|
||||
zoomControl.style.bottom = (window.innerHeight - currentY - zoomControl.offsetHeight) + 'px';
|
||||
zoomControl.style.transform = 'none'; // Remove centering transform
|
||||
});
|
||||
|
||||
// End drag on mouseup
|
||||
document.addEventListener('mouseup', function() {
|
||||
if (isDragging) {
|
||||
isDragging = false;
|
||||
zoomControl.style.transition = 'all 0.3s ease'; // Re-enable transitions
|
||||
|
||||
// Save position to localStorage
|
||||
const position = {
|
||||
bottom: zoomControl.style.bottom,
|
||||
left: zoomControl.style.left
|
||||
};
|
||||
localStorage.setItem('cv-zoom-position', JSON.stringify(position));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Hide zoom control and show menu button
|
||||
*/
|
||||
function hideZoomControl() {
|
||||
const zoomControl = document.getElementById('zoom-control');
|
||||
const showButton = document.getElementById('show-zoom-menu-btn');
|
||||
|
||||
if (zoomControl) {
|
||||
zoomControl.style.display = 'none';
|
||||
localStorage.setItem('cv-zoom-visible', 'false');
|
||||
}
|
||||
|
||||
if (showButton) {
|
||||
showButton.style.display = 'block';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Show zoom control and hide menu button (global function for onclick)
|
||||
*/
|
||||
window.showZoomControl = function(event) {
|
||||
if (event) event.preventDefault(); // Prevent default link behavior
|
||||
|
||||
const zoomControl = document.getElementById('zoom-control');
|
||||
const showButton = document.getElementById('show-zoom-menu-btn');
|
||||
|
||||
if (zoomControl) {
|
||||
zoomControl.style.display = 'flex';
|
||||
localStorage.setItem('cv-zoom-visible', 'true');
|
||||
}
|
||||
|
||||
if (showButton) {
|
||||
showButton.style.display = 'none';
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Initialize zoom visibility state from localStorage
|
||||
*/
|
||||
function initZoomVisibility() {
|
||||
if (isMobileView()) return; // Always hidden on mobile
|
||||
|
||||
const zoomControl = document.getElementById('zoom-control');
|
||||
const showButton = document.getElementById('show-zoom-menu-btn');
|
||||
const isVisible = localStorage.getItem('cv-zoom-visible');
|
||||
|
||||
// Default to visible if not set
|
||||
if (isVisible === 'false') {
|
||||
if (zoomControl) zoomControl.style.display = 'none';
|
||||
if (showButton) showButton.style.display = 'block';
|
||||
} else {
|
||||
if (zoomControl) zoomControl.style.display = 'flex';
|
||||
if (showButton) showButton.style.display = 'none';
|
||||
}
|
||||
|
||||
// Setup close button
|
||||
const closeBtn = document.getElementById('zoom-close');
|
||||
if (closeBtn) {
|
||||
closeBtn.addEventListener('click', function(e) {
|
||||
e.stopPropagation(); // Prevent drag from starting
|
||||
hideZoomControl();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// PRINT & PDF
|
||||
// =============================================================================
|
||||
|
||||
// Print Friendly - Apply Clean Theme + Short Version for minimal printing
|
||||
window.printFriendly = function() {
|
||||
const container = document.querySelector('.cv-container');
|
||||
const paper = document.querySelector('.cv-paper');
|
||||
const wasClean = container.classList.contains('theme-clean');
|
||||
const wasLong = paper.classList.contains('cv-long');
|
||||
|
||||
// Store current zoom
|
||||
const currentZoom = localStorage.getItem('cv-zoom') || '100';
|
||||
|
||||
// Apply clean theme for minimal print (no sidebars, no header, no icons)
|
||||
if (!wasClean) {
|
||||
container.classList.add('theme-clean');
|
||||
}
|
||||
|
||||
// Force SHORT version for print (hide detailed content)
|
||||
paper.classList.remove('cv-long');
|
||||
paper.classList.add('cv-short');
|
||||
|
||||
// Temporarily reset zoom for printing
|
||||
if (paper) {
|
||||
paper.style.transform = 'scale(1)';
|
||||
}
|
||||
|
||||
// Small delay to let CSS apply
|
||||
setTimeout(() => {
|
||||
window.print();
|
||||
|
||||
// Restore original theme and length after print dialog closes
|
||||
setTimeout(() => {
|
||||
if (!wasClean) {
|
||||
container.classList.remove('theme-clean');
|
||||
}
|
||||
// Restore original length
|
||||
if (wasLong) {
|
||||
paper.classList.remove('cv-short');
|
||||
paper.classList.add('cv-long');
|
||||
}
|
||||
// Restore zoom
|
||||
if (paper && currentZoom !== '100') {
|
||||
applyZoom(parseInt(currentZoom, 10), false);
|
||||
}
|
||||
}, 100);
|
||||
}, 50);
|
||||
};
|
||||
|
||||
// =============================================================================
|
||||
// INITIALIZATION & PREFERENCES
|
||||
// =============================================================================
|
||||
|
||||
function initPreferences() {
|
||||
const paper = document.querySelector('.cv-paper');
|
||||
|
||||
// Handle language preference
|
||||
const urlLang = new URLSearchParams(window.location.search).get('lang');
|
||||
const savedLang = localStorage.getItem('cv-language');
|
||||
|
||||
if (!urlLang && savedLang) {
|
||||
// URL is clean but we have a saved preference - redirect with lang parameter
|
||||
const url = new URL(window.location);
|
||||
url.searchParams.set('lang', savedLang);
|
||||
window.location.replace(url.toString());
|
||||
} else if (urlLang) {
|
||||
// Save URL language to localStorage
|
||||
localStorage.setItem('cv-language', urlLang);
|
||||
}
|
||||
|
||||
// Restore CV length preference
|
||||
const savedLength = localStorage.getItem('cv-length') || 'short';
|
||||
const lengthChecked = savedLength === 'long';
|
||||
if (lengthChecked) {
|
||||
paper.classList.add('cv-long');
|
||||
paper.classList.remove('cv-short');
|
||||
} else {
|
||||
paper.classList.add('cv-short');
|
||||
paper.classList.remove('cv-long');
|
||||
}
|
||||
// Sync both header and menu toggles
|
||||
const headerLengthToggle = document.getElementById('lengthToggle');
|
||||
const menuLengthToggle = document.getElementById('lengthToggleMenu');
|
||||
if (headerLengthToggle) headerLengthToggle.checked = lengthChecked;
|
||||
if (menuLengthToggle) menuLengthToggle.checked = lengthChecked;
|
||||
|
||||
// Restore logos preference
|
||||
const savedLogos = localStorage.getItem('cv-logos') || 'show';
|
||||
const logosChecked = savedLogos === 'show';
|
||||
if (logosChecked) {
|
||||
paper.classList.add('show-logos');
|
||||
} else {
|
||||
paper.classList.remove('show-logos');
|
||||
}
|
||||
// Sync both header and menu toggles
|
||||
const headerLogoToggle = document.getElementById('logoToggle');
|
||||
const menuLogoToggle = document.getElementById('logoToggleMenu');
|
||||
if (headerLogoToggle) headerLogoToggle.checked = logosChecked;
|
||||
if (menuLogoToggle) menuLogoToggle.checked = logosChecked;
|
||||
|
||||
// Restore theme preference
|
||||
const savedTheme = localStorage.getItem('cv-theme') || 'default';
|
||||
const themeChecked = savedTheme === 'clean';
|
||||
// Sync both header and menu toggles
|
||||
const headerThemeToggle = document.getElementById('themeToggle');
|
||||
const menuThemeToggle = document.getElementById('themeToggleMenu');
|
||||
if (headerThemeToggle) headerThemeToggle.checked = themeChecked;
|
||||
if (menuThemeToggle) menuThemeToggle.checked = themeChecked;
|
||||
if (themeChecked) {
|
||||
window.toggleTheme();
|
||||
}
|
||||
|
||||
// Initialize zoom control (zoom level, event listeners)
|
||||
initZoomControl();
|
||||
|
||||
// Initialize zoom visibility state (show/hide based on localStorage)
|
||||
initZoomVisibility();
|
||||
|
||||
// Initialize zoom dragging (make draggable, restore position)
|
||||
initZoomDragging();
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// SCROLL BEHAVIOR
|
||||
// =============================================================================
|
||||
|
||||
function initScrollBehavior() {
|
||||
let lastScrollTop = 0;
|
||||
let scrollThreshold = 100; // Start hiding after 100px scroll
|
||||
|
||||
window.addEventListener('scroll', function() {
|
||||
const actionBar = document.querySelector('.action-bar');
|
||||
const navMenu = document.querySelector('.navigation-menu');
|
||||
const backToTopBtn = document.getElementById('back-to-top');
|
||||
const infoBtn = document.querySelector('.info-button');
|
||||
const currentScroll = window.pageYOffset || document.documentElement.scrollTop;
|
||||
const isMenuOpen = navMenu.classList.contains('menu-open');
|
||||
|
||||
// Update zoom control position on horizontal scroll
|
||||
updateZoomControlPosition();
|
||||
|
||||
// Check if at bottom of page (within 50px threshold)
|
||||
const scrollHeight = document.documentElement.scrollHeight;
|
||||
const clientHeight = document.documentElement.clientHeight;
|
||||
const isAtBottom = (scrollHeight - currentScroll - clientHeight) < 50;
|
||||
|
||||
// If scrolling up, reset the keepHeaderVisible flag
|
||||
if (currentScroll < lastScrollTop) {
|
||||
keepHeaderVisible = false;
|
||||
}
|
||||
|
||||
// Hide/show header based on scroll direction
|
||||
if (currentScroll > scrollThreshold) {
|
||||
if (currentScroll > lastScrollTop && !keepHeaderVisible) {
|
||||
// Scrolling down - hide header (only if keepHeaderVisible is false)
|
||||
actionBar.classList.add('header-hidden');
|
||||
// Only hide menu if it's open
|
||||
if (isMenuOpen) {
|
||||
navMenu.classList.add('header-hidden');
|
||||
}
|
||||
} else {
|
||||
// Scrolling up - show header
|
||||
actionBar.classList.remove('header-hidden');
|
||||
// Only show menu if it's open
|
||||
if (isMenuOpen) {
|
||||
navMenu.classList.remove('header-hidden');
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// At top - always show header
|
||||
actionBar.classList.remove('header-hidden');
|
||||
// Only affect menu if it's open
|
||||
if (isMenuOpen) {
|
||||
navMenu.classList.remove('header-hidden');
|
||||
}
|
||||
}
|
||||
|
||||
// Show/hide back to top button
|
||||
if (currentScroll > 300) {
|
||||
backToTopBtn.style.display = 'flex';
|
||||
} else {
|
||||
backToTopBtn.style.display = 'none';
|
||||
}
|
||||
|
||||
// Add/remove at-bottom class for both buttons
|
||||
if (isAtBottom) {
|
||||
if (backToTopBtn) backToTopBtn.classList.add('at-bottom');
|
||||
if (infoBtn) infoBtn.classList.add('at-bottom');
|
||||
} else {
|
||||
if (backToTopBtn) backToTopBtn.classList.remove('at-bottom');
|
||||
if (infoBtn) infoBtn.classList.remove('at-bottom');
|
||||
}
|
||||
|
||||
lastScrollTop = currentScroll <= 0 ? 0 : currentScroll;
|
||||
}, false);
|
||||
|
||||
// Back to top button click handler
|
||||
const backToTopBtn = document.getElementById('back-to-top');
|
||||
if (backToTopBtn) {
|
||||
backToTopBtn.addEventListener('click', function() {
|
||||
window.scrollTo({
|
||||
top: 0,
|
||||
behavior: 'smooth'
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// MODALS
|
||||
// =============================================================================
|
||||
|
||||
// Info Modal Functions
|
||||
window.openInfoModal = function() {
|
||||
const modal = document.getElementById('info-modal');
|
||||
modal.classList.add('active');
|
||||
document.body.style.overflow = 'hidden'; // Prevent scrolling when modal is open
|
||||
};
|
||||
|
||||
window.closeInfoModal = function() {
|
||||
const modal = document.getElementById('info-modal');
|
||||
modal.classList.remove('active');
|
||||
document.body.style.overflow = ''; // Restore scrolling
|
||||
};
|
||||
|
||||
window.closeInfoModalOnBackdrop = function(event) {
|
||||
if (event.target.id === 'info-modal') {
|
||||
window.closeInfoModal();
|
||||
}
|
||||
};
|
||||
|
||||
// PDF Modal Functions
|
||||
window.openPdfModal = function() {
|
||||
const modal = document.getElementById('pdf-modal');
|
||||
modal.classList.add('active');
|
||||
document.body.style.overflow = 'hidden'; // Prevent scrolling when modal is open
|
||||
};
|
||||
|
||||
window.closePdfModal = function() {
|
||||
const modal = document.getElementById('pdf-modal');
|
||||
modal.classList.remove('active');
|
||||
document.body.style.overflow = ''; // Restore scrolling
|
||||
};
|
||||
|
||||
window.closePdfModalOnBackdrop = function(event) {
|
||||
if (event.target.id === 'pdf-modal') {
|
||||
window.closePdfModal();
|
||||
}
|
||||
};
|
||||
|
||||
// Close modals with Escape key
|
||||
function initModalKeyHandlers() {
|
||||
document.addEventListener('keydown', function(event) {
|
||||
if (event.key === 'Escape') {
|
||||
window.closeInfoModal();
|
||||
window.closePdfModal();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// ERROR HANDLING
|
||||
// =============================================================================
|
||||
|
||||
// Error handling utility
|
||||
window.showError = function(message) {
|
||||
const errorToast = document.getElementById('error-toast');
|
||||
const errorMessage = document.getElementById('error-message');
|
||||
errorMessage.textContent = message;
|
||||
errorToast.style.display = 'flex';
|
||||
|
||||
// Auto-hide after 5 seconds
|
||||
setTimeout(() => {
|
||||
errorToast.style.display = 'none';
|
||||
}, 5000);
|
||||
};
|
||||
|
||||
// =============================================================================
|
||||
// HTMX EVENT HANDLERS
|
||||
// =============================================================================
|
||||
|
||||
function initHTMXHandlers() {
|
||||
// HTMX Global Error Handlers
|
||||
document.body.addEventListener('htmx:responseError', function(evt) {
|
||||
console.error('HTMX Response Error:', evt.detail);
|
||||
const lang = document.documentElement.lang;
|
||||
const message = lang === 'es'
|
||||
? 'Error al cargar el contenido. Por favor, inténtelo de nuevo.'
|
||||
: 'Failed to load content. Please try again.';
|
||||
window.showError(message);
|
||||
});
|
||||
|
||||
document.body.addEventListener('htmx:sendError', function(evt) {
|
||||
console.error('HTMX Send Error:', evt.detail);
|
||||
const lang = document.documentElement.lang;
|
||||
const message = lang === 'es'
|
||||
? 'Error de conexión. Verifique su conexión a internet.'
|
||||
: 'Connection error. Please check your internet connection.';
|
||||
window.showError(message);
|
||||
});
|
||||
|
||||
document.body.addEventListener('htmx:timeout', function(evt) {
|
||||
console.error('HTMX Timeout:', evt.detail);
|
||||
const lang = document.documentElement.lang;
|
||||
const message = lang === 'es'
|
||||
? 'La solicitud tardó demasiado. Por favor, inténtelo de nuevo.'
|
||||
: 'Request timed out. Please try again.';
|
||||
window.showError(message);
|
||||
});
|
||||
|
||||
document.body.addEventListener('htmx:afterSwap', function(evt) {
|
||||
// Smooth scroll to top on language change
|
||||
if (evt.detail.target.id === 'cv-content') {
|
||||
window.scrollTo({ top: 0, behavior: 'smooth' });
|
||||
}
|
||||
|
||||
// Track HTMX navigation events with Matomo
|
||||
if (typeof _paq !== 'undefined' && evt.detail.target.id === 'cv-content') {
|
||||
// Track language change as virtual pageview
|
||||
const lang = new URLSearchParams(window.location.search).get('lang') || 'en';
|
||||
_paq.push(['setCustomUrl', window.location.href]);
|
||||
_paq.push(['setDocumentTitle', document.title]);
|
||||
_paq.push(['trackPageView']);
|
||||
}
|
||||
});
|
||||
|
||||
// Log successful swaps for debugging
|
||||
document.body.addEventListener('htmx:afterRequest', function(evt) {
|
||||
if (evt.detail.successful) {
|
||||
console.log('HTMX request successful:', evt.detail.pathInfo.requestPath);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// INITIALIZATION
|
||||
// =============================================================================
|
||||
|
||||
// Initialize everything when DOM is ready
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
initMenuSystem();
|
||||
initClickOutsideHandler();
|
||||
initPreferences();
|
||||
initScrollBehavior();
|
||||
initModalKeyHandlers();
|
||||
initHTMXHandlers();
|
||||
});
|
||||
|
||||
})();
|
||||
Reference in New Issue
Block a user