feat: simplify architecture by removing cache layer and centralizing routes
- Removed over-engineered cache system for static CV data that only changes on deployment - Extracted all route configuration to internal/routes/routes.go for better organization - Implemented rate limiting and cache control middleware for PDF endpoint protection
This commit is contained in:
+56
-13
@@ -148,15 +148,53 @@ For comprehensive documentation of each endpoint, request/response formats, and
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
### Route Organization
|
||||||
|
|
||||||
|
All routes and middleware configuration are centralized in `internal/routes/routes.go` for clean separation of concerns:
|
||||||
|
|
||||||
|
```go
|
||||||
|
// internal/routes/routes.go
|
||||||
|
func Setup(cvHandler *handlers.CVHandler, healthHandler *handlers.HealthHandler) http.Handler {
|
||||||
|
mux := http.NewServeMux()
|
||||||
|
|
||||||
|
// Public routes
|
||||||
|
mux.HandleFunc("/", cvHandler.Home)
|
||||||
|
mux.HandleFunc("/cv", cvHandler.CVContent)
|
||||||
|
mux.HandleFunc("/health", healthHandler.Check)
|
||||||
|
|
||||||
|
// Protected PDF endpoint (rate limited + origin checked)
|
||||||
|
// Static files with cache control
|
||||||
|
// Middleware chain (Recovery → Logger → SecurityHeaders)
|
||||||
|
|
||||||
|
return handler
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
This architecture provides:
|
||||||
|
- ✅ Single source of truth for routes
|
||||||
|
- ✅ Clear middleware chain visibility
|
||||||
|
- ✅ Easy route management and testing
|
||||||
|
- ✅ Clean separation from main.go
|
||||||
|
|
||||||
|
### Data Loading
|
||||||
|
|
||||||
|
**Simplified Architecture** (cache removed as of v1.1.0):
|
||||||
|
- JSON files loaded directly from disk on each request
|
||||||
|
- No caching layer (over-engineering for static CV data)
|
||||||
|
- Go's built-in file system caching is sufficient
|
||||||
|
- Data only changes on deployment/restart
|
||||||
|
|
||||||
## Endpoints Overview
|
## Endpoints Overview
|
||||||
|
|
||||||
| Method | Path | Description | HTMX Support |
|
| Method | Path | Description | HTMX Support | Protection |
|
||||||
|--------|------|-------------|--------------|
|
|--------|------|-------------|--------------|------------|
|
||||||
| GET | `/` | Full CV page (home) | ❌ No |
|
| GET | `/` | Full CV page (home) | ❌ No | None |
|
||||||
| GET | `/cv` | CV content partial | ✅ Yes |
|
| GET | `/cv` | CV content partial | ✅ Yes | None |
|
||||||
| GET | `/export/pdf` | PDF export | ❌ No |
|
| GET | `/export/pdf` | PDF export | ❌ No | ✅ Rate Limited + Origin Check |
|
||||||
| GET | `/health` | Health check | ❌ No |
|
| GET | `/health` | Health check | ❌ No | None |
|
||||||
| GET | `/static/*` | Static files (CSS, JS, images) | ❌ No |
|
| GET | `/static/*` | Static files (CSS, JS, images) | ❌ No | Cache Control |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -458,7 +496,7 @@ No special headers required.
|
|||||||
```
|
```
|
||||||
|
|
||||||
#### Schema
|
#### Schema
|
||||||
|
|
||||||
| Field | Type | Description |
|
| Field | Type | Description |
|
||||||
|-------|------|-------------|
|
|-------|------|-------------|
|
||||||
| `status` | string | Always `"ok"` if server is running |
|
| `status` | string | Always `"ok"` if server is running |
|
||||||
@@ -471,6 +509,8 @@ No special headers required.
|
|||||||
|
|
||||||
**curl:**
|
**curl:**
|
||||||
```bash
|
```bash
|
||||||
|
curl http://localhost:1999/health
|
||||||
|
```
|
||||||
|
|
||||||
**curl with pretty print:**
|
**curl with pretty print:**
|
||||||
```bash
|
```bash
|
||||||
@@ -1253,9 +1293,11 @@ For comprehensive protection documentation, see [SECURITY.md](SECURITY.md#api-pr
|
|||||||
|
|
||||||
| Endpoint | Recommended Limit | Burst |
|
| Endpoint | Recommended Limit | Burst |
|
||||||
|----------|-------------------|-------|
|
|----------|-------------------|-------|
|
||||||
| `/export/pdf` | 5 req/min | 2 |
|
| `/` | 20 req/min | 10 |
|
||||||
| `/cv` | 30 req/min | 15 |
|
| `/cv` | 30 req/min | 15 |
|
||||||
| `/static/*` | 100 req/min | 50 |
|
| `/export/pdf` | 5 req/min | 2 |
|
||||||
|
| `/health` | Unlimited | - |
|
||||||
|
| `/static/*` | 100 req/min | 50 |
|
||||||
|
|
||||||
#### Implementation Options
|
#### Implementation Options
|
||||||
|
|
||||||
@@ -1925,6 +1967,7 @@ go tool trace trace.out
|
|||||||
- [DEPLOYMENT.md](DEPLOYMENT.md) - Deployment guides
|
- [DEPLOYMENT.md](DEPLOYMENT.md) - Deployment guides
|
||||||
|
|
||||||
### Support
|
### Support
|
||||||
|
|
||||||
**Issues:** [GitHub Issues](https://github.com/juanatsap/cv-site/issues)
|
**Issues:** [GitHub Issues](https://github.com/juanatsap/cv-site/issues)
|
||||||
**Email:** [juan.a.moreno.rubio@gmail.com](mailto:juan.a.moreno.rubio@gmail.com)
|
**Email:** [juan.a.moreno.rubio@gmail.com](mailto:juan.a.moreno.rubio@gmail.com)
|
||||||
|
|
||||||
@@ -1940,6 +1983,6 @@ go tool trace trace.out
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
**Last Updated:** November 9, 2025
|
**Last Updated:** November 12, 2025
|
||||||
**API Version:** 1.0.0
|
**API Version:** 1.1.0
|
||||||
**Documentation Version:** 1.0.0
|
**Documentation Version:** 1.1.0
|
||||||
|
|||||||
@@ -0,0 +1,633 @@
|
|||||||
|
# User Guide - CV Website Features
|
||||||
|
|
||||||
|
This guide provides comprehensive documentation for all interactive features of the CV website.
|
||||||
|
|
||||||
|
## Table of Contents
|
||||||
|
|
||||||
|
- [Zoom Feature](#zoom-feature)
|
||||||
|
- [Language Switching](#language-switching)
|
||||||
|
- [CV Customization](#cv-customization)
|
||||||
|
- [PDF Export](#pdf-export)
|
||||||
|
- [Navigation](#navigation)
|
||||||
|
- [Accessibility](#accessibility)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Zoom Feature
|
||||||
|
|
||||||
|
The CV includes an intelligent zoom control for comfortable viewing at any size.
|
||||||
|
|
||||||
|
### Overview
|
||||||
|
|
||||||
|
The zoom feature allows you to adjust the CV display size from 25% (bird's-eye view) to 175% (enhanced readability) while keeping navigation controls at a consistent, accessible size.
|
||||||
|
|
||||||
|
### How to Use
|
||||||
|
|
||||||
|
#### Visual Control
|
||||||
|
|
||||||
|
The zoom control appears as a draggable widget near the bottom of the screen (desktop only).
|
||||||
|
|
||||||
|
**Components:**
|
||||||
|
- **Slider**: Drag anywhere between 25% and 175% zoom
|
||||||
|
- **Min/Max Labels**: Show the range (25% and 175%)
|
||||||
|
- **Reset Button**: Circular button showing current zoom percentage
|
||||||
|
- **Close Button**: Small X button (top-right corner) to hide the control
|
||||||
|
|
||||||
|
**Interaction:**
|
||||||
|
1. **Adjust Zoom**: Click and drag the slider to your preferred zoom level
|
||||||
|
2. **Reset to Default**: Click the circular button (shows current %) to return to 100%
|
||||||
|
3. **Reposition Control**: Click and drag the control itself (not the slider/buttons) to move it anywhere on screen
|
||||||
|
4. **Hide Control**: Click the X button to hide the control
|
||||||
|
5. **Show Control**: Access from hamburger menu (☰) → Select "Zoom"
|
||||||
|
|
||||||
|
#### Keyboard Shortcuts
|
||||||
|
|
||||||
|
The zoom feature supports standard browser-style keyboard shortcuts:
|
||||||
|
|
||||||
|
| Shortcut | Action | Increment |
|
||||||
|
|----------|--------|-----------|
|
||||||
|
| `Ctrl/Cmd + Plus (+)` | Zoom In | +10% |
|
||||||
|
| `Ctrl/Cmd + Minus (-)` | Zoom Out | -10% |
|
||||||
|
| `Ctrl/Cmd + 0` | Reset to Default | 100% |
|
||||||
|
|
||||||
|
**Note**: These shortcuts work regardless of whether the zoom control is visible.
|
||||||
|
|
||||||
|
#### Menu Access
|
||||||
|
|
||||||
|
1. Click the **hamburger menu (☰)** in the top-left corner
|
||||||
|
2. Hover or click **"Zoom"** menu item
|
||||||
|
3. The zoom control will appear
|
||||||
|
4. Use the control as described above
|
||||||
|
|
||||||
|
### What Zooms vs. What Stays Fixed
|
||||||
|
|
||||||
|
#### Elements That Zoom (Content)
|
||||||
|
|
||||||
|
The following elements scale according to the zoom level:
|
||||||
|
- All CV content (text, headings, paragraphs)
|
||||||
|
- Images and icons within the CV
|
||||||
|
- CV paper and layout structure
|
||||||
|
- Section spacing and margins
|
||||||
|
- All typography and line heights
|
||||||
|
|
||||||
|
**Behavior at >100% zoom:**
|
||||||
|
- Content extends beyond viewport width
|
||||||
|
- Horizontal scrollbar appears
|
||||||
|
- Natural overflow allows full-size viewing
|
||||||
|
- Matches browser zoom behavior
|
||||||
|
|
||||||
|
#### Elements That Stay Fixed (UI Controls)
|
||||||
|
|
||||||
|
The following elements maintain consistent size regardless of zoom:
|
||||||
|
- **Action Bar**: Top black bar with all buttons
|
||||||
|
- **Hamburger Menu**: Navigation menu and all menu items
|
||||||
|
- **Zoom Control**: The zoom widget itself
|
||||||
|
- **Back-to-Top Button**: Bottom-right corner button
|
||||||
|
- **Info Button**: Information modal trigger
|
||||||
|
- **Footer**: Bottom footer remains at normal size
|
||||||
|
|
||||||
|
**Why fixed UI?**
|
||||||
|
- **Accessibility**: Controls stay at standard touch target size (44x44px)
|
||||||
|
- **Usability**: Buttons never become too large or too small
|
||||||
|
- **Consistency**: Predictable interaction at any zoom level
|
||||||
|
- **Navigation**: Always accessible regardless of content zoom state
|
||||||
|
|
||||||
|
### Design Rationale
|
||||||
|
|
||||||
|
#### Zoom Range: 25-175%
|
||||||
|
|
||||||
|
**25% - Bird's Eye View:**
|
||||||
|
- See entire CV structure at once
|
||||||
|
- Great for navigation and overview
|
||||||
|
- Check layout balance and spacing
|
||||||
|
- Quick section location
|
||||||
|
- Ideal for presentations or large screens
|
||||||
|
|
||||||
|
**100% - Optimal Reading:**
|
||||||
|
- Default and recommended setting
|
||||||
|
- Calibrated for comfortable reading
|
||||||
|
- Optimal font rendering and line length
|
||||||
|
- Balanced for most screen sizes
|
||||||
|
- Professional presentation view
|
||||||
|
|
||||||
|
**175% - Enhanced Readability:**
|
||||||
|
- Enhanced readability for detailed review
|
||||||
|
- Accessibility support for vision needs
|
||||||
|
- Close inspection of specific content
|
||||||
|
- Better for small or high-DPI screens
|
||||||
|
- Reduced eye strain for long reading sessions
|
||||||
|
|
||||||
|
#### Fixed UI Elements (Inverse Zoom Technique)
|
||||||
|
|
||||||
|
**Technical Implementation:**
|
||||||
|
Fixed buttons use `zoom: 1/zoomLevel` to counteract content zoom.
|
||||||
|
|
||||||
|
**Example:**
|
||||||
|
- Content zoom: 150% (`zoom: 1.5`)
|
||||||
|
- Button inverse: 67% (`zoom: 0.67`)
|
||||||
|
- Result: Button stays at original size
|
||||||
|
|
||||||
|
**Benefits:**
|
||||||
|
- Buttons remain at accessibility-compliant size (44x44px)
|
||||||
|
- Prevents unusably large buttons at 175% zoom
|
||||||
|
- Prevents unusably small buttons at 25% zoom
|
||||||
|
- Maintains muscle memory for button positions
|
||||||
|
- Consistent visual hierarchy
|
||||||
|
|
||||||
|
#### Horizontal Scroll Behavior
|
||||||
|
|
||||||
|
**Why horizontal scroll appears:**
|
||||||
|
- Content naturally extends beyond viewport at >100% zoom
|
||||||
|
- Allows viewing full-size content without compression
|
||||||
|
- Matches expected browser zoom behavior
|
||||||
|
- Prevents text reflow issues
|
||||||
|
- Maintains layout integrity
|
||||||
|
|
||||||
|
**User Experience:**
|
||||||
|
- Smooth scrolling on all devices
|
||||||
|
- Keyboard navigation supported (arrow keys)
|
||||||
|
- Mouse wheel scrolling (shift + wheel)
|
||||||
|
- Trackpad gestures work naturally
|
||||||
|
- Does not interfere with vertical scrolling
|
||||||
|
|
||||||
|
### Optimal Viewing Tips
|
||||||
|
|
||||||
|
#### Quick Overview (25-75% Zoom)
|
||||||
|
|
||||||
|
**Best For:**
|
||||||
|
- Getting familiar with CV structure
|
||||||
|
- Locating specific sections quickly
|
||||||
|
- Checking overall layout balance
|
||||||
|
- Comparing multiple sections
|
||||||
|
- Presentation mode on large screens
|
||||||
|
|
||||||
|
**Recommended Settings:**
|
||||||
|
- Zoom: 50-75%
|
||||||
|
- View: Full screen
|
||||||
|
- Navigation: Use section links
|
||||||
|
|
||||||
|
#### Comfortable Reading (100% Zoom)
|
||||||
|
|
||||||
|
**Best For:**
|
||||||
|
- Primary reading experience
|
||||||
|
- Professional presentation
|
||||||
|
- Standard review and evaluation
|
||||||
|
- Sharing with others
|
||||||
|
- PDF export preparation
|
||||||
|
|
||||||
|
**Recommended Settings:**
|
||||||
|
- Zoom: 100% (default)
|
||||||
|
- View: Full screen or windowed
|
||||||
|
- Font rendering: Optimal
|
||||||
|
|
||||||
|
#### Detailed Review (125-175% Zoom)
|
||||||
|
|
||||||
|
**Best For:**
|
||||||
|
- Enhanced readability needs
|
||||||
|
- Close inspection of content
|
||||||
|
- Accessibility requirements
|
||||||
|
- Small screen viewing
|
||||||
|
- Detailed proofreading
|
||||||
|
|
||||||
|
**Recommended Settings:**
|
||||||
|
- Zoom: 125-150%
|
||||||
|
- Use horizontal scroll to navigate
|
||||||
|
- Focus on one section at a time
|
||||||
|
- Combine with section navigation
|
||||||
|
|
||||||
|
### Persistence and Storage
|
||||||
|
|
||||||
|
**Automatic Save:**
|
||||||
|
- Your zoom level is automatically saved to browser's localStorage
|
||||||
|
- Returns to your preferred zoom when you revisit
|
||||||
|
- No manual save required
|
||||||
|
|
||||||
|
**Per-Device Storage:**
|
||||||
|
- Each device/browser remembers its own zoom preference
|
||||||
|
- Desktop and mobile can have different settings
|
||||||
|
- Private/Incognito mode does not persist zoom
|
||||||
|
|
||||||
|
**Storage Keys:**
|
||||||
|
- `cv-zoom`: Current zoom percentage (25-175)
|
||||||
|
- `cv-zoom-position`: Custom position if dragged
|
||||||
|
- `cv-zoom-visible`: Whether control is shown/hidden
|
||||||
|
|
||||||
|
**Clear Storage:**
|
||||||
|
To reset zoom preferences:
|
||||||
|
1. Browser DevTools → Application → Local Storage
|
||||||
|
2. Find keys starting with `cv-zoom`
|
||||||
|
3. Delete to reset to defaults
|
||||||
|
|
||||||
|
### Mobile Behavior
|
||||||
|
|
||||||
|
**Automatic Handling:**
|
||||||
|
- Zoom control is **hidden on mobile devices** (screens ≤768px)
|
||||||
|
- Mobile browsers have native pinch-to-zoom
|
||||||
|
- Native zoom works better on small screens
|
||||||
|
- Touch gestures are more intuitive on mobile
|
||||||
|
|
||||||
|
**Why hidden on mobile?**
|
||||||
|
- Limited screen space for controls
|
||||||
|
- Native pinch-to-zoom is superior
|
||||||
|
- Touch targets would be too small
|
||||||
|
- Desktop-oriented feature
|
||||||
|
- Mobile viewport already optimized
|
||||||
|
|
||||||
|
**Mobile Alternatives:**
|
||||||
|
- Use browser's native pinch-to-zoom
|
||||||
|
- Double-tap to zoom sections
|
||||||
|
- Use browser zoom in/out buttons
|
||||||
|
|
||||||
|
### Accessibility Features
|
||||||
|
|
||||||
|
#### Keyboard Support
|
||||||
|
|
||||||
|
**Full Keyboard Navigation:**
|
||||||
|
- `Tab`: Navigate to zoom control
|
||||||
|
- `Space`/`Enter`: Activate reset button
|
||||||
|
- `Arrow Keys`: Adjust slider (when focused)
|
||||||
|
- `Ctrl/Cmd + Plus/Minus/0`: Global shortcuts
|
||||||
|
- `Esc`: Close menu/modals
|
||||||
|
|
||||||
|
#### Screen Reader Support
|
||||||
|
|
||||||
|
**ARIA Attributes:**
|
||||||
|
```html
|
||||||
|
<input
|
||||||
|
type="range"
|
||||||
|
aria-label="Adjust CV zoom level"
|
||||||
|
aria-valuemin="25"
|
||||||
|
aria-valuemax="175"
|
||||||
|
aria-valuenow="100"
|
||||||
|
aria-valuetext="100%"
|
||||||
|
>
|
||||||
|
```
|
||||||
|
|
||||||
|
**Announcements:**
|
||||||
|
- Current zoom level announced on change
|
||||||
|
- Reset button announces "Reset zoom to 100%"
|
||||||
|
- Slider range clearly communicated
|
||||||
|
- Interactive elements properly labeled
|
||||||
|
|
||||||
|
#### Visual Accessibility
|
||||||
|
|
||||||
|
**High Contrast:**
|
||||||
|
- Slider track: Clear contrast ratio
|
||||||
|
- Button borders: Visible in all modes
|
||||||
|
- Text labels: White on semi-transparent background
|
||||||
|
- Focus indicators: 2px white outline
|
||||||
|
|
||||||
|
**Focus Indicators:**
|
||||||
|
- All interactive elements have visible focus
|
||||||
|
- Keyboard navigation clearly indicated
|
||||||
|
- Tab order follows logical flow
|
||||||
|
|
||||||
|
**Touch Targets:**
|
||||||
|
- All buttons meet 44x44px minimum
|
||||||
|
- Slider thumb: 18px diameter
|
||||||
|
- Close button: 24px diameter
|
||||||
|
- Reset button: 44px diameter
|
||||||
|
|
||||||
|
### Troubleshooting
|
||||||
|
|
||||||
|
#### Zoom Not Working
|
||||||
|
|
||||||
|
**Symptoms:**
|
||||||
|
- Slider moves but nothing happens
|
||||||
|
- Keyboard shortcuts don't work
|
||||||
|
- Page doesn't zoom
|
||||||
|
|
||||||
|
**Solutions:**
|
||||||
|
1. Check browser console for JavaScript errors
|
||||||
|
2. Ensure JavaScript is enabled
|
||||||
|
3. Try hard refresh: `Ctrl/Cmd + Shift + R`
|
||||||
|
4. Clear browser cache and reload
|
||||||
|
5. Check if browser zoom is interfering (reset to 100%)
|
||||||
|
|
||||||
|
#### Zoom Control Not Visible
|
||||||
|
|
||||||
|
**Symptoms:**
|
||||||
|
- Can't find zoom control on page
|
||||||
|
- Control disappeared
|
||||||
|
|
||||||
|
**Solutions:**
|
||||||
|
1. Check if you're on mobile (control hidden <768px)
|
||||||
|
2. Open hamburger menu → Click "Zoom" to show
|
||||||
|
3. Check localStorage: `cv-zoom-visible` should be `true`
|
||||||
|
4. Control may be positioned off-screen (drag from edge)
|
||||||
|
5. Refresh page to reset position
|
||||||
|
|
||||||
|
#### Zoom Resets on Refresh
|
||||||
|
|
||||||
|
**Symptoms:**
|
||||||
|
- Zoom goes back to 100% every time
|
||||||
|
- Preference not saving
|
||||||
|
|
||||||
|
**Solutions:**
|
||||||
|
1. Check if localStorage is enabled in browser
|
||||||
|
2. Ensure you're not in Private/Incognito mode
|
||||||
|
3. Check browser privacy settings
|
||||||
|
4. Try a different browser to isolate issue
|
||||||
|
|
||||||
|
#### Horizontal Scroll Issues
|
||||||
|
|
||||||
|
**Symptoms:**
|
||||||
|
- Can't scroll horizontally when zoomed
|
||||||
|
- Content cut off at edges
|
||||||
|
|
||||||
|
**Solutions:**
|
||||||
|
1. Ensure body overflow-x is set to auto
|
||||||
|
2. Try Shift + Mouse Wheel for horizontal scroll
|
||||||
|
3. Use keyboard arrow keys
|
||||||
|
4. Check if browser extensions are interfering
|
||||||
|
|
||||||
|
#### Fixed Buttons Look Wrong
|
||||||
|
|
||||||
|
**Symptoms:**
|
||||||
|
- Buttons too large or too small
|
||||||
|
- Buttons scaling with content
|
||||||
|
|
||||||
|
**Solutions:**
|
||||||
|
1. Check if CSS zoom property is supported
|
||||||
|
2. Verify browser compatibility (all modern browsers)
|
||||||
|
3. Check for conflicting CSS from extensions
|
||||||
|
4. Try disabling browser themes/extensions
|
||||||
|
|
||||||
|
### Technical Details
|
||||||
|
|
||||||
|
#### Implementation
|
||||||
|
|
||||||
|
**CSS Zoom Property:**
|
||||||
|
```css
|
||||||
|
.zoom-wrapper {
|
||||||
|
zoom: 1.25; /* 125% zoom example */
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Inverse Zoom for Fixed Elements:**
|
||||||
|
```css
|
||||||
|
.fixed-button {
|
||||||
|
zoom: 0.8; /* 1/1.25 = 0.8 to counteract 125% */
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Horizontal Overflow:**
|
||||||
|
```css
|
||||||
|
body {
|
||||||
|
overflow-x: auto; /* Allow horizontal scroll */
|
||||||
|
}
|
||||||
|
|
||||||
|
.zoom-wrapper {
|
||||||
|
min-width: 100vw; /* When zoomed > 100% */
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### JavaScript API
|
||||||
|
|
||||||
|
**Key Functions:**
|
||||||
|
```javascript
|
||||||
|
// Apply zoom level
|
||||||
|
applyZoom(zoomValue, saveToStorage)
|
||||||
|
|
||||||
|
// Increment/decrement zoom
|
||||||
|
incrementZoom(step)
|
||||||
|
|
||||||
|
// Update display
|
||||||
|
updateZoomDisplay(zoomValue)
|
||||||
|
|
||||||
|
// Check mobile view
|
||||||
|
isMobileView()
|
||||||
|
```
|
||||||
|
|
||||||
|
**Event Listeners:**
|
||||||
|
- `input`: Real-time slider updates
|
||||||
|
- `click`: Reset button handler
|
||||||
|
- `keydown`: Global keyboard shortcuts
|
||||||
|
- `resize`: Handle viewport changes
|
||||||
|
|
||||||
|
#### Browser Compatibility
|
||||||
|
|
||||||
|
**Supported Browsers:**
|
||||||
|
- Chrome/Edge 90+ (Full support)
|
||||||
|
- Firefox 90+ (Full support)
|
||||||
|
- Safari 13+ (Full support)
|
||||||
|
- Opera 76+ (Full support)
|
||||||
|
|
||||||
|
**CSS Zoom Support:**
|
||||||
|
- Webkit/Blink: Native support
|
||||||
|
- Firefox: Supported since Firefox 90
|
||||||
|
- All modern browsers support CSS zoom
|
||||||
|
|
||||||
|
**Fallback:**
|
||||||
|
- No fallback needed (all modern browsers)
|
||||||
|
- For older browsers, zoom would simply not work
|
||||||
|
- Page remains fully functional without zoom
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Language Switching
|
||||||
|
|
||||||
|
The CV supports instant language switching between English and Spanish without page reload.
|
||||||
|
|
||||||
|
### How to Use
|
||||||
|
|
||||||
|
**From Action Bar:**
|
||||||
|
1. Click language flag icon in top bar
|
||||||
|
2. Select English or Spanish
|
||||||
|
3. Content swaps instantly via HTMX
|
||||||
|
|
||||||
|
**From Menu:**
|
||||||
|
1. Open hamburger menu (☰)
|
||||||
|
2. Hover over "Language" item
|
||||||
|
3. Select preferred language
|
||||||
|
|
||||||
|
**Via URL:**
|
||||||
|
- English: `?lang=en`
|
||||||
|
- Spanish: `?lang=es`
|
||||||
|
|
||||||
|
**Persistence:**
|
||||||
|
- Language preference saved to localStorage
|
||||||
|
- Returns to your preferred language on next visit
|
||||||
|
- Works across all pages
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## CV Customization
|
||||||
|
|
||||||
|
### CV Length Toggle
|
||||||
|
|
||||||
|
**Short Version:**
|
||||||
|
- Concise overview
|
||||||
|
- Key highlights only
|
||||||
|
- 1-2 pages when printed
|
||||||
|
- Quick review
|
||||||
|
|
||||||
|
**Long Version:**
|
||||||
|
- Complete details
|
||||||
|
- Full project descriptions
|
||||||
|
- Comprehensive experience
|
||||||
|
- In-depth review
|
||||||
|
|
||||||
|
**Toggle:**
|
||||||
|
1. Action bar toggle switch
|
||||||
|
2. Hamburger menu → "Long Version"
|
||||||
|
3. Preference persisted to localStorage
|
||||||
|
|
||||||
|
### Logo Visibility
|
||||||
|
|
||||||
|
**Show Logos:**
|
||||||
|
- Company and technology logos visible
|
||||||
|
- Visual brand recognition
|
||||||
|
- More engaging presentation
|
||||||
|
|
||||||
|
**Hide Logos:**
|
||||||
|
- Text-only presentation
|
||||||
|
- Cleaner, more formal look
|
||||||
|
- Better for print
|
||||||
|
|
||||||
|
**Toggle:**
|
||||||
|
1. Action bar toggle switch
|
||||||
|
2. Hamburger menu → "Show Logos"
|
||||||
|
3. Preference persisted to localStorage
|
||||||
|
|
||||||
|
### Theme Switching
|
||||||
|
|
||||||
|
**Default Theme:**
|
||||||
|
- Gray background with paper texture
|
||||||
|
- Sidebars with contact info
|
||||||
|
- Header and footer visible
|
||||||
|
- Full visual design
|
||||||
|
|
||||||
|
**Clean Theme:**
|
||||||
|
- White background
|
||||||
|
- No sidebars
|
||||||
|
- Minimal chrome
|
||||||
|
- Print-optimized
|
||||||
|
|
||||||
|
**Toggle:**
|
||||||
|
1. Action bar toggle switch
|
||||||
|
2. Hamburger menu → "Clean Theme"
|
||||||
|
3. Preference persisted to localStorage
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## PDF Export
|
||||||
|
|
||||||
|
### Server-Side Generation
|
||||||
|
|
||||||
|
**Recommended Method:**
|
||||||
|
1. Click "Download as PDF" in action bar
|
||||||
|
2. Server generates PDF using headless Chrome
|
||||||
|
3. File downloads automatically
|
||||||
|
4. Filename: `CV-Juan-Andres-Moreno-Rubio-{lang}.pdf`
|
||||||
|
|
||||||
|
**Advantages:**
|
||||||
|
- Consistent rendering across platforms
|
||||||
|
- Perfect font rendering
|
||||||
|
- No browser compatibility issues
|
||||||
|
- Professional quality output
|
||||||
|
|
||||||
|
### Browser Print
|
||||||
|
|
||||||
|
**Alternative Method:**
|
||||||
|
1. Click "Print Friendly" in action bar
|
||||||
|
2. Browser print dialog opens
|
||||||
|
3. Select "Save as PDF"
|
||||||
|
4. Configure print settings
|
||||||
|
|
||||||
|
**Settings:**
|
||||||
|
- Switches to clean theme
|
||||||
|
- Forces short version
|
||||||
|
- Removes interactive elements
|
||||||
|
- Optimized for printing
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Navigation
|
||||||
|
|
||||||
|
### Section Navigation
|
||||||
|
|
||||||
|
**Table of Contents:**
|
||||||
|
- Auto-generated from CV sections
|
||||||
|
- Click any section to scroll smoothly
|
||||||
|
- Header stays visible during navigation
|
||||||
|
|
||||||
|
**Hamburger Menu:**
|
||||||
|
- Mobile-friendly navigation
|
||||||
|
- Hover to open on desktop
|
||||||
|
- Click to open on mobile
|
||||||
|
- Automatic closing after selection
|
||||||
|
|
||||||
|
**Back to Top:**
|
||||||
|
- Appears after scrolling 300px
|
||||||
|
- Smooth scroll to top
|
||||||
|
- Always accessible
|
||||||
|
- Bottom-right position
|
||||||
|
|
||||||
|
### Collapsible Sections
|
||||||
|
|
||||||
|
**Details/Summary:**
|
||||||
|
- Click section headers to expand/collapse
|
||||||
|
- Mobile accordion style
|
||||||
|
- Desktop always expanded
|
||||||
|
|
||||||
|
**Expand/Collapse All:**
|
||||||
|
- Action bar buttons
|
||||||
|
- Hamburger menu options
|
||||||
|
- Affects all collapsible sections
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Accessibility
|
||||||
|
|
||||||
|
### Keyboard Navigation
|
||||||
|
|
||||||
|
**Tab Order:**
|
||||||
|
- Logical flow through all interactive elements
|
||||||
|
- Visible focus indicators
|
||||||
|
- Skip links for main content
|
||||||
|
- All features accessible via keyboard
|
||||||
|
|
||||||
|
### Screen Readers
|
||||||
|
|
||||||
|
**ARIA Labels:**
|
||||||
|
- All interactive elements labeled
|
||||||
|
- Form controls properly described
|
||||||
|
- Dynamic content announcements
|
||||||
|
- Semantic HTML structure
|
||||||
|
|
||||||
|
### Visual Accessibility
|
||||||
|
|
||||||
|
**Color Contrast:**
|
||||||
|
- WCAG AA compliant
|
||||||
|
- High contrast mode support
|
||||||
|
- Focus indicators visible
|
||||||
|
|
||||||
|
**Font Scaling:**
|
||||||
|
- Responsive typography
|
||||||
|
- Works with browser zoom
|
||||||
|
- Readable at all sizes
|
||||||
|
|
||||||
|
**Motion Preferences:**
|
||||||
|
- Respects prefers-reduced-motion
|
||||||
|
- Smooth scrolling optional
|
||||||
|
- Animations can be disabled
|
||||||
|
|
||||||
|
### Touch Targets
|
||||||
|
|
||||||
|
**Minimum Sizes:**
|
||||||
|
- All buttons: 44x44px
|
||||||
|
- Touch-friendly spacing
|
||||||
|
- No overlapping targets
|
||||||
|
- Adequate margins
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Support
|
||||||
|
|
||||||
|
For issues, questions, or feedback:
|
||||||
|
- GitHub Issues: https://github.com/txemac/cv/issues
|
||||||
|
- Documentation: See /doc folder
|
||||||
|
- Live Demo: https://juan.andres.morenorub.io/
|
||||||
|
|
||||||
|
**Last Updated:** 2025-11-12
|
||||||
Vendored
-189
@@ -1,189 +0,0 @@
|
|||||||
package cache
|
|
||||||
|
|
||||||
import (
|
|
||||||
"fmt"
|
|
||||||
"log"
|
|
||||||
"sync"
|
|
||||||
"time"
|
|
||||||
)
|
|
||||||
|
|
||||||
// CacheEntry represents a cached item with expiration
|
|
||||||
type cacheEntry struct {
|
|
||||||
data interface{}
|
|
||||||
expiration time.Time
|
|
||||||
}
|
|
||||||
|
|
||||||
// CVCache provides thread-safe caching for CV and UI data
|
|
||||||
type CVCache struct {
|
|
||||||
mu sync.RWMutex
|
|
||||||
entries map[string]*cacheEntry
|
|
||||||
ttl time.Duration
|
|
||||||
stats CacheStats
|
|
||||||
}
|
|
||||||
|
|
||||||
// CacheStats tracks cache performance metrics
|
|
||||||
type CacheStats struct {
|
|
||||||
mu sync.RWMutex
|
|
||||||
Hits int64
|
|
||||||
Misses int64
|
|
||||||
Size int
|
|
||||||
}
|
|
||||||
|
|
||||||
// New creates a new CVCache with the specified TTL
|
|
||||||
func New(ttl time.Duration) *CVCache {
|
|
||||||
if ttl <= 0 {
|
|
||||||
ttl = 1 * time.Hour // Default TTL
|
|
||||||
}
|
|
||||||
|
|
||||||
cache := &CVCache{
|
|
||||||
entries: make(map[string]*cacheEntry),
|
|
||||||
ttl: ttl,
|
|
||||||
stats: CacheStats{},
|
|
||||||
}
|
|
||||||
|
|
||||||
// Start background cleanup goroutine
|
|
||||||
go cache.cleanupExpired()
|
|
||||||
|
|
||||||
return cache
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get retrieves an item from the cache
|
|
||||||
func (c *CVCache) Get(key string) (interface{}, bool) {
|
|
||||||
c.mu.RLock()
|
|
||||||
entry, exists := c.entries[key]
|
|
||||||
c.mu.RUnlock()
|
|
||||||
|
|
||||||
if !exists {
|
|
||||||
c.recordMiss()
|
|
||||||
return nil, false
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if expired
|
|
||||||
if time.Now().After(entry.expiration) {
|
|
||||||
c.mu.Lock()
|
|
||||||
delete(c.entries, key)
|
|
||||||
c.mu.Unlock()
|
|
||||||
c.recordMiss()
|
|
||||||
return nil, false
|
|
||||||
}
|
|
||||||
|
|
||||||
c.recordHit()
|
|
||||||
return entry.data, true
|
|
||||||
}
|
|
||||||
|
|
||||||
// Set stores an item in the cache with TTL
|
|
||||||
func (c *CVCache) Set(key string, data interface{}) {
|
|
||||||
c.mu.Lock()
|
|
||||||
defer c.mu.Unlock()
|
|
||||||
|
|
||||||
c.entries[key] = &cacheEntry{
|
|
||||||
data: data,
|
|
||||||
expiration: time.Now().Add(c.ttl),
|
|
||||||
}
|
|
||||||
|
|
||||||
c.updateSize()
|
|
||||||
}
|
|
||||||
|
|
||||||
// Invalidate removes a specific key from cache
|
|
||||||
func (c *CVCache) Invalidate(key string) {
|
|
||||||
c.mu.Lock()
|
|
||||||
defer c.mu.Unlock()
|
|
||||||
|
|
||||||
delete(c.entries, key)
|
|
||||||
c.updateSize()
|
|
||||||
}
|
|
||||||
|
|
||||||
// InvalidateAll clears the entire cache
|
|
||||||
func (c *CVCache) InvalidateAll() {
|
|
||||||
c.mu.Lock()
|
|
||||||
defer c.mu.Unlock()
|
|
||||||
|
|
||||||
c.entries = make(map[string]*cacheEntry)
|
|
||||||
c.updateSize()
|
|
||||||
log.Println("🗑️ Cache invalidated")
|
|
||||||
}
|
|
||||||
|
|
||||||
// Warm preloads cache with specific keys
|
|
||||||
func (c *CVCache) Warm(key string, loader func() (interface{}, error)) error {
|
|
||||||
data, err := loader()
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("cache warm failed for key %s: %w", key, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
c.Set(key, data)
|
|
||||||
log.Printf("🔥 Cache warmed: %s", key)
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetStats returns current cache statistics
|
|
||||||
func (c *CVCache) GetStats() CacheStats {
|
|
||||||
c.stats.mu.RLock()
|
|
||||||
defer c.stats.mu.RUnlock()
|
|
||||||
|
|
||||||
return CacheStats{
|
|
||||||
Hits: c.stats.Hits,
|
|
||||||
Misses: c.stats.Misses,
|
|
||||||
Size: c.stats.Size,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// HitRate returns the cache hit rate as a percentage
|
|
||||||
func (c *CVCache) HitRate() float64 {
|
|
||||||
stats := c.GetStats()
|
|
||||||
total := stats.Hits + stats.Misses
|
|
||||||
if total == 0 {
|
|
||||||
return 0
|
|
||||||
}
|
|
||||||
return (float64(stats.Hits) / float64(total)) * 100
|
|
||||||
}
|
|
||||||
|
|
||||||
// recordHit increments the hit counter
|
|
||||||
func (c *CVCache) recordHit() {
|
|
||||||
c.stats.mu.Lock()
|
|
||||||
c.stats.Hits++
|
|
||||||
c.stats.mu.Unlock()
|
|
||||||
}
|
|
||||||
|
|
||||||
// recordMiss increments the miss counter
|
|
||||||
func (c *CVCache) recordMiss() {
|
|
||||||
c.stats.mu.Lock()
|
|
||||||
c.stats.Misses++
|
|
||||||
c.stats.mu.Unlock()
|
|
||||||
}
|
|
||||||
|
|
||||||
// updateSize updates the cached size count
|
|
||||||
func (c *CVCache) updateSize() {
|
|
||||||
c.stats.mu.Lock()
|
|
||||||
c.stats.Size = len(c.entries)
|
|
||||||
c.stats.mu.Unlock()
|
|
||||||
}
|
|
||||||
|
|
||||||
// cleanupExpired periodically removes expired entries
|
|
||||||
func (c *CVCache) cleanupExpired() {
|
|
||||||
ticker := time.NewTicker(5 * time.Minute)
|
|
||||||
defer ticker.Stop()
|
|
||||||
|
|
||||||
for range ticker.C {
|
|
||||||
c.mu.Lock()
|
|
||||||
now := time.Now()
|
|
||||||
removed := 0
|
|
||||||
|
|
||||||
for key, entry := range c.entries {
|
|
||||||
if now.After(entry.expiration) {
|
|
||||||
delete(c.entries, key)
|
|
||||||
removed++
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if removed > 0 {
|
|
||||||
c.updateSize()
|
|
||||||
log.Printf("🧹 Cache cleanup: removed %d expired entries", removed)
|
|
||||||
}
|
|
||||||
c.mu.Unlock()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// BuildKey creates a consistent cache key
|
|
||||||
func BuildKey(prefix, lang string) string {
|
|
||||||
return fmt.Sprintf("%s:%s", prefix, lang)
|
|
||||||
}
|
|
||||||
@@ -5,24 +5,13 @@ import (
|
|||||||
"log"
|
"log"
|
||||||
"net/http"
|
"net/http"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/juanatsap/cv-site/internal/models"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// HealthResponse represents the health check response
|
// HealthResponse represents the health check response
|
||||||
type HealthResponse struct {
|
type HealthResponse struct {
|
||||||
Status string `json:"status"`
|
Status string `json:"status"`
|
||||||
Timestamp time.Time `json:"timestamp"`
|
Timestamp time.Time `json:"timestamp"`
|
||||||
Version string `json:"version"`
|
Version string `json:"version"`
|
||||||
Cache *CacheInfo `json:"cache,omitempty"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// CacheInfo represents cache statistics
|
|
||||||
type CacheInfo struct {
|
|
||||||
Hits int64 `json:"hits"`
|
|
||||||
Misses int64 `json:"misses"`
|
|
||||||
Size int `json:"size"`
|
|
||||||
HitRate float64 `json:"hit_rate_percent"`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// HealthHandler handles health check requests
|
// HealthHandler handles health check requests
|
||||||
@@ -45,21 +34,9 @@ func (h *HealthHandler) Check(w http.ResponseWriter, r *http.Request) {
|
|||||||
Version: h.version,
|
Version: h.version,
|
||||||
}
|
}
|
||||||
|
|
||||||
// Include cache stats if available
|
|
||||||
if cache := models.GetCache(); cache != nil {
|
|
||||||
stats := cache.GetStats()
|
|
||||||
response.Cache = &CacheInfo{
|
|
||||||
Hits: stats.Hits,
|
|
||||||
Misses: stats.Misses,
|
|
||||||
Size: stats.Size,
|
|
||||||
HitRate: cache.HitRate(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
w.Header().Set("Content-Type", "application/json")
|
w.Header().Set("Content-Type", "application/json")
|
||||||
w.WriteHeader(http.StatusOK)
|
w.WriteHeader(http.StatusOK)
|
||||||
if err := json.NewEncoder(w).Encode(response); err != nil {
|
if err := json.NewEncoder(w).Encode(response); err != nil {
|
||||||
// Log error but don't change response status (already written)
|
|
||||||
log.Printf("ERROR encoding health check response: %v", err)
|
log.Printf("ERROR encoding health check response: %v", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -221,3 +221,17 @@ func (rl *RateLimiter) cleanup() {
|
|||||||
rl.mu.Unlock()
|
rl.mu.Unlock()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// CacheControl adds cache headers to static files
|
||||||
|
// 1 hour in development, 1 day in production
|
||||||
|
func CacheControl(next http.Handler) http.Handler {
|
||||||
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
maxAge := "3600" // 1 hour
|
||||||
|
if os.Getenv("GO_ENV") == "production" {
|
||||||
|
maxAge = "86400" // 1 day
|
||||||
|
}
|
||||||
|
|
||||||
|
w.Header().Set("Cache-Control", "public, max-age="+maxAge)
|
||||||
|
next.ServeHTTP(w, r)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|||||||
@@ -4,11 +4,7 @@ import (
|
|||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"html/template"
|
"html/template"
|
||||||
"log"
|
|
||||||
"os"
|
"os"
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/juanatsap/cv-site/internal/cache"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// CV represents the complete curriculum vitae structure
|
// CV represents the complete curriculum vitae structure
|
||||||
@@ -16,7 +12,6 @@ type CV struct {
|
|||||||
Personal Personal `json:"personal"`
|
Personal Personal `json:"personal"`
|
||||||
Summary string `json:"summary"`
|
Summary string `json:"summary"`
|
||||||
Experience []Experience `json:"experience"`
|
Experience []Experience `json:"experience"`
|
||||||
AIDevelopment AIDevelopment `json:"ai_development"`
|
|
||||||
Education []Education `json:"education"`
|
Education []Education `json:"education"`
|
||||||
Skills Skills `json:"skills"`
|
Skills Skills `json:"skills"`
|
||||||
Languages []Language `json:"languages"`
|
Languages []Language `json:"languages"`
|
||||||
@@ -62,20 +57,6 @@ type Experience struct {
|
|||||||
Duration string `json:"-"` // Calculated field, not from JSON
|
Duration string `json:"-"` // Calculated field, not from JSON
|
||||||
}
|
}
|
||||||
|
|
||||||
type AIDevelopment struct {
|
|
||||||
Title string `json:"title"`
|
|
||||||
Period string `json:"period"`
|
|
||||||
Description string `json:"description"`
|
|
||||||
Skills []AISkill `json:"skills"`
|
|
||||||
Achievements []string `json:"achievements"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type AISkill struct {
|
|
||||||
Category string `json:"category"`
|
|
||||||
Proficiency string `json:"proficiency"`
|
|
||||||
Items []string `json:"items"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type Education struct {
|
type Education struct {
|
||||||
Degree string `json:"degree"`
|
Degree string `json:"degree"`
|
||||||
Institution string `json:"institution"`
|
Institution string `json:"institution"`
|
||||||
@@ -193,104 +174,42 @@ type TechStack struct {
|
|||||||
CSS3 string `json:"css3"`
|
CSS3 string `json:"css3"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// Global cache instance (initialized in main.go)
|
|
||||||
var cvCache *cache.CVCache
|
|
||||||
|
|
||||||
// InitCache initializes the global cache with specified TTL
|
|
||||||
func InitCache(ttl time.Duration) {
|
|
||||||
cvCache = cache.New(ttl)
|
|
||||||
log.Printf("✓ Cache initialized (TTL: %v)", ttl)
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetCache returns the cache instance (for stats/management)
|
|
||||||
func GetCache() *cache.CVCache {
|
|
||||||
return cvCache
|
|
||||||
}
|
|
||||||
|
|
||||||
// LoadCV loads CV data from a JSON file for the specified language
|
// LoadCV loads CV data from a JSON file for the specified language
|
||||||
// Uses cache if available, falls back to disk on cache miss
|
|
||||||
func LoadCV(lang string) (*CV, error) {
|
func LoadCV(lang string) (*CV, error) {
|
||||||
// Validate language
|
|
||||||
if lang != "en" && lang != "es" {
|
if lang != "en" && lang != "es" {
|
||||||
return nil, fmt.Errorf("unsupported language: %s", lang)
|
return nil, fmt.Errorf("unsupported language: %s", lang)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Try cache first if available
|
|
||||||
if cvCache != nil {
|
|
||||||
cacheKey := cache.BuildKey("cv", lang)
|
|
||||||
if cached, found := cvCache.Get(cacheKey); found {
|
|
||||||
if cv, ok := cached.(*CV); ok {
|
|
||||||
return cv, nil
|
|
||||||
}
|
|
||||||
// Invalid cache entry, invalidate it
|
|
||||||
cvCache.Invalidate(cacheKey)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Cache miss or no cache - load from disk
|
|
||||||
filename := fmt.Sprintf("data/cv-%s.json", lang)
|
filename := fmt.Sprintf("data/cv-%s.json", lang)
|
||||||
|
|
||||||
// Read the JSON file
|
|
||||||
data, err := os.ReadFile(filename)
|
data, err := os.ReadFile(filename)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("error reading file %s: %w", filename, err)
|
return nil, fmt.Errorf("error reading file %s: %w", filename, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Parse JSON
|
|
||||||
var cv CV
|
var cv CV
|
||||||
if err := json.Unmarshal(data, &cv); err != nil {
|
if err := json.Unmarshal(data, &cv); err != nil {
|
||||||
return nil, fmt.Errorf("error parsing JSON: %w", err)
|
return nil, fmt.Errorf("error parsing JSON: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Store in cache if available
|
|
||||||
if cvCache != nil {
|
|
||||||
cacheKey := cache.BuildKey("cv", lang)
|
|
||||||
cvCache.Set(cacheKey, &cv)
|
|
||||||
}
|
|
||||||
|
|
||||||
return &cv, nil
|
return &cv, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// LoadUI loads UI translations from a JSON file for the specified language
|
// LoadUI loads UI translations from a JSON file for the specified language
|
||||||
// Uses cache if available, falls back to disk on cache miss
|
|
||||||
func LoadUI(lang string) (*UI, error) {
|
func LoadUI(lang string) (*UI, error) {
|
||||||
// Validate language
|
|
||||||
if lang != "en" && lang != "es" {
|
if lang != "en" && lang != "es" {
|
||||||
return nil, fmt.Errorf("unsupported language: %s", lang)
|
return nil, fmt.Errorf("unsupported language: %s", lang)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Try cache first if available
|
|
||||||
if cvCache != nil {
|
|
||||||
cacheKey := cache.BuildKey("ui", lang)
|
|
||||||
if cached, found := cvCache.Get(cacheKey); found {
|
|
||||||
if ui, ok := cached.(*UI); ok {
|
|
||||||
return ui, nil
|
|
||||||
}
|
|
||||||
// Invalid cache entry, invalidate it
|
|
||||||
cvCache.Invalidate(cacheKey)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Cache miss or no cache - load from disk
|
|
||||||
filename := fmt.Sprintf("data/ui-%s.json", lang)
|
filename := fmt.Sprintf("data/ui-%s.json", lang)
|
||||||
|
|
||||||
// Read the JSON file
|
|
||||||
data, err := os.ReadFile(filename)
|
data, err := os.ReadFile(filename)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("error reading file %s: %w", filename, err)
|
return nil, fmt.Errorf("error reading file %s: %w", filename, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Parse JSON
|
|
||||||
var ui UI
|
var ui UI
|
||||||
if err := json.Unmarshal(data, &ui); err != nil {
|
if err := json.Unmarshal(data, &ui); err != nil {
|
||||||
return nil, fmt.Errorf("error parsing JSON: %w", err)
|
return nil, fmt.Errorf("error parsing JSON: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Store in cache if available
|
|
||||||
if cvCache != nil {
|
|
||||||
cacheKey := cache.BuildKey("ui", lang)
|
|
||||||
cvCache.Set(cacheKey, &ui)
|
|
||||||
}
|
|
||||||
|
|
||||||
return &ui, nil
|
return &ui, nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,41 @@
|
|||||||
|
package routes
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/juanatsap/cv-site/internal/handlers"
|
||||||
|
"github.com/juanatsap/cv-site/internal/middleware"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Setup configures all application routes and middleware
|
||||||
|
func Setup(cvHandler *handlers.CVHandler, healthHandler *handlers.HealthHandler) http.Handler {
|
||||||
|
mux := http.NewServeMux()
|
||||||
|
|
||||||
|
// Public routes
|
||||||
|
mux.HandleFunc("/", cvHandler.Home)
|
||||||
|
mux.HandleFunc("/cv", cvHandler.CVContent)
|
||||||
|
mux.HandleFunc("/health", healthHandler.Check)
|
||||||
|
|
||||||
|
// Protected PDF endpoint with rate limiting (3 requests/minute per IP)
|
||||||
|
pdfRateLimiter := middleware.NewRateLimiter(3, 1*time.Minute)
|
||||||
|
protectedPDFHandler := middleware.OriginChecker(
|
||||||
|
pdfRateLimiter.Middleware(
|
||||||
|
http.HandlerFunc(cvHandler.ExportPDF),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
mux.Handle("/export/pdf", protectedPDFHandler)
|
||||||
|
|
||||||
|
// Static files with cache control
|
||||||
|
staticHandler := http.StripPrefix("/static/", http.FileServer(http.Dir("static")))
|
||||||
|
mux.Handle("/static/", middleware.CacheControl(staticHandler))
|
||||||
|
|
||||||
|
// Apply comprehensive middleware chain
|
||||||
|
handler := middleware.Recovery(
|
||||||
|
middleware.Logger(
|
||||||
|
middleware.SecurityHeaders(mux),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
return handler
|
||||||
|
}
|
||||||
@@ -13,12 +13,11 @@ import (
|
|||||||
"github.com/joho/godotenv"
|
"github.com/joho/godotenv"
|
||||||
"github.com/juanatsap/cv-site/internal/config"
|
"github.com/juanatsap/cv-site/internal/config"
|
||||||
"github.com/juanatsap/cv-site/internal/handlers"
|
"github.com/juanatsap/cv-site/internal/handlers"
|
||||||
"github.com/juanatsap/cv-site/internal/middleware"
|
"github.com/juanatsap/cv-site/internal/routes"
|
||||||
"github.com/juanatsap/cv-site/internal/models"
|
|
||||||
"github.com/juanatsap/cv-site/internal/templates"
|
"github.com/juanatsap/cv-site/internal/templates"
|
||||||
)
|
)
|
||||||
|
|
||||||
const version = "1.0.0"
|
const version = "1.1.0"
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
// Initialize logger
|
// Initialize logger
|
||||||
@@ -36,29 +35,6 @@ func main() {
|
|||||||
cfg := config.Load()
|
cfg := config.Load()
|
||||||
log.Printf("✓ Configuration loaded (env: %s)", os.Getenv("GO_ENV"))
|
log.Printf("✓ Configuration loaded (env: %s)", os.Getenv("GO_ENV"))
|
||||||
|
|
||||||
// Initialize cache (1 hour TTL, configurable via env)
|
|
||||||
cacheTTL := 1 * time.Hour
|
|
||||||
if ttlEnv := os.Getenv("CACHE_TTL_MINUTES"); ttlEnv != "" {
|
|
||||||
if minutes, err := time.ParseDuration(ttlEnv + "m"); err == nil {
|
|
||||||
cacheTTL = minutes
|
|
||||||
}
|
|
||||||
}
|
|
||||||
models.InitCache(cacheTTL)
|
|
||||||
|
|
||||||
// Warm cache with default languages
|
|
||||||
log.Println("🔥 Warming cache...")
|
|
||||||
for _, lang := range []string{"en", "es"} {
|
|
||||||
// Warm CV cache
|
|
||||||
if _, err := models.LoadCV(lang); err != nil {
|
|
||||||
log.Printf("⚠️ Failed to warm CV cache for %s: %v", lang, err)
|
|
||||||
}
|
|
||||||
// Warm UI cache
|
|
||||||
if _, err := models.LoadUI(lang); err != nil {
|
|
||||||
log.Printf("⚠️ Failed to warm UI cache for %s: %v", lang, err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
log.Printf("✓ Cache warmed (TTL: %v)", cacheTTL)
|
|
||||||
|
|
||||||
// Initialize template manager
|
// Initialize template manager
|
||||||
templateMgr, err := templates.NewManager(&cfg.Template)
|
templateMgr, err := templates.NewManager(&cfg.Template)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -69,37 +45,8 @@ func main() {
|
|||||||
cvHandler := handlers.NewCVHandler(templateMgr, cfg.Address())
|
cvHandler := handlers.NewCVHandler(templateMgr, cfg.Address())
|
||||||
healthHandler := handlers.NewHealthHandler(version)
|
healthHandler := handlers.NewHealthHandler(version)
|
||||||
|
|
||||||
// Setup router
|
// Setup routes and middleware
|
||||||
mux := http.NewServeMux()
|
handler := routes.Setup(cvHandler, healthHandler)
|
||||||
|
|
||||||
// Create rate limiter for PDF endpoint
|
|
||||||
// Allow 3 PDF generations per minute per IP
|
|
||||||
pdfRateLimiter := middleware.NewRateLimiter(3, 1*time.Minute)
|
|
||||||
log.Printf("🔒 Rate limiter enabled for PDF endpoint (3 requests/minute)")
|
|
||||||
|
|
||||||
// Routes
|
|
||||||
mux.HandleFunc("/", cvHandler.Home)
|
|
||||||
mux.HandleFunc("/cv", cvHandler.CVContent)
|
|
||||||
mux.HandleFunc("/health", healthHandler.Check)
|
|
||||||
|
|
||||||
// Protected PDF endpoint with origin checking + rate limiting
|
|
||||||
protectedPDFHandler := middleware.OriginChecker(
|
|
||||||
pdfRateLimiter.Middleware(
|
|
||||||
http.HandlerFunc(cvHandler.ExportPDF),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
mux.Handle("/export/pdf", protectedPDFHandler)
|
|
||||||
|
|
||||||
// Static files with cache control
|
|
||||||
staticHandler := http.StripPrefix("/static/", http.FileServer(http.Dir("static")))
|
|
||||||
mux.Handle("/static/", cacheControl(staticHandler))
|
|
||||||
|
|
||||||
// Apply comprehensive middleware chain
|
|
||||||
handler := middleware.Recovery(
|
|
||||||
middleware.Logger(
|
|
||||||
middleware.SecurityHeaders(mux),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
// Create server with timeouts
|
// Create server with timeouts
|
||||||
server := &http.Server{
|
server := &http.Server{
|
||||||
@@ -152,17 +99,3 @@ func main() {
|
|||||||
log.Println("✓ Server stopped gracefully")
|
log.Println("✓ Server stopped gracefully")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// cacheControl adds cache headers to static files
|
|
||||||
func cacheControl(h http.Handler) http.Handler {
|
|
||||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
// Cache static files for 1 hour in development, 1 day in production
|
|
||||||
maxAge := "3600" // 1 hour
|
|
||||||
if os.Getenv("GO_ENV") == "production" {
|
|
||||||
maxAge = "86400" // 1 day
|
|
||||||
}
|
|
||||||
|
|
||||||
w.Header().Set("Cache-Control", "public, max-age="+maxAge)
|
|
||||||
h.ServeHTTP(w, r)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,24 +0,0 @@
|
|||||||
{
|
|
||||||
"old": {},
|
|
||||||
"new": {
|
|
||||||
"badge": {
|
|
||||||
"box": {
|
|
||||||
"x": 528.046875,
|
|
||||||
"y": 173.96875,
|
|
||||||
"width": 164,
|
|
||||||
"height": 21.609375
|
|
||||||
},
|
|
||||||
"styles": {
|
|
||||||
"height": "21.6094px",
|
|
||||||
"padding": "0px",
|
|
||||||
"fontSize": "14.4px",
|
|
||||||
"fontWeight": "400",
|
|
||||||
"color": "rgb(204, 204, 204)",
|
|
||||||
"backgroundColor": "rgba(0, 0, 0, 0)",
|
|
||||||
"borderRadius": "0px",
|
|
||||||
"display": "block",
|
|
||||||
"alignItems": "normal"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,91 +0,0 @@
|
|||||||
{
|
|
||||||
"timestamp": "2025-10-31T15:52:05.485Z",
|
|
||||||
"oldSite": {
|
|
||||||
"url": "http://localhost:3000",
|
|
||||||
"hasContent": true,
|
|
||||||
"classesFound": 0,
|
|
||||||
"dimensions": {
|
|
||||||
"width": 1920,
|
|
||||||
"height": 1080
|
|
||||||
},
|
|
||||||
"badges": null
|
|
||||||
},
|
|
||||||
"newSite": {
|
|
||||||
"url": "http://localhost:1999",
|
|
||||||
"dimensions": {
|
|
||||||
"width": 1920,
|
|
||||||
"height": 2195
|
|
||||||
},
|
|
||||||
"badges": [
|
|
||||||
{
|
|
||||||
"tag": "SPAN",
|
|
||||||
"class": "title-badge",
|
|
||||||
"text": "ANALYST PROGRAMMER",
|
|
||||||
"styles": {
|
|
||||||
"fontSize": "14.4px",
|
|
||||||
"fontWeight": "400",
|
|
||||||
"color": "rgb(204, 204, 204)",
|
|
||||||
"backgroundColor": "rgba(0, 0, 0, 0)",
|
|
||||||
"padding": "0px",
|
|
||||||
"height": "21.6094px"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"tag": "SPAN",
|
|
||||||
"class": "title-badge",
|
|
||||||
"text": "NODEJS + REACTJS DEVELOPER",
|
|
||||||
"styles": {
|
|
||||||
"fontSize": "14.4px",
|
|
||||||
"fontWeight": "400",
|
|
||||||
"color": "rgb(204, 204, 204)",
|
|
||||||
"backgroundColor": "rgba(0, 0, 0, 0)",
|
|
||||||
"padding": "0px",
|
|
||||||
"height": "21.6094px"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"tag": "SPAN",
|
|
||||||
"class": "title-badge",
|
|
||||||
"text": "WEB DEVELOPER",
|
|
||||||
"styles": {
|
|
||||||
"fontSize": "14.4px",
|
|
||||||
"fontWeight": "400",
|
|
||||||
"color": "rgb(204, 204, 204)",
|
|
||||||
"backgroundColor": "rgba(0, 0, 0, 0)",
|
|
||||||
"padding": "0px",
|
|
||||||
"height": "21.6094px"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"tag": "SPAN",
|
|
||||||
"class": "title-badge",
|
|
||||||
"text": "JAVA DEVELOPER",
|
|
||||||
"styles": {
|
|
||||||
"fontSize": "14.4px",
|
|
||||||
"fontWeight": "400",
|
|
||||||
"color": "rgb(204, 204, 204)",
|
|
||||||
"backgroundColor": "rgba(0, 0, 0, 0)",
|
|
||||||
"padding": "0px",
|
|
||||||
"height": "21.6094px"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"tag": "SPAN",
|
|
||||||
"class": "title-badge",
|
|
||||||
"text": "PHP DEVELOPER",
|
|
||||||
"styles": {
|
|
||||||
"fontSize": "14.4px",
|
|
||||||
"fontWeight": "400",
|
|
||||||
"color": "rgb(204, 204, 204)",
|
|
||||||
"backgroundColor": "rgba(0, 0, 0, 0)",
|
|
||||||
"padding": "0px",
|
|
||||||
"height": "21.6094px"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"comparison": {
|
|
||||||
"dimensionsMatch": false,
|
|
||||||
"pixelPerfect": null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
File diff suppressed because it is too large
Load Diff
Binary file not shown.
|
Before Width: | Height: | Size: 390 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 389 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 6.3 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 26 KiB |
@@ -1,9 +0,0 @@
|
|||||||
{
|
|
||||||
"old": {},
|
|
||||||
"new": {
|
|
||||||
"backgroundColor": "rgb(209, 212, 210)",
|
|
||||||
"padding": "32px 24px",
|
|
||||||
"width": "300px",
|
|
||||||
"minWidth": "auto"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,37 +0,0 @@
|
|||||||
{
|
|
||||||
"old": {},
|
|
||||||
"new": {
|
|
||||||
"name": {
|
|
||||||
"fontFamily": "Quicksand, sans-serif",
|
|
||||||
"fontSize": "35.2px",
|
|
||||||
"fontWeight": "400",
|
|
||||||
"lineHeight": "38.72px",
|
|
||||||
"color": "rgb(0, 0, 0)",
|
|
||||||
"letterSpacing": "normal"
|
|
||||||
},
|
|
||||||
"sidebarTitle": {
|
|
||||||
"fontFamily": "Quicksand, sans-serif",
|
|
||||||
"fontSize": "18.72px",
|
|
||||||
"fontWeight": "500",
|
|
||||||
"lineHeight": "22.464px",
|
|
||||||
"color": "rgb(51, 51, 51)",
|
|
||||||
"letterSpacing": "normal"
|
|
||||||
},
|
|
||||||
"sectionTitle": {
|
|
||||||
"fontFamily": "Quicksand, sans-serif",
|
|
||||||
"fontSize": "20.8px",
|
|
||||||
"fontWeight": "500",
|
|
||||||
"lineHeight": "24.96px",
|
|
||||||
"color": "rgb(51, 51, 51)",
|
|
||||||
"letterSpacing": "normal"
|
|
||||||
},
|
|
||||||
"badge": {
|
|
||||||
"fontFamily": "Quicksand, \"Source Sans Pro\", -apple-system, system-ui, sans-serif",
|
|
||||||
"fontSize": "14.4px",
|
|
||||||
"fontWeight": "400",
|
|
||||||
"lineHeight": "21.6px",
|
|
||||||
"color": "rgb(204, 204, 204)",
|
|
||||||
"letterSpacing": "normal"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,216 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Bottom Buttons Test</title>
|
||||||
|
<style>
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
padding: 20px;
|
||||||
|
font-family: Arial, sans-serif;
|
||||||
|
min-height: 300vh; /* Make page scrollable */
|
||||||
|
background: linear-gradient(to bottom, #f0f0f0 0%, #e0e0e0 100%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.test-info {
|
||||||
|
position: fixed;
|
||||||
|
top: 20px;
|
||||||
|
left: 20px;
|
||||||
|
background: white;
|
||||||
|
padding: 20px;
|
||||||
|
border-radius: 8px;
|
||||||
|
box-shadow: 0 2px 10px rgba(0,0,0,0.1);
|
||||||
|
z-index: 1000;
|
||||||
|
max-width: 400px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.test-info h2 {
|
||||||
|
margin-top: 0;
|
||||||
|
color: #333;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status {
|
||||||
|
margin: 10px 0;
|
||||||
|
padding: 10px;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-family: monospace;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status.at-bottom {
|
||||||
|
background: #27ae60;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status.not-bottom {
|
||||||
|
background: #e0e0e0;
|
||||||
|
color: #333;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Copy styles from main.css */
|
||||||
|
.back-to-top {
|
||||||
|
position: fixed;
|
||||||
|
bottom: 2rem;
|
||||||
|
right: 2rem;
|
||||||
|
width: 35px;
|
||||||
|
height: 35px;
|
||||||
|
background: #2a2a2a;
|
||||||
|
color: white;
|
||||||
|
border: none;
|
||||||
|
border-radius: 50%;
|
||||||
|
cursor: pointer;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
|
||||||
|
z-index: 99;
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
opacity: 0.2;
|
||||||
|
font-size: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.back-to-top:hover {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(-3px) scale(1.43);
|
||||||
|
box-shadow: 0 6px 16px rgba(0, 0, 0, 0.4);
|
||||||
|
background: #3a3a3a;
|
||||||
|
}
|
||||||
|
|
||||||
|
.back-to-top.at-bottom {
|
||||||
|
opacity: 1;
|
||||||
|
background: #27ae60;
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-button {
|
||||||
|
position: fixed;
|
||||||
|
bottom: 2rem;
|
||||||
|
left: 2rem;
|
||||||
|
width: 50px;
|
||||||
|
height: 50px;
|
||||||
|
background: #2a2a2a;
|
||||||
|
color: white;
|
||||||
|
border: none;
|
||||||
|
border-radius: 50%;
|
||||||
|
cursor: pointer;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
|
||||||
|
z-index: 99;
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
opacity: 0.2;
|
||||||
|
font-size: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-button:hover {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(-3px);
|
||||||
|
box-shadow: 0 6px 16px rgba(0, 0, 0, 0.4);
|
||||||
|
background: #3a3a3a;
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-button.at-bottom {
|
||||||
|
opacity: 1;
|
||||||
|
background: #27ae60;
|
||||||
|
}
|
||||||
|
|
||||||
|
.content-marker {
|
||||||
|
padding: 20px;
|
||||||
|
margin: 50px 0;
|
||||||
|
background: white;
|
||||||
|
border-radius: 8px;
|
||||||
|
box-shadow: 0 2px 5px rgba(0,0,0,0.1);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="test-info">
|
||||||
|
<h2>🧪 Bottom Buttons Test</h2>
|
||||||
|
<p>Scroll to the bottom to see the buttons turn green!</p>
|
||||||
|
<div id="status" class="status not-bottom">
|
||||||
|
Not at bottom
|
||||||
|
</div>
|
||||||
|
<div style="margin-top: 15px;">
|
||||||
|
<strong>Current Scroll:</strong> <span id="scroll-pos">0</span>px<br>
|
||||||
|
<strong>Distance from Bottom:</strong> <span id="distance">0</span>px
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="content-marker">
|
||||||
|
<h1>Top of Page</h1>
|
||||||
|
<p>Start scrolling down to test the bottom detection...</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="content-marker" style="margin-top: 100vh;">
|
||||||
|
<h2>Middle Section</h2>
|
||||||
|
<p>Keep scrolling...</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="content-marker" style="margin-top: 100vh;">
|
||||||
|
<h2>Almost There...</h2>
|
||||||
|
<p>Just a bit more...</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="content-marker" style="margin-top: 50vh; margin-bottom: 100px;">
|
||||||
|
<h2>🎯 Bottom of Page</h2>
|
||||||
|
<p>You should see the buttons turn green now!</p>
|
||||||
|
<p><strong>The info button (ℹ️ bottom-left) and back-to-top button (↑ bottom-right) should both be GREEN when you're at the bottom.</strong></p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Test buttons -->
|
||||||
|
<button class="info-button" onclick="alert('Info button clicked!')">ℹ️</button>
|
||||||
|
<button class="back-to-top" onclick="window.scrollTo({top: 0, behavior: 'smooth'})">↑</button>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
// Test implementation matching the actual code
|
||||||
|
function updateBottomStatus() {
|
||||||
|
const currentScroll = window.pageYOffset || document.documentElement.scrollTop;
|
||||||
|
const scrollHeight = document.documentElement.scrollHeight;
|
||||||
|
const clientHeight = document.documentElement.clientHeight;
|
||||||
|
const distanceFromBottom = scrollHeight - currentScroll - clientHeight;
|
||||||
|
const isAtBottom = distanceFromBottom < 50;
|
||||||
|
|
||||||
|
// Update test info
|
||||||
|
const statusDiv = document.getElementById('status');
|
||||||
|
const scrollPos = document.getElementById('scroll-pos');
|
||||||
|
const distance = document.getElementById('distance');
|
||||||
|
|
||||||
|
scrollPos.textContent = Math.round(currentScroll);
|
||||||
|
distance.textContent = Math.round(distanceFromBottom);
|
||||||
|
|
||||||
|
if (isAtBottom) {
|
||||||
|
statusDiv.textContent = '✅ AT BOTTOM - Buttons should be GREEN!';
|
||||||
|
statusDiv.className = 'status at-bottom';
|
||||||
|
} else {
|
||||||
|
statusDiv.textContent = '❌ Not at bottom yet';
|
||||||
|
statusDiv.className = 'status not-bottom';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update button states
|
||||||
|
const backToTopBtn = document.querySelector('.back-to-top');
|
||||||
|
const infoBtn = document.querySelector('.info-button');
|
||||||
|
|
||||||
|
if (isAtBottom) {
|
||||||
|
backToTopBtn.classList.add('at-bottom');
|
||||||
|
infoBtn.classList.add('at-bottom');
|
||||||
|
} else {
|
||||||
|
backToTopBtn.classList.remove('at-bottom');
|
||||||
|
infoBtn.classList.remove('at-bottom');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Show back-to-top button when scrolled down
|
||||||
|
if (currentScroll > 300) {
|
||||||
|
backToTopBtn.style.display = 'flex';
|
||||||
|
} else {
|
||||||
|
backToTopBtn.style.display = 'none';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update on scroll
|
||||||
|
window.addEventListener('scroll', updateBottomStatus);
|
||||||
|
|
||||||
|
// Initial update
|
||||||
|
updateBottomStatus();
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
Reference in New Issue
Block a user