feat(cv): add simple in-page modal, fix cross-origin download, and resolve hydration warnings

This commit is contained in:
ssbg04 committed 2026-09-12 15:41:52 +08:00
1 parent b2a5ef1a31
commit 363fa64faf
6 files changed
+207 -36

No files matched your search

+190 -15
View File
@@ -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 (
<section id="about" className="py-12 sm:py-16 relative">
<div className="max-w-6xl mx-auto px-4 sm:px-6 relative z-10">
@@ -161,29 +238,43 @@ export default function About({
Curriculum Vitae
</span>
<div className="grid grid-cols-2 gap-2">
<a
href={resumeUrl}
target="_blank"
rel="noopener noreferrer"
<button
type="button"
onClick={() => 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"
>
<svg className="w-3.5 h-3.5" fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" d="M2.036 12.322a1.012 1.012 0 0 1 0-.639C3.423 7.51 7.36 4.5 12 4.5c4.638 0 8.573 3.007 9.963 7.178.07.207.07.431 0 .639C20.577 16.49 16.64 19.5 12 19.5c-4.638 0-8.573-3.007-9.963-7.178Z" />
<path strokeLinecap="round" strokeLinejoin="round" d="M15 12a3 3 0 1 1-6 0 3 3 0 0 1 6 0Z" />
</svg>
View CV ↗
</a>
</button>
<a
href={resumeUrl}
download="CV-Cris-Charles-Garcia.pdf"
className="inline-flex items-center justify-center gap-1.5 px-3 py-2.5 rounded-xl border border-border-custom hover:border-primary-custom/40 text-foreground-custom text-xs font-medium hover:bg-primary-custom/5 transition-all cursor-pointer text-center"
<button
type="button"
onClick={handleDownload}
disabled={isDownloading}
className="inline-flex items-center justify-center gap-1.5 px-3 py-2.5 rounded-xl border border-border-custom hover:border-primary-custom/40 text-foreground-custom text-xs font-medium hover:bg-primary-custom/5 transition-all cursor-pointer text-center disabled:opacity-60"
title="Download CV file"
>
<svg className="w-3.5 h-3.5" fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" d="M3 16.5v2.25A2.25 2.25 0 0 0 5.25 21h13.5A2.25 2.25 0 0 0 21 18.75V16.5M16.5 12 12 16.5m0 0L7.5 12m4.5 4.5V3" />
</svg>
Download
</a>
{isDownloading ? (
<>
<svg className="w-3.5 h-3.5 animate-spin" fill="none" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle>
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8v8H4z"></path>
</svg>
Downloading...
</>
) : (
<>
<svg className="w-3.5 h-3.5" fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" d="M3 16.5v2.25A2.25 2.25 0 0 0 5.25 21h13.5A2.25 2.25 0 0 0 21 18.75V16.5M16.5 12 12 16.5m0 0L7.5 12m4.5 4.5V3" />
</svg>
Download
</>
)}
</button>
</div>
</div>
</div>
@@ -328,6 +419,90 @@ export default function About({
</div>
</div>
{/* Simple CV Preview Modal */}
{mounted && isPreviewOpen && createPortal(
<div
className="fixed inset-0 z-[100] flex items-center justify-center p-3 sm:p-5 bg-black/70 backdrop-blur-sm transition-all"
onClick={() => setIsPreviewOpen(false)}
role="dialog"
aria-modal="true"
aria-label="Curriculum Vitae Preview"
>
<div
className="w-full max-w-4xl h-[88vh] max-h-[900px] flex flex-col rounded-2xl bg-card-custom border border-border-custom shadow-2xl overflow-hidden"
onClick={(e) => e.stopPropagation()}
>
{/* Modal Header */}
<div className="px-4 sm:px-6 py-3.5 border-b border-border-custom flex items-center justify-between gap-3 bg-card-custom shrink-0">
<div className="flex items-center gap-2.5 min-w-0">
<span className="p-1.5 rounded-lg bg-primary-custom/10 text-primary-custom shrink-0">
<svg className="w-4 h-4" fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" d="M19.5 14.25v-2.625a3.375 3.375 0 0 0-3.375-3.375h-1.5A1.125 1.125 0 0 1 13.5 7.125v-1.5a3.375 3.375 0 0 0-3.375-3.375H8.25m2.25 0H5.625c-.621 0-1.125.504-1.125 1.125v17.25c0 .621.504 1.125 1.125 1.125h12.75c.621 0 1.125-.504 1.125-1.125V11.25a9 9 0 0 0-9-9Z" />
</svg>
</span>
<div className="min-w-0">
<h3 className="text-sm font-bold font-heading text-foreground-custom truncate">
Curriculum Vitae
</h3>
<p className="text-[11px] font-mono text-muted-foreground-custom truncate">
{settings.fullName}
</p>
</div>
</div>
{/* Actions: Download & Close */}
<div className="flex items-center gap-2 shrink-0">
<button
type="button"
onClick={handleDownload}
disabled={isDownloading}
className="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-xl bg-primary-custom text-white text-xs font-medium hover:bg-primary-custom/90 transition-all cursor-pointer shadow-xs disabled:opacity-60"
title="Download CV"
>
{isDownloading ? (
<>
<svg className="w-3.5 h-3.5 animate-spin" fill="none" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle>
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8v8H4z"></path>
</svg>
<span className="hidden sm:inline">Downloading...</span>
</>
) : (
<>
<svg className="w-3.5 h-3.5" fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" d="M3 16.5v2.25A2.25 2.25 0 0 0 5.25 21h13.5A2.25 2.25 0 0 0 21 18.75V16.5M16.5 12 12 16.5m0 0L7.5 12m4.5 4.5V3" />
</svg>
<span>Download</span>
</>
)}
</button>
<button
type="button"
onClick={() => setIsPreviewOpen(false)}
aria-label="Close modal"
className="p-1.5 rounded-xl border border-border-custom hover:border-primary-custom/40 text-muted-foreground-custom hover:text-foreground-custom transition-all cursor-pointer"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18 18 6M6 6l12 12" />
</svg>
</button>
</div>
</div>
{/* Modal Body: PDF Preview */}
<div className="relative flex-1 w-full h-full bg-zinc-900 overflow-hidden">
<iframe
src={`${resumeUrl}#toolbar=1&navpanes=0`}
title="Curriculum Vitae Document"
className="w-full h-full border-0 bg-zinc-900"
/>
</div>
</div>
</div>,
document.body
)}
</section>
);
}
+2 -2
View File
@@ -184,7 +184,7 @@ export default function LocalTimeWeather({
<span className="font-mono text-muted-foreground-custom">Local Time</span>
<div className="flex items-center gap-1.5 font-mono text-foreground-custom">
<span className="w-1.5 h-1.5 rounded-full bg-emerald-500 animate-pulse" />
<span className="font-semibold text-xs">{dateTime.time}</span>
<span className="font-semibold text-xs" suppressHydrationWarning>{dateTime.time}</span>
<span className="text-[10px] text-muted-foreground-custom font-medium">PHT</span>
</div>
</div>
@@ -192,7 +192,7 @@ export default function LocalTimeWeather({
{/* Date */}
<div className="flex justify-between items-center py-1 border-b border-border-custom/50">
<span className="font-mono text-muted-foreground-custom">Date</span>
<span className="font-mono text-foreground-custom text-xs font-medium">
<span className="font-mono text-foreground-custom text-xs font-medium" suppressHydrationWarning>
{dateTime.date}
</span>
</div>
+2 -7
View File
@@ -23,12 +23,8 @@ export default function ScrollReveal({
variant = 'fade-up',
delay = 0
}: ScrollRevealProps) {
const [isLowTier, setIsLowTier] = useState(() => {
if (typeof document !== 'undefined') {
return document.documentElement.dataset.tier === 'low' || localStorage.getItem('liteMode') === 'true';
}
return false;
});
const isMobile = useIsMobile();
const [isLowTier, setIsLowTier] = useState(false);
useEffect(() => {
const checkTier = () => {
@@ -52,7 +48,6 @@ export default function ScrollReveal({
}
const chosenVariant = variantsMap[variant] || variantsMap['fade-up'];
const isMobile = useIsMobile();
return (
<motion.div
+4 -9
View File
@@ -7,14 +7,8 @@ export default function SpotlightLens() {
const [bodySize, setBodySize] = useState({ width: 0, height: 0 });
const [scroll, setScroll] = useState({ x: 0, y: 0 });
const [magnifierPos, setMagnifierPos] = useState({ clientX: -100, clientY: -100 });
const [isDesktop, setIsDesktop] = useState(() => {
if (typeof window !== 'undefined') {
const fine = window.matchMedia('(hover: hover) and (pointer: fine)').matches;
const isLow = document.documentElement.dataset.tier === 'low' || localStorage.getItem('liteMode') === 'true';
return fine && !isLow;
}
return false;
});
const [mounted, setMounted] = useState(false);
const [isDesktop, setIsDesktop] = useState(false);
const [magnifierEnabled, setMagnifierEnabled] = useState(false);
// Direct DOM ref for 120 FPS cursor (0 React re-renders on mouse movement)
@@ -29,6 +23,7 @@ export default function SpotlightLens() {
const rafId = useRef<number | null>(null);
useEffect(() => {
setMounted(true);
const checkDesktop = () => {
const fine = window.matchMedia('(hover: hover) and (pointer: fine)').matches;
const isLow = document.documentElement.dataset.tier === 'low' || localStorage.getItem('liteMode') === 'true';
@@ -215,7 +210,7 @@ export default function SpotlightLens() {
const pageX = magnifierPos.clientX + scroll.x;
const pageY = magnifierPos.clientY + scroll.y;
if (!isDesktop) return null;
if (!mounted || !isDesktop) return null;
return (
<>
+5 -2
View File
@@ -16,7 +16,10 @@ const links = allLinks.length > 0
{ platform: 'Facebook', url: 'https://facebook.com', icon: 'facebook', order: 5 },
];
const resumeUrl = settings.resumeUrl || '/CV-Cris-Charles-Garcia.pdf';
const resumeUrl = settings.resumeFile && settings.resumeFile !== '#' ? settings.resumeFile : '/CV-Cris-Charles-Garcia.pdf';
const downloadUrl = resumeUrl.includes('cdn.sanity.io')
? `${resumeUrl}${resumeUrl.includes('?') ? '&' : '?'}dl=CV-Cris-Charles-Garcia.pdf`
: resumeUrl;
const profileAvatar = settings.heroImage || settings.logoImage || '/logo.png';
const formatDisplayUrl = (url: string) => {
@@ -124,7 +127,7 @@ const getIconSvg = (platform: string) => {
</a>
<a
href={resumeUrl}
href={downloadUrl}
download="CV-Cris-Charles-Garcia.pdf"
class="inline-flex items-center justify-center gap-1.5 px-3 py-2 rounded-xl border border-border-custom hover:border-primary-custom/40 text-foreground-custom text-xs font-medium hover:bg-primary-custom/5 transition-all text-center"
>
+4 -1
View File
@@ -5,6 +5,9 @@ import { getSiteSettings, getSocialLinks } from '../lib/data';
const settings = await getSiteSettings();
const socialLinks = await getSocialLinks();
const resumeUrl = settings.resumeFile && settings.resumeFile !== '#' ? settings.resumeFile : '/CV-Cris-Charles-Garcia.pdf';
const downloadUrl = resumeUrl.includes('cdn.sanity.io')
? `${resumeUrl}${resumeUrl.includes('?') ? '&' : '?'}dl=CV-Cris-Charles-Garcia.pdf`
: resumeUrl;
---
<Layout title={`Under Development | ${settings.fullName}`} description="Cris Charles Garcia — Portfolio currently under development. Please check back later.">
@@ -87,7 +90,7 @@ const resumeUrl = settings.resumeFile && settings.resumeFile !== '#' ? settings.
View CV
</a>
<a
href={resumeUrl}
href={downloadUrl}
download="CV-Cris-Charles-Garcia.pdf"
class="inline-flex items-center justify-center gap-1.5 px-3 py-2 rounded-xl border border-border-custom hover:border-primary-custom/40 text-foreground-custom text-xs font-medium hover:bg-primary-custom/5 transition-all text-center"
>