diff --git a/internal/handlers/cv.go b/internal/handlers/cv.go index 28536cc..ddde777 100644 --- a/internal/handlers/cv.go +++ b/internal/handlers/cv.go @@ -260,13 +260,46 @@ func (h *CVHandler) DefaultCVShortcut(w http.ResponseWriter, r *http.Request) { return } - // Build redirect URL with default parameters (short + with_skills) - redirectURL := fmt.Sprintf("/export/pdf?lang=%s&length=short&icons=show&version=with_skills", lang) + // Generate PDF directly instead of redirecting + // This ensures the Content-Disposition filename is respected by browsers + log.Printf("Shortcut URL: %s → generating PDF (short + with_skills)", path) - log.Printf("Shortcut URL: %s → %s", path, redirectURL) + // Prepare cookies for PDF generation (short, with_skills, light mode) + cookies := map[string]string{ + "cv-length": "short", + "cv-icons": "show", + "cv-language": lang, + "cv-theme": "default", // with_skills = default theme + "color-theme": "light", // Always light for PDFs + } - // Redirect to PDF export endpoint - http.Redirect(w, r, redirectURL, http.StatusMovedPermanently) + // Construct URL for PDF generation + targetURL := fmt.Sprintf("http://%s/?lang=%s", h.serverAddr, lang) + + // Generate PDF with screen render mode (for sidebar layout) + ctx := r.Context() + pdfData, err := h.pdfGenerator.GenerateFromURLWithOptions(ctx, targetURL, cookies, pdf.RenderModeScreen) + if err != nil { + log.Printf("PDF generation failed for shortcut URL: %v", err) + HandleError(w, r, InternalError(err)) + return + } + + // Use the shortcut filename directly (simple, user-friendly) + filename := filepath.Base(path) // cv-jamr-2025-en.pdf + + // Set response headers with shortcut filename + w.Header().Set("Content-Type", "application/pdf") + w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=%s", filename)) + w.Header().Set("Content-Length", fmt.Sprintf("%d", len(pdfData))) + + // Write PDF data + if _, err := w.Write(pdfData); err != nil { + log.Printf("Error writing PDF response: %v", err) + return + } + + log.Printf("PDF generated successfully: %s (%d bytes)", filename, len(pdfData)) } // ExportPDF handles PDF export requests using chromedp diff --git a/internal/models/cv.go b/internal/models/cv.go index 5844c22..a047cf7 100644 --- a/internal/models/cv.go +++ b/internal/models/cv.go @@ -5,6 +5,7 @@ import ( "fmt" "html/template" "os" + "strings" "time" ) @@ -241,7 +242,7 @@ func LoadCV(lang string) (*CV, error) { // replaceYearPlaceholder replaces {{YEAR}} with the current year func replaceYearPlaceholder(url string, year string) string { - return fmt.Sprintf(url, year) + return strings.ReplaceAll(url, "{{YEAR}}", year) } // LoadUI loads UI translations from a JSON file for the specified language diff --git a/static/css/04-interactive/_toasts.css b/static/css/04-interactive/_toasts.css new file mode 100644 index 0000000..217bc33 --- /dev/null +++ b/static/css/04-interactive/_toasts.css @@ -0,0 +1,255 @@ +/* ============================================================================= + TOAST NOTIFICATIONS - Error, Success, Info Messages + ============================================================================= */ + +/* Toast Container - Positioned bottom-right */ +.error-toast, +.success-toast, +.toast { + position: fixed; + bottom: 2rem; + right: 2rem; + min-width: 320px; + max-width: 420px; + padding: 1rem 1.25rem; + border-radius: 12px; + box-shadow: 0 8px 24px rgba(0, 0, 0, 0.15), 0 2px 6px rgba(0, 0, 0, 0.1); + display: none; + align-items: center; + gap: 0.75rem; + z-index: 10000; + font-size: 0.95rem; + line-height: 1.5; + backdrop-filter: blur(10px); + -webkit-backdrop-filter: blur(10px); + animation: toastSlideIn 0.3s ease; +} + +/* Toast Visibility */ +.error-toast.show, +.success-toast.show, +.toast.show { + display: flex; +} + +/* Auto-hide animation lifecycle */ +.error-toast.show, +.success-toast.show, +.toast.show { + animation: toastLifecycle 5s ease forwards; +} + +/* Toast Variants */ +.error-toast { + background: linear-gradient(135deg, rgba(220, 53, 69, 0.95) 0%, rgba(200, 35, 51, 0.95) 100%); + color: white; + border-left: 4px solid #fff; +} + +.success-toast { + background: linear-gradient(135deg, rgba(40, 167, 69, 0.95) 0%, rgba(25, 135, 84, 0.95) 100%); + color: white; + border-left: 4px solid #fff; +} + +.info-toast { + background: linear-gradient(135deg, rgba(13, 110, 253, 0.95) 0%, rgba(10, 88, 202, 0.95) 100%); + color: white; + border-left: 4px solid #fff; +} + +.warning-toast { + background: linear-gradient(135deg, rgba(255, 193, 7, 0.95) 0%, rgba(255, 167, 38, 0.95) 100%); + color: #333; + border-left: 4px solid #333; +} + +/* Toast Icon */ +.toast-icon, +.error-icon, +.success-icon, +.info-icon { + font-size: 1.5rem; + flex-shrink: 0; + line-height: 1; +} + +/* Toast Content */ +.toast-content { + flex: 1; + display: flex; + flex-direction: column; + gap: 0.25rem; +} + +.toast-title { + font-weight: 600; + font-size: 1rem; + margin: 0; +} + +.toast-message { + font-size: 0.875rem; + opacity: 0.95; + margin: 0; +} + +/* Close Button */ +.error-close, +.toast-close { + background: rgba(255, 255, 255, 0.2); + border: none; + color: inherit; + font-size: 1.5rem; + width: 28px; + height: 28px; + border-radius: 50%; + cursor: pointer; + flex-shrink: 0; + display: flex; + align-items: center; + justify-content: center; + transition: all 0.2s ease; + line-height: 1; + font-weight: 300; +} + +.error-close:hover, +.toast-close:hover { + background: rgba(255, 255, 255, 0.3); + transform: rotate(90deg); +} + +/* Progress Bar (for long operations like PDF generation) */ +.toast-progress { + position: absolute; + bottom: 0; + left: 0; + width: 100%; + height: 3px; + background: rgba(255, 255, 255, 0.3); + border-radius: 0 0 12px 12px; + overflow: hidden; +} + +.toast-progress-bar { + height: 100%; + background: rgba(255, 255, 255, 0.8); + border-radius: 0 0 12px 12px; + animation: progressShrink 5s linear forwards; +} + +/* Animations */ +@keyframes toastSlideIn { + from { + opacity: 0; + transform: translateX(100%) translateY(0); + } + to { + opacity: 1; + transform: translateX(0) translateY(0); + } +} + +@keyframes toastSlideOut { + from { + opacity: 1; + transform: translateX(0) translateY(0); + } + to { + opacity: 0; + transform: translateX(100%) translateY(0); + } +} + +@keyframes toastLifecycle { + 0% { + opacity: 1; + transform: translateX(0) translateY(0); + } + 85% { + opacity: 1; + transform: translateX(0) translateY(0); + } + 100% { + opacity: 0; + transform: translateX(100%) translateY(0); + } +} + +@keyframes progressShrink { + from { + width: 100%; + } + to { + width: 0%; + } +} + +/* ============================================================================= + RESPONSIVE DESIGN - TOASTS + ============================================================================= */ + +/* Mobile: Full width at bottom */ +@media (max-width: 540px) { + .error-toast, + .success-toast, + .toast { + bottom: 1rem; + right: 1rem; + left: 1rem; + min-width: unset; + max-width: unset; + padding: 0.875rem 1rem; + font-size: 0.875rem; + } + + .toast-icon, + .error-icon, + .success-icon { + font-size: 1.25rem; + } + + .toast-title { + font-size: 0.95rem; + } + + .toast-message { + font-size: 0.8rem; + } +} + +/* ============================================================================= + ACCESSIBILITY - REDUCED MOTION + ============================================================================= */ + +@media (prefers-reduced-motion: reduce) { + .error-toast, + .success-toast, + .toast { + animation: none; + } + + .error-toast.show, + .success-toast.show, + .toast.show { + animation: none; + opacity: 1; + } + + .toast-progress-bar { + animation: none; + } +} + +/* ============================================================================= + PRINT - HIDE TOASTS + ============================================================================= */ + +@media print { + .error-toast, + .success-toast, + .toast { + display: none !important; + } +} diff --git a/static/css/main.css b/static/css/main.css index 66fba0e..c09a62b 100644 --- a/static/css/main.css +++ b/static/css/main.css @@ -31,6 +31,7 @@ @import './04-interactive/_scroll-behavior.css'; @import './04-interactive/_buttons.css'; @import './04-interactive/_modals.css'; +@import './04-interactive/_toasts.css'; @import './04-interactive/_zoom-control.css'; /* 05 - Responsive */ diff --git a/static/js/main.js b/static/js/main.js index d4b5966..61673bd 100644 --- a/static/js/main.js +++ b/static/js/main.js @@ -192,6 +192,61 @@ errorToast.classList.add('show'); // CSS animation handles lifecycle }; + /** + * Display PDF download toast notification + * Shows progress and completion status for PDF downloads + * @param {Object} options - Toast configuration + * @param {string} options.icon - Icon emoji (📥, ✅, ⚠️) + * @param {string} options.title - Toast title + * @param {string} options.message - Toast message + * @param {number} options.duration - Auto-hide duration in ms (default: 5000) + */ + window.showPDFToast = function(options = {}) { + const toast = document.getElementById('pdf-toast'); + const icon = document.getElementById('pdf-toast-icon'); + const title = document.getElementById('pdf-toast-title'); + const message = document.getElementById('pdf-toast-message'); + const progressBar = document.getElementById('pdf-toast-progress'); + + // Set content + if (options.icon) icon.textContent = options.icon; + if (options.title) title.textContent = options.title; + if (options.message) message.textContent = options.message; + + // Reset animation + toast.classList.remove('show'); + void toast.offsetWidth; // Trigger reflow + + // Reset progress bar animation + if (progressBar) { + progressBar.style.animation = 'none'; + void progressBar.offsetWidth; + + // Set duration if provided + const duration = options.duration || 5000; + progressBar.style.animation = `progressShrink ${duration}ms linear forwards`; + } + + // Show toast + toast.classList.add('show'); + + // Auto-hide after duration + if (options.autoHide !== false) { + const duration = options.duration || 5000; + setTimeout(() => { + toast.classList.remove('show'); + }, duration); + } + }; + + /** + * Hide PDF toast immediately + */ + window.hidePDFToast = function() { + const toast = document.getElementById('pdf-toast'); + toast?.classList.remove('show'); + }; + /** * Close button handler for error toast */ diff --git a/templates/index.html b/templates/index.html index b3215cf..dba5877 100644 --- a/templates/index.html +++ b/templates/index.html @@ -161,6 +161,7 @@ {{template "page-footer" .}} {{template "error-toast" .}} + {{template "pdf-toast" .}} {{template "back-to-top" .}} {{template "info-button" .}} {{template "download-button" .}} diff --git a/templates/partials/modals/pdf-modal.html b/templates/partials/modals/pdf-modal.html index ae6deb7..532a340 100644 --- a/templates/partials/modals/pdf-modal.html +++ b/templates/partials/modals/pdf-modal.html @@ -301,10 +301,11 @@ formatName = isSpanish ? 'CV Actual' : 'Current CV'; } - // Show loading overlay + // Show loading overlay in modal const overlay = document.getElementById('pdf-loading-overlay'); const modalContent = document.getElementById('pdf-modal-content'); const estimateEl = document.getElementById('pdf-loading-estimate'); + const modal = document.getElementById('pdf-modal'); overlay.classList.add('active'); modalContent.classList.add('loading-active'); @@ -315,16 +316,53 @@ : `Generating ${formatName}... This may take ~${estimatedTime} seconds`; estimateEl.textContent = estimateMsg; + // Track if modal is closed by user during download + let modalClosedByUser = false; + const onModalClose = () => { + modalClosedByUser = true; + + // Show toast when user closes modal + if (window.showPDFToast) { + window.showPDFToast({ + icon: '📥', + title: isSpanish ? 'Preparando PDF...' : 'Preparing PDF...', + message: isSpanish + ? `Generando ${formatName}... (~${estimatedTime}s)` + : `Generating ${formatName}... (~${estimatedTime}s)`, + duration: estimatedTime * 1000, + autoHide: false // We'll manually update it + }); + } + }; + + // Listen for modal close + modal.addEventListener('close', onModalClose, { once: true }); + console.log('Navigating to:', url); // Trigger download window.location.href = url; - // Keep overlay showing for estimated time, then close modal + // After estimated time: update toast to success or close modal setTimeout(() => { overlay.classList.remove('active'); modalContent.classList.remove('loading-active'); - document.getElementById('pdf-modal').close(); + + if (modalClosedByUser && window.showPDFToast) { + // Update toast to success + window.showPDFToast({ + icon: '✅', + title: isSpanish ? '¡PDF Listo!' : 'PDF Ready!', + message: isSpanish + ? 'Revisa tu carpeta de descargas' + : 'Check your downloads folder', + duration: 3000, + autoHide: true + }); + } else { + // Close modal if still open + modal.close(); + } }, estimatedTime * 1000); } diff --git a/templates/partials/widgets/pdf-toast.html b/templates/partials/widgets/pdf-toast.html new file mode 100644 index 0000000..a2407ff --- /dev/null +++ b/templates/partials/widgets/pdf-toast.html @@ -0,0 +1,14 @@ +{{define "pdf-toast"}} + +
+ 📥 +
+

{{if eq .Lang "es"}}Preparando PDF{{else}}Preparing PDF{{end}}

+

+
+ +
+
+
+
+{{end}}