From 363fa64fafb10ecde83c0a3a1bbcee1f776087bc Mon Sep 17 00:00:00 2001 From: ssbg04 Date: Sat, 12 Sep 2026 15:41:52 +0800 Subject: [PATCH] feat(cv): add simple in-page modal, fix cross-origin download, and resolve hydration warnings --- src/components/About.tsx | 205 ++++++++++++++++++++++++++-- src/components/LocalTimeWeather.tsx | 4 +- src/components/ScrollReveal.tsx | 9 +- src/components/SpotlightLens.tsx | 13 +- src/pages/links.astro | 7 +- src/pages/maintenance.astro | 5 +- 6 files changed, 207 insertions(+), 36 deletions(-) diff --git a/src/components/About.tsx b/src/components/About.tsx index b084ea3..4047041 100644 --- a/src/components/About.tsx +++ b/src/components/About.tsx @@ -1,4 +1,5 @@ -import React from 'react'; +import React, { useState, useEffect } from 'react'; +import { createPortal } from 'react-dom'; import type { Education, Experience, SiteSettings } from '../lib/data'; import ScrollReveal from './ScrollReveal'; import GithubContributions from './GithubContributions'; @@ -29,6 +30,82 @@ export default function About({ const hasExperience = experience && experience.length > 0; const hasEducation = education && education.length > 0; + // CV Preview Modal & Download States + const [mounted, setMounted] = useState(false); + const [isPreviewOpen, setIsPreviewOpen] = useState(false); + const [isDownloading, setIsDownloading] = useState(false); + + useEffect(() => { + setMounted(true); + }, []); + + useEffect(() => { + if (!isPreviewOpen) return; + + const originalOverflow = document.body.style.overflow; + document.body.style.overflow = 'hidden'; + + const handleKeyDown = (e: KeyboardEvent) => { + if (e.key === 'Escape') { + setIsPreviewOpen(false); + } + }; + + window.addEventListener('keydown', handleKeyDown); + return () => { + document.body.style.overflow = originalOverflow; + window.removeEventListener('keydown', handleKeyDown); + }; + }, [isPreviewOpen]); + + // Robust File Download (supports Sanity CDN ?dl= attachment and local files) + const handleDownload = async (e?: React.MouseEvent) => { + if (e) { + e.preventDefault(); + e.stopPropagation(); + } + if (isDownloading) return; + setIsDownloading(true); + + const filename = 'CV-Cris-Charles-Garcia.pdf'; + try { + let targetUrl = resumeUrl; + if (targetUrl.includes('cdn.sanity.io')) { + const sep = targetUrl.includes('?') ? '&' : '?'; + targetUrl = `${targetUrl}${sep}dl=${encodeURIComponent(filename)}`; + } + + const response = await fetch(targetUrl, { mode: 'cors' }); + if (!response.ok) throw new Error(`HTTP error! status: ${response.status}`); + const blob = await response.blob(); + const blobUrl = window.URL.createObjectURL(blob); + const link = document.createElement('a'); + link.href = blobUrl; + link.download = filename; + document.body.appendChild(link); + link.click(); + document.body.removeChild(link); + setTimeout(() => window.URL.revokeObjectURL(blobUrl), 2000); + } catch (err) { + console.warn('Direct blob download failed, falling back to direct anchor:', err); + let fallbackUrl = resumeUrl; + if (fallbackUrl.includes('cdn.sanity.io')) { + const sep = fallbackUrl.includes('?') ? '&' : '?'; + fallbackUrl = `${fallbackUrl}${sep}dl=${encodeURIComponent(filename)}`; + } + const link = document.createElement('a'); + link.href = fallbackUrl; + link.download = filename; + link.target = '_blank'; + link.rel = 'noopener noreferrer'; + document.body.appendChild(link); + link.click(); + document.body.removeChild(link); + } finally { + setIsDownloading(false); + } + }; + return (
@@ -161,29 +238,43 @@ export default function About({ Curriculum Vitae
- setIsPreviewOpen(true)} className="inline-flex items-center justify-center gap-1.5 px-3 py-2.5 rounded-xl bg-primary-custom text-white text-xs font-medium hover:bg-primary-custom/90 transition-all cursor-pointer text-center shadow-xs" + title="Preview CV" > View CV ↗ - + - - - - - Download - + {isDownloading ? ( + <> + + + + + Downloading... + + ) : ( + <> + + + + Download + + )} +
@@ -328,6 +419,90 @@ export default function About({ + + {/* Simple CV Preview Modal */} + {mounted && isPreviewOpen && createPortal( +
setIsPreviewOpen(false)} + role="dialog" + aria-modal="true" + aria-label="Curriculum Vitae Preview" + > +
e.stopPropagation()} + > + {/* Modal Header */} +
+
+ + + + + +
+

+ Curriculum Vitae +

+

+ {settings.fullName} +

+
+
+ + {/* Actions: Download & Close */} +
+ + + +
+
+ + {/* Modal Body: PDF Preview */} +
+