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:
parent
27da6ad1c9
commit
058655f23d
1
dev/blog-example.html
Normal file
1
dev/blog-example.html
Normal file
File diff suppressed because one or more lines are too long
148
src/components/BlogCard.astro
Normal file
148
src/components/BlogCard.astro
Normal 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>
|
||||
|
||||
202
src/components/BlogFilters.astro
Normal file
202
src/components/BlogFilters.astro
Normal 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>
|
||||
|
||||
@ -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(),
|
||||
}),
|
||||
});
|
||||
|
||||
|
||||
@ -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.
|
||||
|
||||
@ -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>
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user