Add blog components and enhance blog functionality

- Introduced new BlogCard and BlogFilters components for improved blog post presentation and filtering capabilities.
- Updated content configuration to include fields for featured posts, categories, and tags.
- Enhanced the blog index page to display a featured post and editor's picks, along with a filterable grid for latest posts.
- Added a new blog entry on the G-Star Raw Olympics campaign with associated metadata for better categorization and tagging.
This commit is contained in:
Nicholai 2025-12-08 03:58:22 -07:00
parent 27da6ad1c9
commit 058655f23d
6 changed files with 577 additions and 41 deletions

1
dev/blog-example.html Normal file

File diff suppressed because one or more lines are too long

View File

@ -0,0 +1,148 @@
---
import { Image } from 'astro:assets';
import type { ImageMetadata } from 'astro';
import FormattedDate from './FormattedDate.astro';
interface Props {
title: string;
description: string;
pubDate: Date;
heroImage?: ImageMetadata;
category?: string;
tags?: string[];
href: string;
variant?: 'default' | 'compact' | 'featured';
class?: string;
}
const {
title,
description,
pubDate,
heroImage,
category,
tags,
href,
variant = 'default',
class: className = '',
} = Astro.props;
// Compute estimated read time (rough estimate: 200 words per minute)
// For now, we'll show a placeholder since we don't have word count in frontmatter
const readTime = '5 min read';
const isCompact = variant === 'compact';
const isFeatured = variant === 'featured';
---
<article class:list={[
'group relative border border-white/10 bg-white/[0.02] hover:border-brand-accent/40 transition-all duration-500 overflow-hidden',
isFeatured ? 'lg:grid lg:grid-cols-2' : '',
className
]}>
<!-- Accent indicator strip -->
<div class="absolute top-0 left-0 w-1 h-full bg-slate-700 opacity-50 group-hover:bg-brand-accent group-hover:opacity-100 transition-all duration-500"></div>
<!-- Image section -->
<a href={href} class:list={[
'block relative overflow-hidden',
isFeatured ? 'aspect-[16/10] lg:aspect-auto lg:h-full' : isCompact ? 'aspect-[16/9]' : 'aspect-[16/9]'
]}>
{heroImage && (
<Image
src={heroImage}
alt=""
width={isFeatured ? 800 : 720}
height={isFeatured ? 500 : 360}
class="w-full h-full object-cover transition-transform duration-[1.2s] ease-out group-hover:scale-105"
/>
)}
<div class="absolute inset-0 bg-brand-dark/40 group-hover:bg-brand-dark/20 transition-colors duration-500"></div>
<div class="absolute inset-0 bg-gradient-to-t from-brand-dark/60 to-transparent"></div>
<!-- Category badge overlay -->
{category && (
<div class="absolute top-4 left-4">
<span class="px-3 py-1.5 text-[10px] font-mono font-bold uppercase tracking-widest bg-brand-dark/80 border border-white/20 text-white backdrop-blur-sm">
{category}
</span>
</div>
)}
</a>
<!-- Content section -->
<div class:list={[
'flex flex-col',
isFeatured ? 'p-8 lg:p-12 justify-center' : isCompact ? 'p-5' : 'p-6 lg:p-8'
]}>
<!-- Technical header with metadata -->
<div class="flex items-center gap-3 mb-4">
<span class="text-[10px] font-mono text-brand-accent uppercase tracking-widest">
<FormattedDate date={pubDate} />
</span>
<span class="h-px flex-grow max-w-8 bg-white/20"></span>
<span class="text-[10px] font-mono text-slate-500 uppercase tracking-widest">
{readTime}
</span>
</div>
<!-- Title -->
<a href={href}>
<h3 class:list={[
'font-bold text-white uppercase tracking-tight mb-3 group-hover:text-brand-accent transition-colors duration-300 leading-tight',
isFeatured ? 'text-3xl lg:text-4xl' : isCompact ? 'text-lg' : 'text-xl lg:text-2xl'
]}>
{title}
</h3>
</a>
<!-- Description -->
<p class:list={[
'text-slate-400 font-light leading-relaxed',
isFeatured ? 'text-base lg:text-lg line-clamp-3 mb-8' : isCompact ? 'text-sm line-clamp-2 mb-4' : 'text-sm line-clamp-2 mb-6'
]}>
{description}
</p>
<!-- Tags (only for featured and default variants) -->
{tags && tags.length > 0 && !isCompact && (
<div class="flex flex-wrap gap-2 mb-6">
{tags.slice(0, 4).map((tag) => (
<span class="px-2 py-1 text-[10px] font-mono uppercase border border-white/10 text-slate-500 group-hover:border-white/20 transition-colors">
{tag}
</span>
))}
</div>
)}
<!-- Read link -->
<div class:list={[
'flex items-center',
isFeatured ? 'mt-auto pt-6 border-t border-white/10' : 'mt-auto'
]}>
<a
href={href}
class="inline-flex items-center gap-3 text-xs font-bold uppercase tracking-widest text-slate-500 group-hover:text-white transition-all duration-300"
>
Read Article
<span class="block w-6 h-[1px] bg-slate-600 group-hover:bg-brand-accent group-hover:w-10 transition-all duration-300"></span>
<svg
xmlns="http://www.w3.org/2000/svg"
width="12"
height="12"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
class="opacity-0 -translate-x-2 group-hover:opacity-100 group-hover:translate-x-0 transition-all duration-300"
>
<path d="M5 12h14" />
<path d="m12 5 7 7-7 7" />
</svg>
</a>
</div>
</div>
</article>

View File

@ -0,0 +1,202 @@
---
interface Props {
categories: string[];
class?: string;
}
const { categories, class: className = '' } = Astro.props;
---
<div class:list={['blog-filters', className]} data-blog-filters>
<!-- Filters row -->
<div class="flex flex-col lg:flex-row lg:items-center lg:justify-between gap-6 mb-10">
<!-- Category chips -->
<div class="flex flex-wrap items-center gap-3">
<span class="text-[10px] font-mono text-slate-500 uppercase tracking-widest mr-2">
/// FILTER BY
</span>
<button
type="button"
data-category="all"
class="filter-chip active px-4 py-2 text-[10px] font-mono font-bold uppercase tracking-widest border border-white/20 text-white bg-white/5 hover:border-brand-accent hover:bg-brand-accent/5 transition-all duration-300"
>
All
</button>
{categories.map((category) => (
<button
type="button"
data-category={category}
class="filter-chip px-4 py-2 text-[10px] font-mono font-bold uppercase tracking-widest border border-white/10 text-slate-400 hover:border-brand-accent hover:text-white hover:bg-brand-accent/5 transition-all duration-300"
>
{category}
</button>
))}
</div>
<!-- Search input -->
<div class="relative lg:w-80">
<div class="absolute left-4 top-1/2 -translate-y-1/2 pointer-events-none">
<svg
xmlns="http://www.w3.org/2000/svg"
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
class="text-slate-500"
>
<circle cx="11" cy="11" r="8" />
<path d="m21 21-4.3-4.3" />
</svg>
</div>
<input
type="text"
id="blog-search"
placeholder="Search articles..."
class="w-full pl-11 pr-4 py-3 text-sm font-mono bg-transparent border border-white/10 text-white placeholder:text-slate-500 focus:border-brand-accent focus:outline-none transition-colors duration-300"
/>
<button
type="button"
id="clear-search"
class="absolute right-3 top-1/2 -translate-y-1/2 text-slate-500 hover:text-white transition-colors hidden"
>
<svg
xmlns="http://www.w3.org/2000/svg"
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="M18 6 6 18" />
<path d="m6 6 12 12" />
</svg>
</button>
</div>
</div>
<!-- Results count -->
<div class="flex items-center gap-4 pb-6 border-b border-white/10 mb-8">
<span class="text-[10px] font-mono text-slate-500 uppercase tracking-widest">
<span id="results-count">0</span> ARTICLES
</span>
<span class="h-px flex-grow bg-white/5"></span>
</div>
</div>
<script>
function initBlogFilters() {
const filterContainer = document.querySelector('[data-blog-filters]');
if (!filterContainer) return;
const filterChips = filterContainer.querySelectorAll('.filter-chip');
const searchInput = document.getElementById('blog-search') as HTMLInputElement;
const clearSearchBtn = document.getElementById('clear-search');
const resultsCount = document.getElementById('results-count');
const postsGrid = document.querySelector('[data-posts-grid]');
const noResults = document.getElementById('no-results');
if (!postsGrid) return;
const allPosts = postsGrid.querySelectorAll('[data-post]');
let activeCategory = 'all';
let searchQuery = '';
function filterPosts() {
let visibleCount = 0;
allPosts.forEach((post) => {
const postEl = post as HTMLElement;
const postCategory = postEl.dataset.category || '';
const postTitle = postEl.dataset.title?.toLowerCase() || '';
const postDescription = postEl.dataset.description?.toLowerCase() || '';
const matchesCategory = activeCategory === 'all' || postCategory === activeCategory;
const matchesSearch = !searchQuery ||
postTitle.includes(searchQuery.toLowerCase()) ||
postDescription.includes(searchQuery.toLowerCase());
if (matchesCategory && matchesSearch) {
postEl.style.display = '';
visibleCount++;
} else {
postEl.style.display = 'none';
}
});
if (resultsCount) {
resultsCount.textContent = String(visibleCount);
}
// Show/hide no results message
if (noResults) {
noResults.classList.toggle('hidden', visibleCount > 0);
}
}
// Category filter click handlers
filterChips.forEach((chip) => {
chip.addEventListener('click', () => {
const chipEl = chip as HTMLElement;
activeCategory = chipEl.dataset.category || 'all';
// Update active state
filterChips.forEach((c) => c.classList.remove('active', 'border-brand-accent', 'text-white', 'bg-white/5'));
filterChips.forEach((c) => c.classList.add('border-white/10', 'text-slate-400'));
chipEl.classList.add('active', 'border-brand-accent', 'text-white', 'bg-white/5');
chipEl.classList.remove('border-white/10', 'text-slate-400');
filterPosts();
});
});
// Search input handler
if (searchInput) {
searchInput.addEventListener('input', () => {
searchQuery = searchInput.value;
filterPosts();
// Show/hide clear button
if (clearSearchBtn) {
clearSearchBtn.classList.toggle('hidden', !searchQuery);
}
});
}
// Clear search button
if (clearSearchBtn) {
clearSearchBtn.addEventListener('click', () => {
if (searchInput) {
searchInput.value = '';
searchQuery = '';
filterPosts();
clearSearchBtn.classList.add('hidden');
}
});
}
// Initial count
filterPosts();
}
// Run on page load
initBlogFilters();
// Also run on Astro page transitions (View Transitions)
document.addEventListener('astro:page-load', initBlogFilters);
</script>
<style>
.filter-chip.active {
border-color: var(--color-brand-accent);
color: white;
background-color: rgba(255, 77, 0, 0.05);
}
</style>

View File

@ -13,6 +13,10 @@ const blog = defineCollection({
pubDate: z.coerce.date(),
updatedDate: z.coerce.date().optional(),
heroImage: image().optional(),
// Blog hub fields
featured: z.boolean().optional().default(false),
category: z.string().optional(),
tags: z.array(z.string()).optional(),
}),
});

View File

@ -3,6 +3,9 @@ title: 'G-Star Raw Olympics Campaign'
description: 'A deep dive into the VFX supervision and technical pipeline behind the G-Star Raw Olympics brand film, created in collaboration with Stinkfilms and director Felix Brady.'
pubDate: 'Aug 15 2024'
heroImage: '../../assets/g-star-image.jpg'
featured: true
category: 'Case Study'
tags: ['VFX', 'Houdini', 'Nuke', 'AI/ML', 'Brand Film']
---
In summer 2024, Biohazard VFX partnered with Stinkfilms and director Felix Brady to create a visually striking brand film for G-Star Raw's Olympics campaign.

View File

@ -3,14 +3,31 @@ import { Image } from 'astro:assets';
import { getCollection } from 'astro:content';
import BaseLayout from '../../layouts/BaseLayout.astro';
import FormattedDate from '../../components/FormattedDate.astro';
import BlogCard from '../../components/BlogCard.astro';
import BlogFilters from '../../components/BlogFilters.astro';
import { SITE_DESCRIPTION, SITE_TITLE } from '../../consts';
const posts = (await getCollection('blog')).sort(
// Fetch all posts sorted by date (newest first)
const allPosts = (await getCollection('blog')).sort(
(a, b) => b.data.pubDate.valueOf() - a.data.pubDate.valueOf(),
);
// Derive featured post (first post with featured: true, or fallback to latest)
const featuredPost = allPosts.find((post) => post.data.featured) || allPosts[0];
// Editor's picks: next 3 posts after featured (excluding the featured one)
const editorPicks = allPosts
.filter((post) => post.id !== featuredPost?.id)
.slice(0, 3);
// Latest posts: all posts for the filterable grid
const latestPosts = allPosts;
// Extract unique categories for filters
const categories = [...new Set(allPosts.map((post) => post.data.category).filter(Boolean))] as string[];
---
<BaseLayout title={SITE_TITLE} description={SITE_DESCRIPTION}>
<BaseLayout title={`Blog | ${SITE_TITLE}`} description={SITE_DESCRIPTION}>
<section class="container mx-auto px-6 lg:px-12">
<!-- Back Navigation -->
<a href="/" class="inline-flex items-center gap-3 text-xs font-semibold uppercase tracking-widest text-slate-500 hover:text-white transition-colors duration-300 mb-12 group">
@ -18,49 +35,210 @@ const posts = (await getCollection('blog')).sort(
Back to Home
</a>
<div class="mb-20 animate-on-scroll slide-up">
<h1 class="text-5xl md:text-7xl lg:text-8xl font-bold uppercase text-white mb-5 tracking-tighter">Blog</h1>
<p class="text-slate-500 font-mono text-sm tracking-wide">/// THOUGHTS & PROCESS</p>
<!-- Page Header -->
<div class="grid grid-cols-1 lg:grid-cols-12 gap-8 lg:gap-12 mb-16 lg:mb-24">
<div class="lg:col-span-8">
<h1 class="text-6xl md:text-8xl lg:text-9xl font-bold uppercase tracking-tighter leading-[0.85]">
<span class="block text-white animate-on-scroll slide-up">BLOG</span>
<span class="block text-transparent text-stroke animate-on-scroll slide-up stagger-1">ARCHIVE</span>
</h1>
</div>
<div class="lg:col-span-4 flex flex-col justify-end pb-4">
<div class="font-mono text-xs text-slate-500 uppercase tracking-widest mb-4">/// THOUGHTS & PROCESS</div>
<p class="text-slate-400 text-base leading-relaxed border-l border-brand-accent pl-6 animate-on-scroll fade-in stagger-2">
Deep dives into VFX production, technical pipelines, and creative process. Sharing lessons from the front lines of visual effects.
</p>
</div>
</div>
<ul class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-x-8 lg:gap-x-10 gap-y-16 lg:gap-y-20 stagger-children">
{
posts.map((post, index) => (
<li class={`group animate-on-scroll slide-up stagger-${Math.min(index + 1, 6)}`}>
<a href={`/blog/${post.id}/`} class="block">
<div class="relative aspect-[16/9] mb-6 overflow-hidden border border-white/10 group-hover:border-brand-accent/40 transition-all duration-500">
{post.data.heroImage && (
<Image
width={720}
height={360}
src={post.data.heroImage}
alt=""
class="w-full h-full object-cover transition-transform duration-[1s] ease-out group-hover:scale-105"
/>
)}
<div class="absolute inset-0 bg-brand-dark/30 group-hover:bg-brand-dark/10 transition-colors duration-500"></div>
<div class="absolute inset-0 bg-gradient-to-t from-brand-dark/50 to-transparent opacity-60"></div>
</div>
<div class="flex flex-col">
<div class="flex items-center gap-2 mb-4">
<span class="text-[10px] font-mono text-brand-accent uppercase tracking-widest">
<FormattedDate date={post.data.pubDate} />
<!-- Featured Hero Section -->
{featuredPost && (
<div class="mb-16 lg:mb-24 animate-on-scroll slide-up stagger-2">
<div class="flex items-center gap-4 mb-8">
<div class="w-2 h-2 bg-brand-accent rounded-full animate-pulse"></div>
<span class="text-[10px] font-mono text-brand-accent uppercase tracking-widest font-bold">
SYS.BLOG /// FEATURED
</span>
<span class="h-px flex-grow bg-white/10"></span>
</div>
<article class="group relative border border-white/10 bg-white/[0.02] hover:border-brand-accent/40 transition-all duration-500 overflow-hidden">
<!-- Accent indicator strip -->
<div class="absolute top-0 left-0 w-1 h-full bg-brand-accent"></div>
<div class="absolute top-0 left-0 w-full h-1 bg-brand-accent opacity-0 group-hover:opacity-100 transition-opacity duration-500"></div>
<div class="grid grid-cols-1 lg:grid-cols-2">
<!-- Image section -->
<a href={`/blog/${featuredPost.id}/`} class="block relative aspect-[16/10] lg:aspect-auto overflow-hidden">
{featuredPost.data.heroImage && (
<Image
src={featuredPost.data.heroImage}
alt=""
width={900}
height={600}
class="w-full h-full object-cover transition-transform duration-[1.2s] ease-out group-hover:scale-105"
/>
)}
<div class="absolute inset-0 bg-brand-dark/30 group-hover:bg-brand-dark/10 transition-colors duration-500"></div>
<div class="absolute inset-0 bg-gradient-to-r from-transparent via-transparent to-brand-dark/80 hidden lg:block"></div>
<div class="absolute inset-0 bg-gradient-to-t from-brand-dark/60 to-transparent lg:hidden"></div>
<!-- Category badge -->
{featuredPost.data.category && (
<div class="absolute top-6 left-6">
<span class="px-4 py-2 text-[10px] font-mono font-bold uppercase tracking-widest bg-brand-dark/80 border border-brand-accent/50 text-brand-accent backdrop-blur-sm">
{featuredPost.data.category}
</span>
</div>
<h4 class="text-xl lg:text-2xl font-bold text-white uppercase mb-3 group-hover:text-brand-accent transition-colors duration-300 leading-tight tracking-tight">
{post.data.title}
</h4>
<p class="text-slate-400 text-sm line-clamp-2 font-light leading-relaxed">
{post.data.description || ''}
</p>
<span class="mt-6 text-xs font-bold uppercase tracking-widest text-slate-500 group-hover:text-white transition-all duration-300 flex items-center gap-3">
Read Article
<span class="block w-6 h-[1px] bg-slate-600 group-hover:bg-brand-accent group-hover:w-10 transition-all duration-300"></span>
)}
<!-- Grid overlay effect -->
<div class="absolute inset-0 grid-overlay opacity-30 pointer-events-none"></div>
</a>
<!-- Content section -->
<div class="p-8 lg:p-12 flex flex-col justify-center">
<!-- Technical header -->
<div class="flex items-center gap-3 mb-6">
<span class="text-[10px] font-mono text-brand-accent uppercase tracking-widest">
<FormattedDate date={featuredPost.data.pubDate} />
</span>
<span class="h-px w-8 bg-white/20"></span>
<span class="text-[10px] font-mono text-slate-500 uppercase tracking-widest">
5 min read
</span>
</div>
</a>
</li>
))
}
</ul>
<!-- Title -->
<a href={`/blog/${featuredPost.id}/`}>
<h2 class="text-3xl lg:text-4xl xl:text-5xl font-bold text-white uppercase tracking-tight mb-6 group-hover:text-brand-accent transition-colors duration-300 leading-tight">
{featuredPost.data.title}
</h2>
</a>
<!-- Description -->
<p class="text-slate-400 text-base lg:text-lg font-light leading-relaxed mb-8 line-clamp-3">
{featuredPost.data.description}
</p>
<!-- Tags -->
{featuredPost.data.tags && featuredPost.data.tags.length > 0 && (
<div class="flex flex-wrap gap-2 mb-8">
{featuredPost.data.tags.slice(0, 5).map((tag: string) => (
<span class="px-3 py-1.5 text-[10px] font-mono uppercase border border-white/10 text-slate-500 group-hover:border-white/20 transition-colors">
{tag}
</span>
))}
</div>
)}
<!-- Read link -->
<div class="pt-6 border-t border-white/10">
<a
href={`/blog/${featuredPost.id}/`}
class="inline-flex items-center gap-4 text-xs font-bold uppercase tracking-widest text-white hover:text-brand-accent transition-all duration-300 group/link"
>
Read Full Article
<span class="block w-8 h-[1px] bg-white/30 group-hover/link:bg-brand-accent group-hover/link:w-12 transition-all duration-300"></span>
<svg
xmlns="http://www.w3.org/2000/svg"
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
class="group-hover/link:translate-x-1 transition-transform duration-300"
>
<path d="M5 12h14" />
<path d="m12 5 7 7-7 7" />
</svg>
</a>
</div>
</div>
</div>
</article>
</div>
)}
<!-- Editor's Picks Section -->
{editorPicks.length > 0 && (
<div class="mb-16 lg:mb-24">
<div class="flex items-center gap-4 mb-8">
<span class="text-[10px] font-mono text-slate-500 uppercase tracking-widest font-bold">
/// EDITOR'S PICKS
</span>
<span class="h-px flex-grow bg-white/10"></span>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6 stagger-children">
{editorPicks.map((post, index) => (
<div class={`animate-on-scroll slide-up stagger-${index + 1}`}>
<BlogCard
title={post.data.title}
description={post.data.description}
pubDate={post.data.pubDate}
heroImage={post.data.heroImage}
category={post.data.category}
tags={post.data.tags}
href={`/blog/${post.id}/`}
variant="compact"
/>
</div>
))}
</div>
</div>
)}
<!-- Divider -->
<div class="h-px divider-gradient mb-16 lg:mb-24"></div>
<!-- Latest Section with Filters -->
<div class="mb-16 lg:mb-24">
<div class="flex items-center gap-4 mb-8">
<span class="text-[10px] font-mono text-slate-500 uppercase tracking-widest font-bold">
/// LATEST TRANSMISSIONS
</span>
<span class="h-px flex-grow bg-white/10"></span>
</div>
<!-- Filters Component -->
<BlogFilters categories={categories} />
<!-- Posts Grid -->
<div data-posts-grid class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-x-6 gap-y-10">
{latestPosts.map((post, index) => (
<div
data-post
data-category={post.data.category || ''}
data-title={post.data.title}
data-description={post.data.description}
class={`animate-on-scroll slide-up stagger-${Math.min((index % 6) + 1, 6)}`}
>
<BlogCard
title={post.data.title}
description={post.data.description}
pubDate={post.data.pubDate}
heroImage={post.data.heroImage}
category={post.data.category}
tags={post.data.tags}
href={`/blog/${post.id}/`}
/>
</div>
))}
</div>
<!-- Empty state (hidden by default, shown via JS when no results) -->
<div id="no-results" class="hidden text-center py-20">
<div class="text-slate-500 font-mono text-sm uppercase tracking-widest mb-4">
/// NO MATCHING ARTICLES FOUND
</div>
<p class="text-slate-400 text-sm">
Try adjusting your search or filter criteria.
</p>
</div>
</div>
</section>
</BaseLayout>