Update site configuration and enhance SEO with structured data

- Changed site URL in configuration from 'https://example.com' to 'https://nicholai.work'.
- Updated site title and description for better SEO optimization.
- Removed outdated portrait images and replaced them with optimized formats in the hero section.
- Enhanced BaseHead component with structured data for improved search engine visibility.
- Added reading time calculation to blog posts for better user engagement.
This commit is contained in:
Nicholai 2025-12-08 14:53:56 -07:00
parent e2d2ada330
commit 673a937db3
11 changed files with 245 additions and 29 deletions

View File

@ -12,7 +12,7 @@ import react from '@astrojs/react';
// https://astro.build/config
export default defineConfig({
site: 'https://example.com',
site: 'https://nicholai.work',
integrations: [mdx(), sitemap(), react()],
adapter: cloudflare({

30
dev/blog_template.mdx Normal file
View File

@ -0,0 +1,30 @@
---
title: 'Your Blog Post Title Here'
description: 'A compelling one-sentence description that summarizes the blog post. This appears in meta tags and previews.'
pubDate: 'Jan 1 2024'
heroImage: '../../assets/blog-placeholder-1.jpg'
featured: true
category: 'Case Study'
tags: ['VFX', 'Houdini', 'Nuke', 'AI/ML', 'Brand Film']
---
<!-- Introduction paragraph: Set the context and hook the reader -->
[Write your opening paragraph here. Introduce the project, collaboration, or topic. This should be engaging and provide context for what follows.]
<!-- Optional: Main hero video or image -->
<div class="video-container my-10">
<video controls class="w-full border border-white/10">
<source src="https://media.nicholai.work/your-video.mp4" type="video/mp4" />
Your browser does not support the video tag.
</video>
<p class="text-slate-500 text-sm mt-3 font-mono">/// FINAL FILM</p>
</div>
<!-- Alternative: If using an image instead of video -->
<!--
<div class="my-10">
<img src="path/to/image.jpg" alt="Description" class="w-full border border-white/10" />
<p class="text-slate-500 text-sm mt-3 font-mono">/// HERO IMAGE</p>
</div>
-->

5
public/robots.txt Normal file
View File

@ -0,0 +1,5 @@
User-agent: *
Allow: /
Sitemap: https://nicholai.work/sitemap-index.xml

View File

Before

Width:  |  Height:  |  Size: 3.3 MiB

After

Width:  |  Height:  |  Size: 3.3 MiB

View File

Before

Width:  |  Height:  |  Size: 199 KiB

After

Width:  |  Height:  |  Size: 199 KiB

View File

@ -3,23 +3,110 @@
// all pages through the use of the <BaseHead /> component.
import '../styles/global.css';
import type { ImageMetadata } from 'astro';
import FallbackImage from '../assets/blog-placeholder-1.jpg';
import { SITE_TITLE } from '../consts';
import DefaultOGImage from '../assets/nicholai-medium-portrait.jpg';
import { SITE_TITLE, SITE_DESCRIPTION, SOCIAL_LINKS } from '../consts';
interface Props {
title: string;
description: string;
image?: ImageMetadata;
type?: 'website' | 'article';
publishedTime?: Date;
modifiedTime?: Date;
}
const canonicalURL = new URL(Astro.url.pathname, Astro.site);
const { title, description, image = FallbackImage } = Astro.props;
const {
title,
description,
image = DefaultOGImage,
type = 'website',
publishedTime,
modifiedTime,
} = Astro.props;
// Structured Data - Person Schema (optimized for rich results)
const personSchema = {
"@context": "https://schema.org",
"@type": "Person",
"@id": "https://nicholai.work/#person",
"name": "Nicholai Vogel",
"givenName": "Nicholai",
"familyName": "Vogel",
"url": "https://nicholai.work",
"email": SOCIAL_LINKS.email,
"image": new URL(DefaultOGImage.src, Astro.site).toString(),
"jobTitle": "VFX Supervisor",
"description": "VFX Supervisor and Houdini Artist specializing in commercial and music video visual effects",
"worksFor": {
"@type": "Organization",
"name": "Biohazard VFX",
"url": "https://biohazardvfx.com"
},
"knowsAbout": [
"Visual Effects",
"VFX Supervision",
"Compositing",
"Houdini",
"SideFX Houdini",
"Nuke",
"The Foundry Nuke",
"3D Animation",
"AI/ML Integration",
"Motion Graphics",
"Commercial VFX",
"Music Video VFX"
],
"sameAs": [
"https://instagram.com/nicholai.exe/",
SOCIAL_LINKS.linkedin,
"https://biohazardvfx.com"
],
"alumniOf": [],
"award": []
};
// Structured Data - WebSite Schema with potential search action
const websiteSchema = {
"@context": "https://schema.org",
"@type": "WebSite",
"@id": "https://nicholai.work/#website",
"name": SITE_TITLE,
"description": SITE_DESCRIPTION,
"url": "https://nicholai.work",
"inLanguage": "en-US",
"author": {
"@id": "https://nicholai.work/#person"
},
"publisher": {
"@id": "https://nicholai.work/#person"
}
};
// Structured Data - Professional Service (helps with local/service searches)
const professionalServiceSchema = {
"@context": "https://schema.org",
"@type": "ProfessionalService",
"@id": "https://nicholai.work/#service",
"name": "Nicholai Vogel - VFX Services",
"description": "Professional visual effects services including VFX supervision, Houdini FX, compositing, and AI integration for commercials and music videos",
"url": "https://nicholai.work",
"provider": {
"@id": "https://nicholai.work/#person"
},
"areaServed": "Worldwide",
"serviceType": ["VFX Supervision", "Visual Effects", "Compositing", "3D Animation", "Motion Graphics"]
};
---
<!-- Global Metadata -->
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width,initial-scale=1" />
<meta name="theme-color" content="#0B0D11" />
<meta name="color-scheme" content="dark" />
<meta name="robots" content="index, follow" />
<meta name="author" content="Nicholai Vogel" />
<link rel="icon" type="image/x-icon" href="/favicon.ico" />
<link rel="icon" type="image/png" sizes="32x32" href="/favicon-32.png" />
<link rel="icon" type="image/png" sizes="192x192" href="/favicon-192.png" />
@ -33,15 +120,24 @@ const { title, description, image = FallbackImage } = Astro.props;
/>
<meta name="generator" content={Astro.generator} />
<!-- Fonts -->
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<!-- Fonts - Preconnect and load with display=swap -->
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link
href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;600;800&family=Space+Mono:wght@400;700&display=swap"
rel="stylesheet">
href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;600;800&family=Space+Mono:wght@400;700&display=swap"
rel="stylesheet"
media="print"
onload="this.media='all'"
/>
<noscript>
<link
href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;600;800&family=Space+Mono:wght@400;700&display=swap"
rel="stylesheet"
/>
</noscript>
<!-- Icons -->
<script src="https://unpkg.com/lucide@latest"></script>
<!-- Icons - Load async to prevent render blocking -->
<script src="https://unpkg.com/lucide@latest" defer></script>
<!-- Canonical URL -->
<link rel="canonical" href={canonicalURL} />
@ -52,15 +148,25 @@ const { title, description, image = FallbackImage } = Astro.props;
<meta name="description" content={description} />
<!-- Open Graph / Facebook -->
<meta property="og:type" content="website" />
<meta property="og:type" content={type} />
<meta property="og:url" content={Astro.url} />
<meta property="og:title" content={title} />
<meta property="og:description" content={description} />
<meta property="og:image" content={new URL(image.src, Astro.url)} />
<meta property="og:site_name" content={SITE_TITLE} />
<meta property="og:locale" content="en_US" />
{publishedTime && <meta property="article:published_time" content={publishedTime.toISOString()} />}
{modifiedTime && <meta property="article:modified_time" content={modifiedTime.toISOString()} />}
<!-- Twitter -->
<meta property="twitter:card" content="summary_large_image" />
<meta property="twitter:url" content={Astro.url} />
<meta property="twitter:title" content={title} />
<meta property="twitter:description" content={description} />
<meta property="twitter:image" content={new URL(image.src, Astro.url)} />
<meta name="twitter:card" content="summary_large_image" />
<meta name="twitter:url" content={Astro.url.toString()} />
<meta name="twitter:title" content={title} />
<meta name="twitter:description" content={description} />
<meta name="twitter:image" content={new URL(image.src, Astro.url).toString()} />
<meta name="twitter:creator" content="@nicholai_exe" />
<!-- Structured Data - JSON-LD -->
<script type="application/ld+json" set:html={JSON.stringify(personSchema)} />
<script type="application/ld+json" set:html={JSON.stringify(websiteSchema)} />
<script type="application/ld+json" set:html={JSON.stringify(professionalServiceSchema)} />

View File

@ -1,4 +1,7 @@
---
import { Picture } from 'astro:assets';
import heroPortrait from '../../assets/nicholai-closeup-portrait.JPEG';
interface Props {
headlineLine1: string;
headlineLine2: string;
@ -11,14 +14,19 @@ interface Props {
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) -->
<!-- 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">
<img
src="/media/nicholai-closeup-portrait.JPEG"
alt="Nicholai Portrait"
<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>

View File

@ -1,10 +1,14 @@
// Place any global data in this file.
// You can import this data from anywhere in your site by using the `import` keyword.
export const SITE_TITLE = 'Nicholai Vogel | VFX Artist & Technical Generalist';
export const SITE_DESCRIPTION = 'VFX Artist & Technical Generalist specializing in Houdini, Nuke, and AI/ML integration. Founder of Biohazard VFX.';
// SEO-optimized title (under 60 characters for full display in search results)
export const SITE_TITLE = 'Nicholai Vogel — VFX Supervisor & Technical VFX Artist';
// SEO-optimized description (under 160 characters, includes keywords and CTA)
export const SITE_DESCRIPTION = 'VFX Supervisor specializing in both 2D and 3D VFX, AI and highend technical visualization. Clients: G-Star Raw, Interscope, Ralph Lauren. Founder of Biohazard VFX.';
export const SOCIAL_LINKS = {
email: 'nicholai@nicholai.work',
website: 'https://nicholai.work',
linkedin: '#' // Update when available
linkedin: 'https://www.linkedin.com/in/nicholai-vogel-7a6b85112/'
};

View File

@ -1,4 +1,5 @@
---
import type { ImageMetadata } from 'astro';
import BaseHead from '../components/BaseHead.astro';
import Footer from '../components/Footer.astro';
import GridOverlay from '../components/GridOverlay.astro';
@ -10,15 +11,35 @@ 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 } = Astro.props;
const {
title = SITE_TITLE,
description = SITE_DESCRIPTION,
usePadding = true,
image,
type = 'website',
publishedTime,
modifiedTime,
} = Astro.props;
---
<!DOCTYPE html>
<html lang="en" class="scroll-smooth">
<head>
<BaseHead title={title} description={description} />
<BaseHead
title={title}
description={description}
image={image}
type={type}
publishedTime={publishedTime}
modifiedTime={modifiedTime}
/>
<slot name="head" />
</head>
<body class="antialiased selection:bg-brand-accent selection:text-brand-dark bg-brand-dark text-white">
<CustomCursor client:load />

View File

@ -37,6 +37,7 @@ interface Props {
prevPost?: NavPost;
nextPost?: NavPost;
relatedPosts?: RelatedPost[];
readTime?: string;
}
const {
@ -51,13 +52,47 @@ const {
prevPost,
nextPost,
relatedPosts = [],
readTime = '5 min read',
} = Astro.props;
// Estimated read time (rough calculation)
const readTime = '5 min read';
// Article structured data (JSON-LD)
const articleSchema = {
"@context": "https://schema.org",
"@type": "Article",
"headline": title,
"description": description,
"datePublished": pubDate.toISOString(),
"dateModified": (updatedDate || pubDate).toISOString(),
"author": {
"@type": "Person",
"name": "Nicholai Vogel",
"url": "https://nicholai.work"
},
"publisher": {
"@type": "Person",
"name": "Nicholai Vogel",
"url": "https://nicholai.work"
},
"mainEntityOfPage": {
"@type": "WebPage",
"@id": Astro.url.href
},
...(heroImage && { "image": new URL(heroImage.src, Astro.url).toString() }),
...(category && { "articleSection": category }),
...(tags && tags.length > 0 && { "keywords": tags.join(", ") })
};
---
<BaseLayout title={title} description={description} image={heroImage}>
<BaseLayout
title={title}
description={description}
image={heroImage}
type="article"
publishedTime={pubDate}
modifiedTime={updatedDate}
>
<!-- Article Structured Data -->
<script type="application/ld+json" set:html={JSON.stringify(articleSchema)} slot="head" />
<ReadingProgress />
<article class="relative pb-24">

View File

@ -89,6 +89,12 @@ interface Props {
const { post, prevPost, nextPost, relatedPosts } = Astro.props;
const { Content, headings } = await render(post);
// Calculate reading time (average 200 words per minute)
const wordsPerMinute = 200;
const wordCount = post.body?.split(/\s+/).length || 0;
const readingTime = Math.max(1, Math.ceil(wordCount / wordsPerMinute));
const readTimeText = `${readingTime} min read`;
---
<BlogPost
@ -97,6 +103,7 @@ const { Content, headings } = await render(post);
prevPost={prevPost}
nextPost={nextPost}
relatedPosts={relatedPosts}
readTime={readTimeText}
>
<Content />
</BlogPost>