Add new blog components for enhanced navigation and reading experience
- Introduced PostNavigation, ReadingProgress, RelatedPosts, and TableOfContents components to improve user navigation and engagement within blog posts. - Updated BlogPost layout to incorporate new components, providing a cohesive reading experience with navigation links to previous and next posts, a reading progress bar, and related articles. - Enhanced global CSS styles for better typography and layout consistency across blog components.
This commit is contained in:
parent
058655f23d
commit
6625112e2c
139
src/components/PostNavigation.astro
Normal file
139
src/components/PostNavigation.astro
Normal file
@ -0,0 +1,139 @@
|
||||
---
|
||||
import { Image } from 'astro:assets';
|
||||
import type { ImageMetadata } from 'astro';
|
||||
|
||||
interface NavPost {
|
||||
title: string;
|
||||
href: string;
|
||||
heroImage?: ImageMetadata;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
prevPost?: NavPost;
|
||||
nextPost?: NavPost;
|
||||
}
|
||||
|
||||
const { prevPost, nextPost } = Astro.props;
|
||||
---
|
||||
|
||||
{(prevPost || nextPost) && (
|
||||
<nav class="post-navigation mt-20 pt-12 border-t border-white/10" aria-label="Post navigation">
|
||||
<div class="flex items-center gap-4 mb-8">
|
||||
<span class="text-[10px] font-mono text-slate-500 uppercase tracking-widest font-bold">
|
||||
/// CONTINUE READING
|
||||
</span>
|
||||
<span class="h-px flex-grow bg-white/10"></span>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<!-- Previous Post -->
|
||||
{prevPost ? (
|
||||
<a
|
||||
href={prevPost.href}
|
||||
class="group relative flex items-center gap-6 p-6 border border-white/10 bg-white/[0.02] hover:border-brand-accent/40 hover:bg-white/[0.04] transition-all duration-500"
|
||||
>
|
||||
<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>
|
||||
|
||||
<!-- Arrow -->
|
||||
<div class="flex-shrink-0 w-10 h-10 flex items-center justify-center border border-white/10 group-hover:border-brand-accent/50 transition-colors">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
class="text-slate-500 group-hover:text-brand-accent transition-colors group-hover:-translate-x-1 transition-transform duration-300"
|
||||
>
|
||||
<path d="M19 12H5" />
|
||||
<path d="m12 19-7-7 7-7" />
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
<!-- Content -->
|
||||
<div class="flex-grow min-w-0">
|
||||
<span class="text-[10px] font-mono text-slate-500 uppercase tracking-widest mb-2 block">
|
||||
Previous
|
||||
</span>
|
||||
<h4 class="text-sm font-bold text-white uppercase tracking-tight truncate group-hover:text-brand-accent transition-colors">
|
||||
{prevPost.title}
|
||||
</h4>
|
||||
</div>
|
||||
|
||||
<!-- Thumbnail -->
|
||||
{prevPost.heroImage && (
|
||||
<div class="hidden sm:block flex-shrink-0 w-16 h-16 overflow-hidden border border-white/10">
|
||||
<Image
|
||||
src={prevPost.heroImage}
|
||||
alt=""
|
||||
width={64}
|
||||
height={64}
|
||||
class="w-full h-full object-cover group-hover:scale-110 transition-transform duration-500"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</a>
|
||||
) : (
|
||||
<div></div>
|
||||
)}
|
||||
|
||||
<!-- Next Post -->
|
||||
{nextPost ? (
|
||||
<a
|
||||
href={nextPost.href}
|
||||
class="group relative flex items-center gap-6 p-6 border border-white/10 bg-white/[0.02] hover:border-brand-accent/40 hover:bg-white/[0.04] transition-all duration-500"
|
||||
>
|
||||
<div class="absolute top-0 right-0 w-1 h-full bg-slate-700 opacity-50 group-hover:bg-brand-accent group-hover:opacity-100 transition-all duration-500"></div>
|
||||
|
||||
<!-- Thumbnail -->
|
||||
{nextPost.heroImage && (
|
||||
<div class="hidden sm:block flex-shrink-0 w-16 h-16 overflow-hidden border border-white/10">
|
||||
<Image
|
||||
src={nextPost.heroImage}
|
||||
alt=""
|
||||
width={64}
|
||||
height={64}
|
||||
class="w-full h-full object-cover group-hover:scale-110 transition-transform duration-500"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<!-- Content -->
|
||||
<div class="flex-grow min-w-0 text-right">
|
||||
<span class="text-[10px] font-mono text-slate-500 uppercase tracking-widest mb-2 block">
|
||||
Next
|
||||
</span>
|
||||
<h4 class="text-sm font-bold text-white uppercase tracking-tight truncate group-hover:text-brand-accent transition-colors">
|
||||
{nextPost.title}
|
||||
</h4>
|
||||
</div>
|
||||
|
||||
<!-- Arrow -->
|
||||
<div class="flex-shrink-0 w-10 h-10 flex items-center justify-center border border-white/10 group-hover:border-brand-accent/50 transition-colors">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
class="text-slate-500 group-hover:text-brand-accent transition-colors group-hover:translate-x-1 transition-transform duration-300"
|
||||
>
|
||||
<path d="M5 12h14" />
|
||||
<path d="m12 5 7 7-7 7" />
|
||||
</svg>
|
||||
</div>
|
||||
</a>
|
||||
) : (
|
||||
<div></div>
|
||||
)}
|
||||
</div>
|
||||
</nav>
|
||||
)}
|
||||
|
||||
50
src/components/ReadingProgress.astro
Normal file
50
src/components/ReadingProgress.astro
Normal file
@ -0,0 +1,50 @@
|
||||
---
|
||||
// Reading progress bar that tracks scroll position
|
||||
---
|
||||
|
||||
<div id="reading-progress-container" class="fixed top-0 left-0 w-full h-[3px] z-[100] bg-brand-dark/50">
|
||||
<div id="reading-progress-bar" class="h-full bg-brand-accent w-0 transition-[width] duration-100 ease-out"></div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
function initReadingProgress() {
|
||||
const progressBar = document.getElementById('reading-progress-bar');
|
||||
if (!progressBar) return;
|
||||
|
||||
function updateProgress() {
|
||||
const article = document.querySelector('article');
|
||||
if (!article) return;
|
||||
|
||||
const articleRect = article.getBoundingClientRect();
|
||||
const articleTop = window.scrollY + articleRect.top;
|
||||
const articleHeight = article.offsetHeight;
|
||||
const windowHeight = window.innerHeight;
|
||||
const scrollY = window.scrollY;
|
||||
|
||||
// Calculate progress based on article position
|
||||
const start = articleTop;
|
||||
const end = articleTop + articleHeight - windowHeight;
|
||||
const current = scrollY;
|
||||
|
||||
let progress = 0;
|
||||
if (current >= start && current <= end) {
|
||||
progress = ((current - start) / (end - start)) * 100;
|
||||
} else if (current > end) {
|
||||
progress = 100;
|
||||
}
|
||||
|
||||
progressBar.style.width = `${Math.min(100, Math.max(0, progress))}%`;
|
||||
}
|
||||
|
||||
window.addEventListener('scroll', updateProgress, { passive: true });
|
||||
window.addEventListener('resize', updateProgress, { passive: true });
|
||||
updateProgress();
|
||||
}
|
||||
|
||||
// Initialize on page load
|
||||
initReadingProgress();
|
||||
|
||||
// Re-initialize on Astro page transitions
|
||||
document.addEventListener('astro:page-load', initReadingProgress);
|
||||
</script>
|
||||
|
||||
48
src/components/RelatedPosts.astro
Normal file
48
src/components/RelatedPosts.astro
Normal file
@ -0,0 +1,48 @@
|
||||
---
|
||||
import BlogCard from './BlogCard.astro';
|
||||
import type { ImageMetadata } from 'astro';
|
||||
|
||||
interface RelatedPost {
|
||||
title: string;
|
||||
description: string;
|
||||
pubDate: Date;
|
||||
heroImage?: ImageMetadata;
|
||||
category?: string;
|
||||
tags?: string[];
|
||||
href: string;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
posts: RelatedPost[];
|
||||
class?: string;
|
||||
}
|
||||
|
||||
const { posts, class: className = '' } = Astro.props;
|
||||
---
|
||||
|
||||
{posts.length > 0 && (
|
||||
<section class:list={['related-posts mt-20 pt-12 border-t border-white/10', className]}>
|
||||
<div class="flex items-center gap-4 mb-8">
|
||||
<span class="text-[10px] font-mono text-slate-500 uppercase tracking-widest font-bold">
|
||||
/// RELATED ARTICLES
|
||||
</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">
|
||||
{posts.slice(0, 3).map((post) => (
|
||||
<BlogCard
|
||||
title={post.title}
|
||||
description={post.description}
|
||||
pubDate={post.pubDate}
|
||||
heroImage={post.heroImage}
|
||||
category={post.category}
|
||||
tags={post.tags}
|
||||
href={post.href}
|
||||
variant="compact"
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
121
src/components/TableOfContents.astro
Normal file
121
src/components/TableOfContents.astro
Normal file
@ -0,0 +1,121 @@
|
||||
---
|
||||
interface Props {
|
||||
headings: Array<{
|
||||
depth: number;
|
||||
slug: string;
|
||||
text: string;
|
||||
}>;
|
||||
class?: string;
|
||||
}
|
||||
|
||||
const { headings, class: className = '' } = Astro.props;
|
||||
|
||||
// Filter to only H2 and H3 headings
|
||||
const tocHeadings = headings.filter((h) => h.depth === 2 || h.depth === 3);
|
||||
---
|
||||
|
||||
{tocHeadings.length > 0 && (
|
||||
<nav class:list={['toc', className]} data-toc aria-label="Table of contents">
|
||||
<div class="flex items-center gap-3 mb-6">
|
||||
<span class="text-[10px] font-mono text-slate-500 uppercase tracking-widest font-bold">
|
||||
/// CONTENTS
|
||||
</span>
|
||||
<span class="h-px flex-grow bg-white/10"></span>
|
||||
</div>
|
||||
<ul class="space-y-3">
|
||||
{tocHeadings.map((heading) => (
|
||||
<li>
|
||||
<a
|
||||
href={`#${heading.slug}`}
|
||||
data-toc-link={heading.slug}
|
||||
class:list={[
|
||||
'toc-link block text-sm transition-all duration-300 hover:text-white',
|
||||
heading.depth === 2
|
||||
? 'text-slate-400 font-medium'
|
||||
: 'text-slate-500 pl-4 text-xs',
|
||||
]}
|
||||
>
|
||||
<span class="flex items-center gap-2">
|
||||
{heading.depth === 2 && (
|
||||
<span class="w-1.5 h-1.5 bg-slate-600 rounded-full toc-indicator transition-colors duration-300"></span>
|
||||
)}
|
||||
{heading.text}
|
||||
</span>
|
||||
</a>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</nav>
|
||||
)}
|
||||
|
||||
<script>
|
||||
function initTableOfContents() {
|
||||
const tocLinks = document.querySelectorAll('[data-toc-link]');
|
||||
if (tocLinks.length === 0) return;
|
||||
|
||||
const headings = Array.from(tocLinks).map((link) => {
|
||||
const slug = (link as HTMLElement).dataset.tocLink;
|
||||
return document.getElementById(slug || '');
|
||||
}).filter(Boolean) as HTMLElement[];
|
||||
|
||||
let currentActive: Element | null = null;
|
||||
|
||||
function updateActiveLink() {
|
||||
const scrollY = window.scrollY;
|
||||
const offset = 150; // Offset for when to activate
|
||||
|
||||
let activeHeading: HTMLElement | null = null;
|
||||
|
||||
for (const heading of headings) {
|
||||
const rect = heading.getBoundingClientRect();
|
||||
const top = rect.top + scrollY;
|
||||
|
||||
if (scrollY >= top - offset) {
|
||||
activeHeading = heading;
|
||||
}
|
||||
}
|
||||
|
||||
if (activeHeading && currentActive !== activeHeading) {
|
||||
// Remove active state from all links
|
||||
tocLinks.forEach((link) => {
|
||||
link.classList.remove('text-brand-accent', 'text-white');
|
||||
link.classList.add('text-slate-400');
|
||||
const indicator = link.querySelector('.toc-indicator');
|
||||
if (indicator) {
|
||||
indicator.classList.remove('bg-brand-accent');
|
||||
indicator.classList.add('bg-slate-600');
|
||||
}
|
||||
});
|
||||
|
||||
// Add active state to current link
|
||||
const activeLink = document.querySelector(`[data-toc-link="${activeHeading.id}"]`);
|
||||
if (activeLink) {
|
||||
activeLink.classList.remove('text-slate-400');
|
||||
activeLink.classList.add('text-brand-accent');
|
||||
const indicator = activeLink.querySelector('.toc-indicator');
|
||||
if (indicator) {
|
||||
indicator.classList.remove('bg-slate-600');
|
||||
indicator.classList.add('bg-brand-accent');
|
||||
}
|
||||
}
|
||||
|
||||
currentActive = activeHeading;
|
||||
}
|
||||
}
|
||||
|
||||
window.addEventListener('scroll', updateActiveLink, { passive: true });
|
||||
updateActiveLink();
|
||||
}
|
||||
|
||||
// Initialize on page load
|
||||
initTableOfContents();
|
||||
|
||||
// Re-initialize on Astro page transitions
|
||||
document.addEventListener('astro:page-load', initTableOfContents);
|
||||
</script>
|
||||
|
||||
<style>
|
||||
.toc-link:hover .toc-indicator {
|
||||
background-color: var(--color-brand-accent);
|
||||
}
|
||||
</style>
|
||||
@ -1,51 +1,230 @@
|
||||
---
|
||||
import type { CollectionEntry } from 'astro:content';
|
||||
import type { ImageMetadata } from 'astro';
|
||||
import BaseLayout from './BaseLayout.astro';
|
||||
import FormattedDate from '../components/FormattedDate.astro';
|
||||
import ReadingProgress from '../components/ReadingProgress.astro';
|
||||
import TableOfContents from '../components/TableOfContents.astro';
|
||||
import PostNavigation from '../components/PostNavigation.astro';
|
||||
import RelatedPosts from '../components/RelatedPosts.astro';
|
||||
import { Image } from 'astro:assets';
|
||||
|
||||
type Props = CollectionEntry<'blog'>['data'];
|
||||
interface NavPost {
|
||||
title: string;
|
||||
href: string;
|
||||
heroImage?: ImageMetadata;
|
||||
}
|
||||
|
||||
const { title, description, pubDate, updatedDate, heroImage } = Astro.props;
|
||||
interface RelatedPost {
|
||||
title: string;
|
||||
description: string;
|
||||
pubDate: Date;
|
||||
heroImage?: ImageMetadata;
|
||||
category?: string;
|
||||
tags?: string[];
|
||||
href: string;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
title: string;
|
||||
description: string;
|
||||
pubDate: Date;
|
||||
updatedDate?: Date;
|
||||
heroImage?: ImageMetadata;
|
||||
category?: string;
|
||||
tags?: string[];
|
||||
headings?: Array<{ depth: number; slug: string; text: string }>;
|
||||
prevPost?: NavPost;
|
||||
nextPost?: NavPost;
|
||||
relatedPosts?: RelatedPost[];
|
||||
}
|
||||
|
||||
const {
|
||||
title,
|
||||
description,
|
||||
pubDate,
|
||||
updatedDate,
|
||||
heroImage,
|
||||
category,
|
||||
tags,
|
||||
headings = [],
|
||||
prevPost,
|
||||
nextPost,
|
||||
relatedPosts = [],
|
||||
} = Astro.props;
|
||||
|
||||
// Estimated read time (rough calculation)
|
||||
const readTime = '5 min read';
|
||||
---
|
||||
|
||||
<BaseLayout title={title} description={description}>
|
||||
<article class="container mx-auto px-6 lg:px-12 max-w-4xl">
|
||||
<!-- Back Navigation -->
|
||||
<a href="/blog" 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">
|
||||
<span class="w-8 h-[1px] bg-slate-600 group-hover:bg-brand-accent group-hover:w-12 transition-all duration-300"></span>
|
||||
Back to Blog
|
||||
</a>
|
||||
<BaseLayout title={title} description={description} image={heroImage}>
|
||||
<ReadingProgress />
|
||||
|
||||
<article class="relative pb-24">
|
||||
<!-- All content in same grid structure for consistent width -->
|
||||
<div class="container mx-auto px-6 lg:px-12">
|
||||
<div class="grid grid-cols-1 lg:grid-cols-12 gap-8 lg:gap-12">
|
||||
<!-- Main Column -->
|
||||
<div class="lg:col-span-8 lg:col-start-3">
|
||||
<!-- Back Navigation -->
|
||||
<div class="mb-8">
|
||||
<a href="/blog" class="inline-flex items-center gap-3 text-xs font-semibold uppercase tracking-widest text-slate-500 hover:text-white transition-colors duration-300 group">
|
||||
<span class="w-8 h-[1px] bg-slate-600 group-hover:bg-brand-accent group-hover:w-12 transition-all duration-300"></span>
|
||||
Back to Blog
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div class="mb-12 animate-on-scroll slide-up">
|
||||
<div class="mb-8">
|
||||
{heroImage && <Image width={1020} height={510} src={heroImage} alt="" class="w-full h-auto border border-white/10" />}
|
||||
</div>
|
||||
<div class="border-b border-white/10 pb-10 mb-10">
|
||||
<div class="flex items-center gap-4 text-xs font-mono text-slate-500 uppercase tracking-widest mb-6">
|
||||
<FormattedDate date={pubDate} />
|
||||
{
|
||||
updatedDate && (
|
||||
<span class="text-slate-600">
|
||||
(Updated: <FormattedDate date={updatedDate} />)
|
||||
</span>
|
||||
)
|
||||
}
|
||||
<!-- Hero Section: Side-by-Side Layout -->
|
||||
<header class="mb-16 lg:mb-20">
|
||||
<div class="grid grid-cols-1 lg:grid-cols-2 gap-8 items-start">
|
||||
<!-- Text Content -->
|
||||
<div class="order-2 lg:order-1 animate-on-scroll slide-up">
|
||||
<!-- Metadata -->
|
||||
<div class="flex flex-wrap items-center gap-3 text-[10px] font-mono text-slate-500 uppercase tracking-widest mb-5">
|
||||
<div class="flex items-center gap-2">
|
||||
<div class="w-2 h-2 bg-brand-accent rounded-full animate-pulse"></div>
|
||||
<span class="text-brand-accent font-bold">SYS.ARTICLE</span>
|
||||
</div>
|
||||
<span class="h-px w-4 bg-white/20"></span>
|
||||
<FormattedDate date={pubDate} />
|
||||
<span class="h-px w-4 bg-white/20"></span>
|
||||
<span>{readTime}</span>
|
||||
</div>
|
||||
|
||||
{category && (
|
||||
<div class="mb-4">
|
||||
<span class="px-3 py-1.5 text-[10px] font-mono font-bold uppercase tracking-widest bg-brand-accent/10 border border-brand-accent/30 text-brand-accent">
|
||||
{category}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<h1 class="text-2xl md:text-3xl lg:text-4xl font-bold text-white uppercase leading-[0.95] tracking-tighter mb-4">
|
||||
{title}
|
||||
</h1>
|
||||
|
||||
<p class="text-sm lg:text-base text-slate-400 leading-relaxed font-light mb-5 border-l border-white/10 pl-4">
|
||||
{description}
|
||||
</p>
|
||||
|
||||
<!-- Tags -->
|
||||
{tags && tags.length > 0 && (
|
||||
<div class="flex flex-wrap gap-2">
|
||||
{tags.map((tag) => (
|
||||
<span class="px-2 py-1 text-[9px] font-mono uppercase border border-white/10 text-slate-500 hover:border-white/20 transition-colors">
|
||||
{tag}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<!-- Hero Image -->
|
||||
{heroImage && (
|
||||
<div class="order-1 lg:order-2 animate-on-scroll fade-in">
|
||||
<div class="relative aspect-[4/3] overflow-hidden border border-white/10 bg-white/[0.02]">
|
||||
<Image
|
||||
src={heroImage}
|
||||
alt=""
|
||||
width={600}
|
||||
height={450}
|
||||
class="w-full h-full object-cover"
|
||||
/>
|
||||
<!-- Subtle overlay -->
|
||||
<div class="absolute inset-0 bg-brand-dark/10"></div>
|
||||
<div class="absolute inset-0 grid-overlay opacity-20 pointer-events-none"></div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- Main Content -->
|
||||
<div class="prose-custom animate-on-scroll slide-up stagger-1">
|
||||
<slot />
|
||||
</div>
|
||||
|
||||
<!-- Author Footer -->
|
||||
<footer class="mt-24 pt-10 border-t border-white/10 animate-on-scroll fade-in">
|
||||
<div class="flex flex-col md:flex-row md:items-center md:justify-between gap-6">
|
||||
<div>
|
||||
<p class="text-[10px] font-mono text-slate-500 uppercase tracking-widest mb-2">
|
||||
/// END TRANSMISSION
|
||||
</p>
|
||||
<p class="text-slate-400 text-sm">
|
||||
Published <FormattedDate date={pubDate} />
|
||||
{updatedDate && (
|
||||
<span class="text-slate-500"> · Last updated <FormattedDate date={updatedDate} /></span>
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Share Links -->
|
||||
<div class="flex items-center gap-4">
|
||||
<span class="text-[10px] font-mono text-slate-500 uppercase tracking-widest">Share</span>
|
||||
<div class="flex items-center gap-2">
|
||||
<a
|
||||
href={`https://twitter.com/intent/tweet?text=${encodeURIComponent(title)}&url=${encodeURIComponent(Astro.url.href)}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="w-10 h-10 flex items-center justify-center border border-white/10 text-slate-400 hover:border-brand-accent hover:text-brand-accent transition-all duration-300"
|
||||
aria-label="Share on Twitter"
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M22 4s-.7 2.1-2 3.4c1.6 10-9.4 17.3-18 11.6 2.2.1 4.4-.6 6-2C3 15.5.5 9.6 3 5c2.2 2.6 5.6 4.1 9 4-.9-4.2 4-6.6 7-3.8 1.1 0 3-1.2 3-1.2z"/>
|
||||
</svg>
|
||||
</a>
|
||||
<a
|
||||
href={`https://www.linkedin.com/shareArticle?mini=true&url=${encodeURIComponent(Astro.url.href)}&title=${encodeURIComponent(title)}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="w-10 h-10 flex items-center justify-center border border-white/10 text-slate-400 hover:border-brand-accent hover:text-brand-accent transition-all duration-300"
|
||||
aria-label="Share on LinkedIn"
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M16 8a6 6 0 0 1 6 6v7h-4v-7a2 2 0 0 0-2-2 2 2 0 0 0-2 2v7h-4v-7a6 6 0 0 1 6-6z"/>
|
||||
<rect width="4" height="12" x="2" y="9"/>
|
||||
<circle cx="4" cy="4" r="2"/>
|
||||
</svg>
|
||||
</a>
|
||||
<button
|
||||
type="button"
|
||||
onclick="navigator.clipboard.writeText(window.location.href)"
|
||||
class="w-10 h-10 flex items-center justify-center border border-white/10 text-slate-400 hover:border-brand-accent hover:text-brand-accent transition-all duration-300"
|
||||
aria-label="Copy link"
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"/>
|
||||
<path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
|
||||
<!-- Post Navigation -->
|
||||
<PostNavigation prevPost={prevPost} nextPost={nextPost} />
|
||||
|
||||
<!-- Related Posts -->
|
||||
<RelatedPosts posts={relatedPosts} />
|
||||
|
||||
<!-- Back to Blog -->
|
||||
<div class="mt-20 pt-10 border-t border-white/10">
|
||||
<a href="/blog" class="inline-flex items-center gap-3 text-xs font-semibold uppercase tracking-widest text-slate-500 hover:text-white transition-colors duration-300 group">
|
||||
<span class="w-8 h-[1px] bg-slate-600 group-hover:bg-brand-accent group-hover:w-12 transition-all duration-300"></span>
|
||||
Back to All Posts
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
<h1 class="text-3xl md:text-5xl lg:text-6xl font-bold text-white uppercase leading-tight tracking-tight mb-6">{title}</h1>
|
||||
<p class="text-lg lg:text-xl text-slate-400 leading-relaxed font-light">{description}</p>
|
||||
|
||||
<!-- Table of Contents Sidebar (Desktop) -->
|
||||
<aside class="hidden lg:block lg:col-span-2 lg:col-start-11">
|
||||
<div class="sticky top-24 mt-32">
|
||||
<TableOfContents headings={headings} />
|
||||
</div>
|
||||
</aside>
|
||||
</div>
|
||||
</div>
|
||||
<div class="prose-custom animate-on-scroll slide-up stagger-1">
|
||||
<slot />
|
||||
</div>
|
||||
|
||||
<!-- Bottom Navigation -->
|
||||
<div class="mt-20 pt-10 border-t border-white/10">
|
||||
<a href="/blog" class="inline-flex items-center gap-3 text-xs font-semibold uppercase tracking-widest text-slate-500 hover:text-white transition-colors duration-300 group">
|
||||
<span class="w-8 h-[1px] bg-slate-600 group-hover:bg-brand-accent group-hover:w-12 transition-all duration-300"></span>
|
||||
Back to All Posts
|
||||
</a>
|
||||
</div>
|
||||
</article>
|
||||
</BaseLayout>
|
||||
|
||||
@ -4,17 +4,99 @@ import BlogPost from '../../layouts/BlogPost.astro';
|
||||
|
||||
export async function getStaticPaths() {
|
||||
const posts = await getCollection('blog');
|
||||
return posts.map((post) => ({
|
||||
params: { slug: post.id },
|
||||
props: post,
|
||||
}));
|
||||
|
||||
// Sort posts by date (newest first)
|
||||
const sortedPosts = posts.sort(
|
||||
(a, b) => b.data.pubDate.valueOf() - a.data.pubDate.valueOf()
|
||||
);
|
||||
|
||||
return sortedPosts.map((post, index) => {
|
||||
// Get previous and next posts
|
||||
const prevPost = index < sortedPosts.length - 1 ? sortedPosts[index + 1] : undefined;
|
||||
const nextPost = index > 0 ? sortedPosts[index - 1] : undefined;
|
||||
|
||||
// Find related posts (same category or shared tags)
|
||||
const relatedPosts = sortedPosts
|
||||
.filter((p) => p.id !== post.id)
|
||||
.filter((p) => {
|
||||
// Match by category
|
||||
if (post.data.category && p.data.category === post.data.category) {
|
||||
return true;
|
||||
}
|
||||
// Match by shared tags
|
||||
if (post.data.tags && p.data.tags) {
|
||||
const sharedTags = post.data.tags.filter((tag) => p.data.tags?.includes(tag));
|
||||
return sharedTags.length > 0;
|
||||
}
|
||||
return false;
|
||||
})
|
||||
.slice(0, 3);
|
||||
|
||||
return {
|
||||
params: { slug: post.id },
|
||||
props: {
|
||||
post,
|
||||
prevPost: prevPost
|
||||
? {
|
||||
title: prevPost.data.title,
|
||||
href: `/blog/${prevPost.id}/`,
|
||||
heroImage: prevPost.data.heroImage,
|
||||
}
|
||||
: undefined,
|
||||
nextPost: nextPost
|
||||
? {
|
||||
title: nextPost.data.title,
|
||||
href: `/blog/${nextPost.id}/`,
|
||||
heroImage: nextPost.data.heroImage,
|
||||
}
|
||||
: undefined,
|
||||
relatedPosts: relatedPosts.map((p) => ({
|
||||
title: p.data.title,
|
||||
description: p.data.description,
|
||||
pubDate: p.data.pubDate,
|
||||
heroImage: p.data.heroImage,
|
||||
category: p.data.category,
|
||||
tags: p.data.tags,
|
||||
href: `/blog/${p.id}/`,
|
||||
})),
|
||||
},
|
||||
};
|
||||
});
|
||||
}
|
||||
type Props = CollectionEntry<'blog'>;
|
||||
|
||||
const post = Astro.props;
|
||||
const { Content } = await render(post);
|
||||
interface Props {
|
||||
post: CollectionEntry<'blog'>;
|
||||
prevPost?: {
|
||||
title: string;
|
||||
href: string;
|
||||
heroImage?: any;
|
||||
};
|
||||
nextPost?: {
|
||||
title: string;
|
||||
href: string;
|
||||
heroImage?: any;
|
||||
};
|
||||
relatedPosts: Array<{
|
||||
title: string;
|
||||
description: string;
|
||||
pubDate: Date;
|
||||
heroImage?: any;
|
||||
category?: string;
|
||||
tags?: string[];
|
||||
href: string;
|
||||
}>;
|
||||
}
|
||||
|
||||
const { post, prevPost, nextPost, relatedPosts } = Astro.props;
|
||||
const { Content, headings } = await render(post);
|
||||
---
|
||||
|
||||
<BlogPost {...post.data}>
|
||||
<BlogPost
|
||||
{...post.data}
|
||||
headings={headings}
|
||||
prevPost={prevPost}
|
||||
nextPost={nextPost}
|
||||
relatedPosts={relatedPosts}
|
||||
>
|
||||
<Content />
|
||||
</BlogPost>
|
||||
|
||||
@ -318,6 +318,7 @@ a {
|
||||
.prose-custom {
|
||||
color: #94A3B8;
|
||||
line-height: 1.8;
|
||||
font-size: 1.0625rem;
|
||||
}
|
||||
|
||||
.prose-custom h2 {
|
||||
@ -326,10 +327,20 @@ a {
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: -0.025em;
|
||||
margin-top: 3rem;
|
||||
margin-top: 3.5rem;
|
||||
margin-bottom: 1.25rem;
|
||||
padding-bottom: 0.75rem;
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
|
||||
position: relative;
|
||||
scroll-margin-top: 6rem;
|
||||
}
|
||||
|
||||
.prose-custom h2::before {
|
||||
content: "//";
|
||||
color: var(--color-brand-accent);
|
||||
margin-right: 0.5rem;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.9em;
|
||||
}
|
||||
|
||||
.prose-custom h3 {
|
||||
@ -338,8 +349,9 @@ a {
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: -0.015em;
|
||||
margin-top: 2rem;
|
||||
margin-top: 2.5rem;
|
||||
margin-bottom: 1rem;
|
||||
scroll-margin-top: 6rem;
|
||||
}
|
||||
|
||||
.prose-custom h4 {
|
||||
@ -348,6 +360,7 @@ a {
|
||||
font-weight: 600;
|
||||
margin-top: 1.5rem;
|
||||
margin-bottom: 0.75rem;
|
||||
scroll-margin-top: 6rem;
|
||||
}
|
||||
|
||||
.prose-custom p {
|
||||
@ -358,11 +371,12 @@ a {
|
||||
color: var(--color-brand-accent);
|
||||
text-decoration: none;
|
||||
transition: color 0.3s ease;
|
||||
border-bottom: 1px solid transparent;
|
||||
}
|
||||
|
||||
.prose-custom a:hover {
|
||||
color: #ffffff;
|
||||
text-decoration: underline;
|
||||
border-bottom-color: var(--color-brand-accent);
|
||||
}
|
||||
|
||||
.prose-custom strong {
|
||||
@ -383,7 +397,7 @@ a {
|
||||
|
||||
.prose-custom ul li {
|
||||
position: relative;
|
||||
padding-left: 1.5rem;
|
||||
padding-left: 1.75rem;
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
@ -392,69 +406,223 @@ a {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
color: var(--color-brand-accent);
|
||||
font-size: 0.85em;
|
||||
}
|
||||
|
||||
.prose-custom ol {
|
||||
list-style: decimal;
|
||||
padding-left: 1.5rem;
|
||||
list-style: none;
|
||||
padding-left: 0;
|
||||
margin-bottom: 1.5rem;
|
||||
counter-reset: ol-counter;
|
||||
}
|
||||
|
||||
.prose-custom ol li {
|
||||
margin-bottom: 0.75rem;
|
||||
padding-left: 0.5rem;
|
||||
padding-left: 2.5rem;
|
||||
position: relative;
|
||||
counter-increment: ol-counter;
|
||||
}
|
||||
|
||||
.prose-custom ol li::marker {
|
||||
.prose-custom ol li::before {
|
||||
content: counter(ol-counter, decimal-leading-zero);
|
||||
position: absolute;
|
||||
left: 0;
|
||||
color: var(--color-brand-accent);
|
||||
font-weight: 600;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.75rem;
|
||||
font-weight: 700;
|
||||
width: 1.75rem;
|
||||
}
|
||||
|
||||
/* Enhanced Blockquotes - Terminal/Industrial Style */
|
||||
.prose-custom blockquote {
|
||||
position: relative;
|
||||
border-left: 3px solid var(--color-brand-accent);
|
||||
padding-left: 1.5rem;
|
||||
margin: 2rem 0;
|
||||
background: linear-gradient(135deg, rgba(255, 77, 0, 0.05), rgba(21, 25, 33, 0.8));
|
||||
padding: 1.5rem 1.5rem 1.5rem 2rem;
|
||||
margin: 2.5rem 0;
|
||||
font-style: italic;
|
||||
color: #CBD5E1;
|
||||
border-right: 1px solid rgba(255, 255, 255, 0.05);
|
||||
border-top: 1px solid rgba(255, 255, 255, 0.05);
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.05);
|
||||
}
|
||||
|
||||
.prose-custom blockquote::before {
|
||||
content: "///";
|
||||
position: absolute;
|
||||
top: -0.75rem;
|
||||
left: 1rem;
|
||||
background: var(--color-brand-dark);
|
||||
padding: 0 0.5rem;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.625rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.1em;
|
||||
color: var(--color-brand-accent);
|
||||
font-style: normal;
|
||||
}
|
||||
|
||||
.prose-custom blockquote p {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.prose-custom blockquote p:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
/* Enhanced Code - Inline */
|
||||
.prose-custom code {
|
||||
color: var(--color-brand-accent);
|
||||
background-color: rgba(255, 77, 0, 0.1);
|
||||
padding: 0.2rem 0.4rem;
|
||||
border-radius: 2px;
|
||||
padding: 0.2rem 0.5rem;
|
||||
border-radius: 0;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.9em;
|
||||
font-size: 0.85em;
|
||||
border: 1px solid rgba(255, 77, 0, 0.2);
|
||||
}
|
||||
|
||||
/* Enhanced Code Blocks - Terminal Style */
|
||||
.prose-custom pre {
|
||||
position: relative;
|
||||
background-color: var(--color-brand-panel);
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
padding: 1.5rem;
|
||||
margin: 2rem 0;
|
||||
overflow-x: auto;
|
||||
padding: 0;
|
||||
margin: 2.5rem 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.prose-custom pre::before {
|
||||
content: "TERMINAL";
|
||||
display: block;
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
|
||||
padding: 0.75rem 1rem;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.625rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.15em;
|
||||
color: #64748B;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.prose-custom pre code {
|
||||
display: block;
|
||||
background: none;
|
||||
padding: 0;
|
||||
padding: 1.5rem;
|
||||
color: #CBD5E1;
|
||||
border: none;
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
/* Enhanced Horizontal Rules - Section Dividers */
|
||||
.prose-custom hr {
|
||||
border: none;
|
||||
height: 1px;
|
||||
background: linear-gradient(
|
||||
to right,
|
||||
transparent,
|
||||
rgba(255, 255, 255, 0.2) 20%,
|
||||
rgba(255, 255, 255, 0.2) 80%,
|
||||
transparent
|
||||
);
|
||||
margin: 3rem 0;
|
||||
height: auto;
|
||||
margin: 4rem 0;
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.prose-custom hr::before {
|
||||
content: "";
|
||||
flex: 1;
|
||||
height: 1px;
|
||||
background: linear-gradient(to right, transparent, rgba(255, 77, 0, 0.3));
|
||||
}
|
||||
|
||||
.prose-custom hr::after {
|
||||
content: "";
|
||||
flex: 1;
|
||||
height: 1px;
|
||||
background: linear-gradient(to left, transparent, rgba(255, 77, 0, 0.3));
|
||||
}
|
||||
|
||||
/* Enhanced Images */
|
||||
.prose-custom img {
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
margin: 2rem 0;
|
||||
margin: 2.5rem 0;
|
||||
transition: border-color 0.3s ease;
|
||||
}
|
||||
|
||||
.prose-custom img:hover {
|
||||
border-color: rgba(255, 77, 0, 0.3);
|
||||
}
|
||||
|
||||
/* Image Captions (for figures) */
|
||||
.prose-custom figure {
|
||||
margin: 2.5rem 0;
|
||||
}
|
||||
|
||||
.prose-custom figure img {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.prose-custom figcaption {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.6875rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.1em;
|
||||
color: #64748B;
|
||||
margin-top: 0.75rem;
|
||||
padding-left: 0.5rem;
|
||||
border-left: 2px solid var(--color-brand-accent);
|
||||
}
|
||||
|
||||
/* Video containers */
|
||||
.prose-custom .video-container {
|
||||
margin: 2.5rem 0;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.prose-custom .video-container video {
|
||||
width: 100%;
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
.prose-custom .video-container p {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.6875rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.1em;
|
||||
color: #64748B;
|
||||
margin-top: 0.75rem;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
/* Tables */
|
||||
.prose-custom table {
|
||||
width: 100%;
|
||||
margin: 2.5rem 0;
|
||||
border-collapse: collapse;
|
||||
font-size: 0.9375rem;
|
||||
}
|
||||
|
||||
.prose-custom thead {
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
.prose-custom th {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.625rem;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.1em;
|
||||
color: #64748B;
|
||||
padding: 1rem;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.prose-custom td {
|
||||
padding: 1rem;
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.05);
|
||||
color: #94A3B8;
|
||||
}
|
||||
|
||||
.prose-custom tr:hover td {
|
||||
background: rgba(255, 255, 255, 0.02);
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user