- Removed unused Lucide CDN script from BaseHead component to reduce network costs. - Enhanced CustomCursor component with requestAnimationFrame for smoother animations and respect for user motion preferences. - Updated Hero section styles for faster fade-out transitions and improved responsiveness to user preferences. - Optimized clock functionality in Hero section to reduce drift and improve performance. - Streamlined mousemove event handling in Hero section for better performance and reduced resource usage. - Lazy-loaded markdown renderer in contact page to keep initial JavaScript lighter. - Added will-change property to global CSS for improved rendering performance.
272 lines
9.9 KiB
Plaintext
272 lines
9.9 KiB
Plaintext
---
|
|
import { Picture } from 'astro:assets';
|
|
import heroPortrait from '../../assets/nicholai-closeup-portrait.JPEG';
|
|
|
|
interface Props {
|
|
headlineLine1: string;
|
|
headlineLine2: string;
|
|
portfolioYear: string;
|
|
location: string;
|
|
locationLabel: string;
|
|
bio: string;
|
|
}
|
|
|
|
const { headlineLine1, headlineLine2, portfolioYear, location, locationLabel, bio } = Astro.props;
|
|
---
|
|
<section id="hero" class="relative w-full h-[100dvh] overflow-hidden bg-brand-dark">
|
|
<!-- Background Image (Portrait) - Optimized with AVIF/WebP -->
|
|
<div class="absolute top-0 right-0 w-full md:w-1/2 h-full z-0">
|
|
<div class="relative w-full h-full">
|
|
<Picture
|
|
src={heroPortrait}
|
|
formats={['avif', 'webp']}
|
|
widths={[640, 1024, 1600]}
|
|
sizes="(max-width: 768px) 100vw, 50vw"
|
|
alt="Nicholai Vogel portrait"
|
|
class="w-full h-full object-cover object-center opacity-0 mix-blend-luminosity md:opacity-0 transition-opacity duration-[2000ms] ease-out delay-500 intro-element"
|
|
id="hero-portrait"
|
|
loading="eager"
|
|
decoding="sync"
|
|
/>
|
|
<div class="absolute inset-0 bg-gradient-to-l from-transparent via-brand-dark/50 to-brand-dark"></div>
|
|
<div class="absolute inset-0 bg-gradient-to-t from-brand-dark via-transparent to-transparent"></div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- The 100 Squares Grid Overlay -->
|
|
<div id="grid-container" class="absolute inset-0 z-10 w-full h-full grid grid-cols-10 grid-rows-10 pointer-events-none">
|
|
{Array.from({ length: 100 }).map((_, i) => (
|
|
<div class="grid-cell w-full h-full border border-white/5 opacity-0 transition-all duration-500 ease-out" data-index={i}></div>
|
|
))}
|
|
</div>
|
|
|
|
<!-- The Content -->
|
|
<!-- Adjusted pt to clear fixed nav since BaseLayout padding is removed -->
|
|
<div class="absolute inset-0 z-20 flex flex-col justify-between p-6 md:p-12 lg:p-16 pt-32 lg:pt-40 pointer-events-auto">
|
|
|
|
<!-- Top Metadata -->
|
|
<div class="flex justify-between items-start w-full intro-element opacity-0 translate-y-4 transition-all duration-1000 ease-out delay-300">
|
|
<div class="font-mono text-xs uppercase tracking-widest text-slate-500">
|
|
{portfolioYear}
|
|
</div>
|
|
<div class="font-mono text-xs text-slate-500 text-right tracking-wide">
|
|
<span class="block text-slate-600 mb-1 uppercase tracking-widest">{locationLabel}</span>
|
|
{location}<br>
|
|
<span id="clock" class="text-brand-accent">00:00:00 MST</span>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Main Heading & Description -->
|
|
<div class="max-w-5xl">
|
|
<h1 class="text-6xl md:text-8xl lg:text-9xl tracking-tighter leading-[0.9] font-bold text-white mix-blend-overlay opacity-90 mb-8 perspective-text">
|
|
<span class="block intro-element opacity-0 translate-y-10 transition-all duration-1000 ease-out delay-100">{headlineLine1}</span>
|
|
<span class="block text-brand-accent opacity-0 translate-y-10 transition-all duration-1000 ease-out delay-200 intro-element">{headlineLine2}</span>
|
|
</h1>
|
|
|
|
<p class="font-mono text-sm md:text-base max-w-lg text-slate-400 font-light leading-relaxed intro-element opacity-0 translate-y-6 transition-all duration-1000 ease-out delay-500">
|
|
{bio}
|
|
</p>
|
|
</div>
|
|
|
|
<!-- Bottom Navigation -->
|
|
<div class="flex justify-between items-end w-full intro-element opacity-0 transition-all duration-1000 ease-out delay-700">
|
|
<a href="#experience" class="flex items-center justify-center w-12 h-12 border border-white/10 rounded-full text-brand-accent hover:bg-brand-accent hover:text-brand-dark transition-all duration-300 group">
|
|
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="group-hover:animate-bounce">
|
|
<path d="M12 5v14M19 12l-7 7-7-7"/>
|
|
</svg>
|
|
</a>
|
|
|
|
<div class="text-right font-mono text-xs text-slate-500 tracking-widest">
|
|
SCROLL TO EXPLORE
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</section>
|
|
|
|
<style>
|
|
.grid-cell.active {
|
|
background-color: var(--color-brand-accent);
|
|
opacity: 0.15;
|
|
transition: opacity 0s, background-color 0s; /* Instant on */
|
|
}
|
|
/* Fade out */
|
|
.grid-cell {
|
|
/* Slightly faster fade-out for a snappier feel */
|
|
transition: opacity 0.6s ease-out, background-color 0.6s ease-out;
|
|
}
|
|
|
|
/* Initial Loaded State Classes */
|
|
.intro-visible {
|
|
opacity: 1 !important;
|
|
transform: translateY(0) !important;
|
|
}
|
|
|
|
/* Portrait Loaded State */
|
|
.portrait-visible {
|
|
opacity: 0.4 !important; /* Mobile default */
|
|
}
|
|
@media (min-width: 768px) {
|
|
.portrait-visible {
|
|
opacity: 0.6 !important; /* Desktop default */
|
|
}
|
|
}
|
|
</style>
|
|
|
|
<script>
|
|
const reduceMotion = window.matchMedia?.('(prefers-reduced-motion: reduce)')?.matches ?? false;
|
|
const finePointer = window.matchMedia?.('(pointer: fine) and (hover: hover)')?.matches ?? false;
|
|
|
|
// ===== CLOCK (pause on hidden tab, align to second boundaries) =====
|
|
let clockTimer = 0;
|
|
|
|
function updateClockOnce() {
|
|
const clock = document.getElementById('clock');
|
|
if (!clock) return;
|
|
|
|
const now = new Date();
|
|
const timeString = now.toLocaleTimeString('en-US', { hour12: false, timeZone: 'America/Denver' });
|
|
clock.textContent = `${timeString} MST`;
|
|
}
|
|
|
|
function startClock() {
|
|
if (clockTimer) window.clearTimeout(clockTimer);
|
|
|
|
const tick = () => {
|
|
if (document.hidden) {
|
|
clockTimer = window.setTimeout(tick, 1000);
|
|
return;
|
|
}
|
|
|
|
updateClockOnce();
|
|
// Align to the next second boundary to reduce drift.
|
|
const msToNextSecond = 1000 - (Date.now() % 1000);
|
|
clockTimer = window.setTimeout(tick, msToNextSecond);
|
|
};
|
|
|
|
tick();
|
|
}
|
|
|
|
startClock();
|
|
|
|
// Intro Animation Sequence
|
|
window.addEventListener('load', () => {
|
|
// Trigger Intro Elements
|
|
const introElements = document.querySelectorAll('.intro-element');
|
|
introElements.forEach(el => {
|
|
el.classList.add('intro-visible');
|
|
});
|
|
|
|
// Trigger Portrait
|
|
const portrait = document.getElementById('hero-portrait');
|
|
if (portrait) {
|
|
portrait.classList.add('portrait-visible');
|
|
}
|
|
|
|
// Trigger Grid Ripple (skip if reduced motion)
|
|
if (!reduceMotion) {
|
|
const cells = document.querySelectorAll('.grid-cell');
|
|
// Diagonal sweep effect
|
|
cells.forEach((cell, i) => {
|
|
const row = Math.floor(i / 10);
|
|
const col = i % 10;
|
|
const delay = (row + col) * 45; // slightly faster diagonal delay
|
|
|
|
window.setTimeout(() => {
|
|
cell.classList.add('active');
|
|
window.setTimeout(() => {
|
|
cell.classList.remove('active');
|
|
}, 180);
|
|
}, delay);
|
|
});
|
|
}
|
|
});
|
|
|
|
|
|
// Robust Grid Interaction
|
|
const section = document.getElementById('hero');
|
|
const cells = document.querySelectorAll('.grid-cell');
|
|
|
|
if (section) {
|
|
// Throttle mousemove work to one update per frame.
|
|
let latestX = 0;
|
|
let latestY = 0;
|
|
let pending = false;
|
|
let lastIndex = -1;
|
|
const timeouts: number[] = new Array(cells.length).fill(0);
|
|
|
|
const process = () => {
|
|
pending = false;
|
|
if (!finePointer || reduceMotion) return;
|
|
|
|
const rect = section.getBoundingClientRect();
|
|
const width = rect.width;
|
|
const height = rect.height;
|
|
if (width <= 0 || height <= 0) return;
|
|
|
|
const x = latestX - rect.left;
|
|
const y = latestY - rect.top;
|
|
|
|
const col = Math.floor((x / width) * 10);
|
|
const row = Math.floor((y / height) * 10);
|
|
|
|
if (col < 0 || col >= 10 || row < 0 || row >= 10) return;
|
|
|
|
const index = row * 10 + col;
|
|
if (index === lastIndex) return;
|
|
lastIndex = index;
|
|
|
|
const cell = cells[index] as HTMLElement | undefined;
|
|
if (!cell) return;
|
|
|
|
cell.classList.add('active');
|
|
|
|
const prev = timeouts[index];
|
|
if (prev) window.clearTimeout(prev);
|
|
|
|
// Shorter hold time for a quicker trail.
|
|
timeouts[index] = window.setTimeout(() => {
|
|
cell.classList.remove('active');
|
|
timeouts[index] = 0;
|
|
}, 35);
|
|
};
|
|
|
|
section.addEventListener('mousemove', (e) => {
|
|
latestX = e.clientX;
|
|
latestY = e.clientY;
|
|
if (pending) return;
|
|
pending = true;
|
|
window.requestAnimationFrame(process);
|
|
}, { passive: true });
|
|
}
|
|
|
|
// Random pulse for liveliness
|
|
let pulseInterval = 0;
|
|
|
|
function startPulse() {
|
|
if (pulseInterval) window.clearInterval(pulseInterval);
|
|
if (!finePointer || reduceMotion) return;
|
|
|
|
pulseInterval = window.setInterval(() => {
|
|
if (document.hidden) return;
|
|
|
|
const randomIndex = Math.floor(Math.random() * cells.length);
|
|
const cell = cells[randomIndex] as HTMLElement | undefined;
|
|
if (!cell) return;
|
|
|
|
cell.classList.add('active');
|
|
window.setTimeout(() => {
|
|
cell.classList.remove('active');
|
|
}, 160);
|
|
}, 1200);
|
|
}
|
|
|
|
startPulse();
|
|
|
|
document.addEventListener('visibilitychange', () => {
|
|
// Keep timers light in background.
|
|
if (!document.hidden) {
|
|
updateClockOnce();
|
|
}
|
|
});
|
|
</script>
|