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
|
# 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
|
## Design Philosophy
|
||||||
|
|
||||||
### Why This Naming Convention?
|
### Why This Naming Convention?
|
||||||
@@ -660,9 +759,35 @@ The system automatically handles year rollovers:
|
|||||||
- ✅ Backwards compatible with existing user preferences
|
- ✅ Backwards compatible with existing user preferences
|
||||||
- ✅ Comprehensive documentation and troubleshooting
|
- ✅ 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
|
**Last Updated**: 2025-11-20
|
||||||
**Version**: 2.0.0 (New Naming Convention + Light Mode Enforcement)
|
**Version**: 2.1.0 (Year-Aware Shortcut URLs)
|
||||||
**Status**: Production ✅
|
**Status**: Production ✅
|
||||||
**Maintainer**: Development Team
|
**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