feat(cv): add simple in-page modal, fix cross-origin download, and resolve hydration warnings
This commit is contained in:
1 parent
b2a5ef1a31
commit
363fa64faf
6 files changed
+207
-36
No files matched your search
+190
-15
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 (
|
||||
<>
|
||||
|
||||
@@ -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"
|
||||
>
|
||||
|
||||
@@ -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"
|
||||
>
|
||||
|
||||
Reference in new issue
Block a user