- Add lunr dependency to package.json and pnpm-lock.yaml. - Implement SearchDialog component for full‑text search using lunr. - Add readTime prop to BlogCard with default 5 min read. - Add reading‑time utility for estimated read duration. - Update layout and page components to include new props. - Generate search.json data for indexing. Hubert The Eunuch
244 lines
7.7 KiB
Plaintext
244 lines
7.7 KiB
Plaintext
---
|
|
import type { ImageMetadata } from 'astro';
|
|
import BaseHead from '../components/BaseHead.astro';
|
|
import Footer from '../components/Footer.astro';
|
|
import GridOverlay from '../components/GridOverlay.astro';
|
|
import Navigation from '../components/Navigation.astro';
|
|
import CustomCursor from '../components/CustomCursor';
|
|
import ThemePreferenceDialog from '../components/ThemePreferenceDialog.astro';
|
|
import { HTML_MARKER, SITE_TITLE, SITE_DESCRIPTION, SOCIAL_LINKS } from '../consts';
|
|
|
|
interface Props {
|
|
title?: string;
|
|
description?: string;
|
|
usePadding?: boolean;
|
|
image?: ImageMetadata;
|
|
type?: 'website' | 'article';
|
|
publishedTime?: Date;
|
|
modifiedTime?: Date;
|
|
}
|
|
|
|
const {
|
|
title = SITE_TITLE,
|
|
description = SITE_DESCRIPTION,
|
|
usePadding = true,
|
|
image,
|
|
type = 'website',
|
|
publishedTime,
|
|
modifiedTime,
|
|
} = Astro.props;
|
|
|
|
// Master Person schema - establishes canonical identity across all pages
|
|
const personSchema = {
|
|
"@context": "https://schema.org",
|
|
"@type": "Person",
|
|
"@id": `${SOCIAL_LINKS.website}/#person`,
|
|
"name": "Nicholai Vogel",
|
|
"url": SOCIAL_LINKS.website,
|
|
"email": SOCIAL_LINKS.email,
|
|
"jobTitle": "VFX Supervisor & Technical Artist",
|
|
"description": "VFX Supervisor specializing in both 2D and 3D VFX, AI and high-end technical visualization.",
|
|
"knowsAbout": [
|
|
"Houdini",
|
|
"Nuke",
|
|
"ComfyUI",
|
|
"Python",
|
|
"VFX Pipeline",
|
|
"Real-time VFX",
|
|
"Motion Graphics",
|
|
"Technical Art"
|
|
],
|
|
"sameAs": [
|
|
SOCIAL_LINKS.linkedin,
|
|
"https://www.instagram.com/nicholai.exe"
|
|
],
|
|
"affiliation": {
|
|
"@type": "Organization",
|
|
"name": "Biohazard VFX",
|
|
"url": "https://biohazardvfx.com",
|
|
"founder": {
|
|
"@id": `${SOCIAL_LINKS.website}/#person`
|
|
}
|
|
}
|
|
};
|
|
---
|
|
|
|
<!DOCTYPE html>
|
|
<html lang="en" class="scroll-smooth" data-theme="dark">
|
|
<head>
|
|
<meta name="x-nicholai-marker" content={HTML_MARKER} />
|
|
<!-- Theme initialization script - runs before page render to prevent flash -->
|
|
<script is:inline>
|
|
(function() {
|
|
// Apply theme
|
|
const storedLocal = localStorage.getItem('theme');
|
|
const storedSession = sessionStorage.getItem('theme');
|
|
const theme =
|
|
(storedLocal === 'light' || storedLocal === 'dark') ? storedLocal :
|
|
(storedSession === 'light' || storedSession === 'dark') ? storedSession :
|
|
'dark'; // Default fallback
|
|
document.documentElement.setAttribute('data-theme', theme);
|
|
|
|
// Apply saved accent color
|
|
const savedColor = localStorage.getItem('accent-color');
|
|
if (savedColor) {
|
|
document.documentElement.style.setProperty('--color-brand-accent', savedColor);
|
|
}
|
|
})();
|
|
</script>
|
|
<BaseHead
|
|
title={title}
|
|
description={description}
|
|
image={image}
|
|
type={type}
|
|
publishedTime={publishedTime}
|
|
modifiedTime={modifiedTime}
|
|
/>
|
|
|
|
<!-- Master Person Schema - Canonical Identity -->
|
|
<script type="application/ld+json" set:html={JSON.stringify(personSchema)} />
|
|
|
|
<slot name="head" />
|
|
</head>
|
|
|
|
<body class="antialiased selection:bg-brand-accent selection:text-brand-dark">
|
|
<!-- First-visit theme preference dialog -->
|
|
<ThemePreferenceDialog />
|
|
|
|
<!-- Only hydrate custom cursor on devices that can actually benefit from it -->
|
|
<CustomCursor client:media="(pointer: fine) and (hover: hover)" />
|
|
<GridOverlay />
|
|
<Navigation />
|
|
|
|
<main class:list={["relative z-10 min-h-screen pb-24", { "pt-32 lg:pt-48": usePadding }]}>
|
|
<slot />
|
|
</main>
|
|
|
|
<Footer />
|
|
|
|
<script>
|
|
// ===== SCROLL ANIMATION SYSTEM =====
|
|
// If you're using Astro view transitions, elements can change between navigations.
|
|
// We'll (re)bind observers on initial load and on `astro:page-load`.
|
|
|
|
// Observer for scroll-triggered animations
|
|
const scrollObserverOptions = {
|
|
threshold: 0.15,
|
|
rootMargin: "0px 0px -50px 0px"
|
|
};
|
|
|
|
const scrollObserver = new IntersectionObserver((entries) => {
|
|
entries.forEach(entry => {
|
|
if (entry.isIntersecting) {
|
|
entry.target.classList.add('is-visible');
|
|
// Optionally unobserve after animation
|
|
// scrollObserver.unobserve(entry.target);
|
|
}
|
|
});
|
|
}, scrollObserverOptions);
|
|
|
|
// Observer for legacy reveal-text animations
|
|
const revealObserverOptions = {
|
|
threshold: 0.1,
|
|
rootMargin: "0px"
|
|
};
|
|
|
|
const revealObserver = new IntersectionObserver((entries) => {
|
|
entries.forEach(entry => {
|
|
if (entry.isIntersecting) {
|
|
entry.target.classList.add('active');
|
|
}
|
|
});
|
|
}, revealObserverOptions);
|
|
|
|
function bindScrollAnimations() {
|
|
// Observe animate-on-scroll elements (avoid re-observing elements already visible)
|
|
document.querySelectorAll('.animate-on-scroll:not(.is-visible)').forEach(el => {
|
|
scrollObserver.observe(el);
|
|
});
|
|
|
|
// Observe reveal-text elements
|
|
document.querySelectorAll('.reveal-text:not(.active)').forEach(el => {
|
|
revealObserver.observe(el);
|
|
});
|
|
|
|
// Auto-stagger children in containers with .stagger-children class
|
|
document.querySelectorAll('.stagger-children').forEach(container => {
|
|
const children = container.querySelectorAll('.animate-on-scroll');
|
|
children.forEach((child, index) => {
|
|
child.classList.add(`stagger-${Math.min(index + 1, 8)}`);
|
|
});
|
|
});
|
|
}
|
|
|
|
// Initial bind
|
|
bindScrollAnimations();
|
|
|
|
// Re-bind on Astro page transitions (if enabled)
|
|
document.addEventListener('astro:page-load', bindScrollAnimations);
|
|
</script>
|
|
|
|
<script>
|
|
// ===== INTENT-BASED PREFETCH (hover/focus) =====
|
|
// Lightweight prefetch to make navigation feel instant without a full SPA router.
|
|
const prefetched = new Set();
|
|
|
|
function isPrefetchableUrl(url) {
|
|
try {
|
|
const u = new URL(url, window.location.href);
|
|
if (u.origin !== window.location.origin) return false;
|
|
if (u.hash) return false;
|
|
if (u.pathname === window.location.pathname && u.search === window.location.search) return false;
|
|
return true;
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
function prefetchDocument(url) {
|
|
if (!isPrefetchableUrl(url)) return;
|
|
const u = new URL(url, window.location.href);
|
|
const key = u.href;
|
|
if (prefetched.has(key)) return;
|
|
prefetched.add(key);
|
|
|
|
const link = document.createElement('link');
|
|
link.rel = 'prefetch';
|
|
link.as = 'document';
|
|
link.href = key;
|
|
document.head.appendChild(link);
|
|
}
|
|
|
|
function getAnchorFromEventTarget(target) {
|
|
if (!(target instanceof Element)) return null;
|
|
return target.closest('a[href]');
|
|
}
|
|
|
|
const schedule = (href) => {
|
|
// Don't block input; prefetch when the browser is idle if possible.
|
|
// @ts-ignore - requestIdleCallback isn't in all TS lib targets
|
|
if (window.requestIdleCallback) {
|
|
// @ts-ignore
|
|
window.requestIdleCallback(() => prefetchDocument(href), { timeout: 1000 });
|
|
} else {
|
|
setTimeout(() => prefetchDocument(href), 0);
|
|
}
|
|
};
|
|
|
|
document.addEventListener('mouseover', (e) => {
|
|
const a = getAnchorFromEventTarget(e.target);
|
|
const href = a?.getAttribute('href');
|
|
if (!href) return;
|
|
schedule(href);
|
|
}, { passive: true });
|
|
|
|
document.addEventListener('focusin', (e) => {
|
|
const a = getAnchorFromEventTarget(e.target);
|
|
const href = a?.getAttribute('href');
|
|
if (!href) return;
|
|
schedule(href);
|
|
}, { passive: true });
|
|
</script>
|
|
</body>
|
|
</html>
|