645 lines
17 KiB
Plaintext
645 lines
17 KiB
Plaintext
---
|
|
import BaseLayout from './BaseLayout.astro';
|
|
import Header from '../components/Header.astro';
|
|
import Footer from '../components/Footer.astro';
|
|
// import MiniKnowledgeGraph from '../components/MiniKnowledgeGraph.astro'; // Keep if needed elsewhere, but remove from main content
|
|
import MiniGraph from '../components/MiniGraph.astro'; // Add import for MiniGraph
|
|
import { getCollection } from 'astro:content';
|
|
|
|
interface Props {
|
|
frontmatter: {
|
|
title: string;
|
|
description?: string;
|
|
pubDate: Date;
|
|
updatedDate?: Date;
|
|
heroImage?: string;
|
|
category?: string;
|
|
tags?: string[];
|
|
readTime?: string;
|
|
draft?: boolean;
|
|
author?: string;
|
|
// Field for explicitly related posts
|
|
related_posts?: string[];
|
|
},
|
|
slug: string // Add slug to props
|
|
}
|
|
|
|
const { frontmatter, slug } = Astro.props;
|
|
|
|
// Get all posts for finding related content
|
|
const allPosts = await getCollection('posts');
|
|
|
|
// Create a currentPost object that matches the structure expected by MiniKnowledgeGraph
|
|
const currentPost = {
|
|
slug: slug,
|
|
data: frontmatter
|
|
};
|
|
|
|
// Find related posts - first from explicitly defined related_posts
|
|
const explicitRelatedPosts = frontmatter.related_posts
|
|
? allPosts.filter(post =>
|
|
frontmatter.related_posts?.includes(post.slug) &&
|
|
post.slug !== slug
|
|
)
|
|
: [];
|
|
|
|
// Then find posts with shared tags (if we need more related posts)
|
|
const MAX_RELATED_POSTS = 3;
|
|
let relatedPostsByTags = [];
|
|
|
|
if (explicitRelatedPosts.length < MAX_RELATED_POSTS && frontmatter.tags && frontmatter.tags.length > 0) {
|
|
// Create a map of posts by tags for efficient lookup
|
|
const postsByTag = new Map();
|
|
frontmatter.tags.forEach(tag => {
|
|
postsByTag.set(tag, allPosts.filter(post =>
|
|
post.slug !== slug &&
|
|
post.data.tags?.includes(tag) &&
|
|
!explicitRelatedPosts.some(p => p.slug === post.slug)
|
|
));
|
|
});
|
|
|
|
// Score posts by number of shared tags
|
|
const scoredPosts = new Map();
|
|
|
|
postsByTag.forEach((posts, tag) => {
|
|
posts.forEach(post => {
|
|
const currentScore = scoredPosts.get(post.slug) || 0;
|
|
scoredPosts.set(post.slug, currentScore + 1);
|
|
});
|
|
});
|
|
|
|
// Convert to array, sort by score, and take what we need
|
|
relatedPostsByTags = Array.from(scoredPosts.entries())
|
|
.sort((a, b) => b[1] - a[1])
|
|
.slice(0, MAX_RELATED_POSTS - explicitRelatedPosts.length)
|
|
.map(([slug]) => allPosts.find(post => post.slug === slug))
|
|
.filter(Boolean); // Remove any undefined entries
|
|
}
|
|
|
|
// Combine explicit and tag-based related posts
|
|
const relatedPosts = [...explicitRelatedPosts, ...relatedPostsByTags];
|
|
|
|
// Format date
|
|
const formattedPubDate = frontmatter.pubDate ? new Date(frontmatter.pubDate).toLocaleDateString('en-us', {
|
|
year: 'numeric',
|
|
month: 'long',
|
|
day: 'numeric',
|
|
}) : 'N/A';
|
|
|
|
const formattedUpdatedDate = frontmatter.updatedDate ? new Date(frontmatter.updatedDate).toLocaleDateString('en-us', {
|
|
year: 'numeric',
|
|
month: 'long',
|
|
day: 'numeric',
|
|
}) : null;
|
|
|
|
// Default image if heroImage is missing
|
|
const displayImage = frontmatter.heroImage || '/images/placeholders/default.jpg';
|
|
---
|
|
|
|
<BaseLayout title={frontmatter.title} description={frontmatter.description} image={displayImage}>
|
|
<Header slot="header" />
|
|
|
|
<div class="blog-post-container">
|
|
<article class="blog-post">
|
|
<header class="blog-post-header">
|
|
{/* Display Draft Badge if needed */}
|
|
{frontmatter.draft && <span class="draft-badge mb-4">DRAFT</span>}
|
|
|
|
{/* Title */}
|
|
<h1 class="blog-post-title mb-2">{frontmatter.title}</h1>
|
|
|
|
{/* Description */}
|
|
{frontmatter.description && <p class="blog-post-description mb-4">{frontmatter.description}</p>}
|
|
|
|
{/* Metadata (Date, Read Time) */}
|
|
<div class="blog-post-meta mb-4">
|
|
<span class="blog-post-date">Published {formattedPubDate}</span>
|
|
{formattedUpdatedDate && (
|
|
<span class="blog-post-updated">(Updated {formattedUpdatedDate})</span>
|
|
)}
|
|
{frontmatter.readTime && <span class="blog-post-read-time">{frontmatter.readTime}</span>}
|
|
{frontmatter.category && <span class="blog-post-category">{frontmatter.category}</span>}
|
|
</div>
|
|
|
|
{/* Tags */}
|
|
{frontmatter.tags && frontmatter.tags.length > 0 && (
|
|
<div class="blog-post-tags">
|
|
{frontmatter.tags.map((tag) => (
|
|
<a href={`/tag/${tag}`} class="blog-post-tag">#{tag}</a>
|
|
))}
|
|
</div>
|
|
)}
|
|
</header>
|
|
|
|
{/* Display Hero Image */}
|
|
{displayImage && (
|
|
<div class="blog-post-hero">
|
|
<img src={displayImage.startsWith('/') ? displayImage : `/${displayImage}`} alt={frontmatter.title} width="1024" height="512" loading="lazy" />
|
|
</div>
|
|
)}
|
|
|
|
{/* Content Connections - Removed MiniKnowledgeGraph from here */}
|
|
{/* <div class="content-connections">
|
|
<h3 class="connections-title">Post Connections</h3>
|
|
<MiniKnowledgeGraph currentPost={currentPost} relatedPosts={relatedPosts} />
|
|
</div> */}
|
|
|
|
{/* Main Content Area */}
|
|
<div class="blog-post-content prose prose-invert max-w-none">
|
|
<slot /> {/* Renders the actual markdown content */}
|
|
</div>
|
|
|
|
{/* Related Posts Section */}
|
|
{relatedPosts.length > 0 && (
|
|
<div class="related-posts-section">
|
|
<h3 class="related-title">Related Content</h3>
|
|
<div class="related-posts-grid">
|
|
{relatedPosts.map((post) => (
|
|
<a href={`/posts/${post.slug}/`} class="related-post-card">
|
|
<div class="related-post-content">
|
|
<h4>{post.data.title}</h4>
|
|
<p>{post.data.description ?
|
|
(post.data.description.length > 100 ?
|
|
post.data.description.substring(0, 100) + '...' :
|
|
post.data.description) :
|
|
'Read more about this related topic.'}</p>
|
|
</div>
|
|
</a>
|
|
))}
|
|
</div>
|
|
</div>
|
|
)}
|
|
</article>
|
|
|
|
{/* Sidebar */}
|
|
<aside class="blog-post-sidebar">
|
|
{/* Author Card */}
|
|
<div class="sidebar-card author-card">
|
|
<div class="author-avatar">
|
|
<div class="avatar-placeholder">DL</div>
|
|
</div>
|
|
<div class="author-info">
|
|
<h3>Daniel LaForce</h3>
|
|
<p>Infrastructure & DevOps Engineer</p>
|
|
</div>
|
|
<p class="author-bio">
|
|
Exploring enterprise-grade infrastructure, automation, Kubernetes, and self-hosted solutions for the modern home lab.
|
|
</p>
|
|
<div class="author-links">
|
|
<a href="https://github.com/keyargo" target="_blank" rel="noopener noreferrer" class="author-link github">
|
|
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
|
<path d="M9 19c-5 1.5-5-2.5-7-3m14 6v-3.87a3.37 3.37 0 0 0-.94-2.61c3.14-.35 6.44-1.54 6.44-7A5.44 5.44 0 0 0 20 4.77 5.07 5.07 0 0 0 19.91 1S18.73.65 16 2.48a13.38 13.38 0 0 0-7 0C6.27.65 5.09 1 5.09 1A5.07 5.07 0 0 0 5 4.77a5.44 5.44 0 0 0-1.5 3.78c0 5.42 3.3 6.61 6.44 7A3.37 3.37 0 0 0 9 18.13V22"></path>
|
|
</svg>
|
|
GitHub
|
|
</a>
|
|
</div>
|
|
</div>
|
|
|
|
{/* MiniGraph Component - Placed after Author Card */}
|
|
<MiniGraph
|
|
slug={slug}
|
|
title={frontmatter.title}
|
|
tags={frontmatter.tags}
|
|
category={frontmatter.category}
|
|
/>
|
|
|
|
{/* Tags Section - Placed after MiniGraph */}
|
|
<div class="sidebar-card">
|
|
<h3 class="sidebar-title">Tags</h3>
|
|
<div class="tags">
|
|
{frontmatter.tags && frontmatter.tags.map(tag => (
|
|
<a href={`/tag/${tag}`} class="tag">{tag}</a>
|
|
))}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Table of Contents Card */}
|
|
<div class="sidebar-card toc-card">
|
|
<h3>Table of Contents</h3>
|
|
<nav class="toc-container" id="toc">
|
|
<p class="text-sm text-gray-400">Loading Table of Contents...</p>
|
|
</nav>
|
|
</div>
|
|
</aside>
|
|
</div>
|
|
|
|
<Footer slot="footer" />
|
|
</BaseLayout>
|
|
|
|
<script>
|
|
// Table of Contents Generator
|
|
document.addEventListener('DOMContentLoaded', () => {
|
|
const tocContainer = document.getElementById('toc');
|
|
const contentArea = document.querySelector('.blog-post-content');
|
|
|
|
if (!tocContainer || !contentArea) return;
|
|
|
|
// Get all headings (h2, h3) from the content
|
|
const headings = contentArea.querySelectorAll('h2, h3');
|
|
|
|
if (headings.length === 0) {
|
|
tocContainer.innerHTML = '<p class="toc-empty">No sections found in this article.</p>';
|
|
return;
|
|
}
|
|
|
|
// Create the TOC list
|
|
const tocList = document.createElement('ul');
|
|
tocList.className = 'toc-list';
|
|
|
|
headings.forEach((heading, index) => {
|
|
// Add ID to heading if it doesn't have one
|
|
if (!heading.id) {
|
|
heading.id = `heading-${index}`;
|
|
}
|
|
|
|
// Create list item
|
|
const listItem = document.createElement('li');
|
|
listItem.className = `toc-item toc-${heading.tagName.toLowerCase()}`;
|
|
|
|
// Create link
|
|
const link = document.createElement('a');
|
|
link.href = `#${heading.id}`;
|
|
link.textContent = heading.textContent;
|
|
link.addEventListener('click', (e) => {
|
|
e.preventDefault();
|
|
document.getElementById(heading.id)?.scrollIntoView({
|
|
behavior: 'smooth',
|
|
block: 'start'
|
|
});
|
|
});
|
|
|
|
// Add to list
|
|
listItem.appendChild(link);
|
|
tocList.appendChild(listItem);
|
|
});
|
|
|
|
// Replace loading message with the TOC
|
|
tocContainer.innerHTML = '';
|
|
tocContainer.appendChild(tocList);
|
|
|
|
// Add smooth scrolling for all links pointing to headings
|
|
document.querySelectorAll('a[href^="#heading-"]').forEach(anchor => {
|
|
anchor.addEventListener('click', function(e) {
|
|
e.preventDefault();
|
|
const targetId = this.getAttribute('href');
|
|
document.querySelector(targetId)?.scrollIntoView({
|
|
behavior: 'smooth',
|
|
block: 'start'
|
|
});
|
|
});
|
|
});
|
|
});
|
|
</script>
|
|
|
|
<style is:global>
|
|
/* Table of Contents Styles */
|
|
.toc-list {
|
|
list-style: none;
|
|
padding: 0;
|
|
margin: 0;
|
|
}
|
|
|
|
.toc-item {
|
|
margin-bottom: 0.75rem;
|
|
}
|
|
|
|
.toc-item a {
|
|
color: var(--text-secondary);
|
|
text-decoration: none;
|
|
transition: color 0.2s ease;
|
|
font-size: 0.9rem;
|
|
display: block;
|
|
}
|
|
|
|
.toc-item a:hover {
|
|
color: var(--accent-primary);
|
|
}
|
|
|
|
.toc-h3 {
|
|
padding-left: 1rem;
|
|
font-size: 0.85rem;
|
|
}
|
|
|
|
.toc-empty {
|
|
color: var(--text-tertiary);
|
|
font-style: italic;
|
|
font-size: 0.9rem;
|
|
}
|
|
|
|
/* Add styles for Tags in the sidebar */
|
|
.tags {
|
|
display: flex;
|
|
flex-wrap: wrap;
|
|
gap: 0.5rem;
|
|
}
|
|
|
|
.tag {
|
|
color: var(--accent-secondary);
|
|
text-decoration: none;
|
|
font-size: 0.85rem;
|
|
transition: all 0.3s ease;
|
|
font-family: var(--font-mono);
|
|
background-color: rgba(59, 130, 246, 0.1);
|
|
padding: 0.3rem 0.6rem;
|
|
border-radius: 4px;
|
|
display: inline-block;
|
|
}
|
|
|
|
.tag:hover {
|
|
color: var(--accent-primary);
|
|
background-color: rgba(6, 182, 212, 0.15);
|
|
transform: translateY(-2px);
|
|
}
|
|
|
|
.sidebar-title {
|
|
font-size: 1.1rem;
|
|
margin-bottom: 1rem;
|
|
color: var(--text-primary);
|
|
}
|
|
</style>
|
|
|
|
<style>
|
|
.blog-post-container {
|
|
display: grid;
|
|
grid-template-columns: 7fr 3fr;
|
|
gap: 2rem;
|
|
max-width: 1200px;
|
|
margin: 2rem auto;
|
|
padding: 0 1.5rem;
|
|
}
|
|
|
|
.blog-post {
|
|
background: var(--card-bg);
|
|
border-radius: 12px;
|
|
border: 1px solid var(--card-border);
|
|
overflow: hidden;
|
|
padding: 2rem;
|
|
}
|
|
|
|
.blog-post-header {
|
|
margin-bottom: 2rem;
|
|
}
|
|
|
|
.draft-badge {
|
|
display: inline-block;
|
|
margin-bottom: 1rem;
|
|
padding: 0.25rem 0.75rem;
|
|
background-color: rgba(234, 179, 8, 0.2);
|
|
color: #ca8a04;
|
|
font-size: 0.8rem;
|
|
border-radius: 0.25rem;
|
|
font-weight: 600;
|
|
font-family: var(--font-mono);
|
|
}
|
|
|
|
.blog-post-title {
|
|
font-size: clamp(1.8rem, 4vw, 2.5rem);
|
|
line-height: 1.2;
|
|
margin-bottom: 0.75rem;
|
|
color: var(--text-primary);
|
|
}
|
|
|
|
.blog-post-description {
|
|
font-size: 1.1rem;
|
|
color: var(--text-secondary);
|
|
margin-bottom: 1.5rem;
|
|
max-width: 75ch;
|
|
}
|
|
|
|
.blog-post-meta {
|
|
display: flex;
|
|
flex-wrap: wrap;
|
|
gap: 0.5rem 1.5rem;
|
|
margin-bottom: 1.5rem;
|
|
font-size: 0.85rem;
|
|
color: var(--text-secondary);
|
|
}
|
|
|
|
.blog-post-category {
|
|
padding: 0.25rem 0.75rem;
|
|
background: rgba(6, 182, 212, 0.1);
|
|
border-radius: 2rem;
|
|
font-family: var(--font-mono);
|
|
}
|
|
|
|
.blog-post-tags {
|
|
display: flex;
|
|
flex-wrap: wrap;
|
|
gap: 0.75rem;
|
|
margin-bottom: 1.5rem;
|
|
}
|
|
|
|
.blog-post-tag {
|
|
color: var(--accent-secondary);
|
|
text-decoration: none;
|
|
font-size: 0.85rem;
|
|
transition: color 0.3s ease;
|
|
font-family: var(--font-mono);
|
|
background-color: rgba(59, 130, 246, 0.1);
|
|
padding: 0.2rem 0.6rem;
|
|
border-radius: 4px;
|
|
}
|
|
|
|
.blog-post-tag:hover {
|
|
color: var(--accent-primary);
|
|
background-color: rgba(6, 182, 212, 0.15);
|
|
transform: translateY(-2px);
|
|
}
|
|
|
|
.blog-post-hero {
|
|
width: 100%;
|
|
margin-bottom: 2rem;
|
|
border-radius: 8px;
|
|
overflow: hidden;
|
|
border: 1px solid var(--card-border);
|
|
background-color: var(--bg-secondary);
|
|
}
|
|
|
|
.blog-post-hero img {
|
|
width: 100%;
|
|
height: auto;
|
|
display: block;
|
|
}
|
|
|
|
/* Content Connections - Knowledge Graph */
|
|
.content-connections {
|
|
margin: 2rem 0;
|
|
padding: 1.5rem;
|
|
background: var(--bg-secondary);
|
|
border-radius: 8px;
|
|
border: 1px solid var(--card-border);
|
|
}
|
|
|
|
.connections-title {
|
|
font-size: 1.2rem;
|
|
margin-bottom: 1rem;
|
|
color: var(--text-primary);
|
|
}
|
|
|
|
/* Related Posts Section */
|
|
.related-posts-section {
|
|
margin-top: 3rem;
|
|
padding-top: 2rem;
|
|
border-top: 1px solid var(--card-border);
|
|
}
|
|
|
|
.related-title {
|
|
font-size: 1.5rem;
|
|
margin-bottom: 1.5rem;
|
|
color: var(--text-primary);
|
|
}
|
|
|
|
.related-posts-grid {
|
|
display: grid;
|
|
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
|
|
gap: 1.5rem;
|
|
}
|
|
|
|
.related-post-card {
|
|
background: var(--bg-secondary);
|
|
border-radius: 8px;
|
|
border: 1px solid var(--card-border);
|
|
padding: 1.5rem;
|
|
text-decoration: none;
|
|
transition: all 0.3s ease;
|
|
}
|
|
|
|
.related-post-card:hover {
|
|
transform: translateY(-3px);
|
|
border-color: var(--accent-primary);
|
|
box-shadow: 0 5px 15px rgba(0, 0, 0, 0.1);
|
|
}
|
|
|
|
.related-post-content h4 {
|
|
color: var(--text-primary);
|
|
font-size: 1.1rem;
|
|
margin-bottom: 0.5rem;
|
|
}
|
|
|
|
.related-post-content p {
|
|
color: var(--text-secondary);
|
|
font-size: 0.9rem;
|
|
}
|
|
|
|
/* Sidebar */
|
|
.blog-post-sidebar {
|
|
position: sticky;
|
|
top: 2rem;
|
|
align-self: start;
|
|
height: calc(100vh - 4rem);
|
|
overflow-y: auto;
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 2rem;
|
|
}
|
|
|
|
.sidebar-card {
|
|
background: var(--card-bg);
|
|
border-radius: 12px;
|
|
border: 1px solid var(--card-border);
|
|
padding: 1.5rem;
|
|
}
|
|
|
|
/* Author Card */
|
|
.author-card {
|
|
text-align: center;
|
|
}
|
|
|
|
.author-avatar {
|
|
width: 80px;
|
|
height: 80px;
|
|
border-radius: 50%;
|
|
overflow: hidden;
|
|
margin: 0 auto 1rem;
|
|
border: 2px solid var(--accent-primary);
|
|
background-color: var(--bg-secondary);
|
|
}
|
|
|
|
.avatar-placeholder {
|
|
width: 100%;
|
|
height: 100%;
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
font-size: 1.5rem;
|
|
font-weight: bold;
|
|
color: var(--accent-primary);
|
|
}
|
|
|
|
.author-info h3 {
|
|
margin-bottom: 0.25rem;
|
|
color: var(--text-primary);
|
|
font-size: 1.2rem;
|
|
}
|
|
|
|
.author-info p {
|
|
color: var(--text-secondary);
|
|
margin-bottom: 1rem;
|
|
font-size: 0.9rem;
|
|
}
|
|
|
|
.author-bio {
|
|
font-size: 0.9rem;
|
|
margin-bottom: 1.5rem;
|
|
color: var(--text-secondary);
|
|
text-align: left;
|
|
}
|
|
|
|
.author-links {
|
|
display: flex;
|
|
justify-content: center;
|
|
}
|
|
|
|
.author-link {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 0.5rem;
|
|
padding: 0.5rem 1rem;
|
|
background: rgba(226, 232, 240, 0.05);
|
|
border-radius: 8px;
|
|
color: var(--text-primary);
|
|
text-decoration: none;
|
|
font-size: 0.9rem;
|
|
transition: all 0.3s ease;
|
|
}
|
|
|
|
.author-link:hover {
|
|
background: rgba(226, 232, 240, 0.1);
|
|
transform: translateY(-2px);
|
|
}
|
|
|
|
/* Table of Contents */
|
|
.toc-card h3 {
|
|
margin-bottom: 1rem;
|
|
color: var(--text-primary);
|
|
}
|
|
|
|
.toc-container {
|
|
max-height: 500px;
|
|
overflow-y: auto;
|
|
}
|
|
|
|
@media (max-width: 1024px) {
|
|
.blog-post-container {
|
|
grid-template-columns: 1fr;
|
|
}
|
|
|
|
.blog-post-sidebar {
|
|
display: none;
|
|
}
|
|
}
|
|
|
|
@media (max-width: 768px) {
|
|
.blog-post-title {
|
|
font-size: 1.8rem;
|
|
}
|
|
|
|
.related-posts-grid {
|
|
grid-template-columns: 1fr;
|
|
}
|
|
|
|
.blog-post {
|
|
padding: 1.5rem;
|
|
}
|
|
}
|
|
</style> |