argobox/src/pages/index.astro

840 lines
24 KiB
Plaintext

---
import { getCollection } from 'astro:content';
import BaseLayout from '../layouts/BaseLayout.astro'; // Corrected path
import KnowledgeGraph from '../components/KnowledgeGraph.astro'; // Corrected path
import Terminal from '../components/Terminal.astro'; // Corrected path
import Header from '../components/Header.astro'; // Import Header
import Footer from '../components/Footer.astro'; // Import Footer
// Get all blog entries
const allPosts = await getCollection('posts');
// Sort by publication date
const sortedPosts = allPosts.sort((a, b) => {
const dateA = a.data.pubDate ? new Date(a.data.pubDate) : new Date(0);
const dateB = b.data.pubDate ? new Date(b.data.pubDate) : new Date(0);
return dateB.getTime() - dateA.getTime();
});
// Get all unique tags
const allTags = [...new Set(allPosts.flatMap(post => post.data.tags || []))].sort();
// Get all unique categories
const allCategories = [...new Set(allPosts.map(post => post.data.category).filter(Boolean))].sort();
// Prepare post data for client-side filtering and knowledge graph
const postsData = sortedPosts.map(post => ({
slug: post.slug,
title: post.data.title,
description: post.data.description || '',
pubDate: post.data.pubDate ? new Date(post.data.pubDate).toLocaleDateString('en-us', { year: 'numeric', month: 'short', day: 'numeric' }) : 'No date',
pubDateISO: post.data.pubDate ? new Date(post.data.pubDate).toISOString() : '',
category: post.data.category || 'Uncategorized',
tags: post.data.tags || [],
heroImage: post.data.heroImage || '/images/placeholders/default.jpg',
readTime: post.data.readTime || '5 min read',
isDraft: post.data.draft || false
}));
// Prepare graph data (Obsidian-style: Posts and Tags)
const graphNodes = [];
const graphEdges = [];
const tagNodes = new Map(); // To avoid duplicate tag nodes
// Add post nodes
sortedPosts.forEach(post => {
if (!post.data.draft) { // Exclude drafts from graph
graphNodes.push({
id: post.slug,
label: post.data.title,
type: 'post', // Add type for styling/interaction
url: `/posts/${post.slug}/` // Add URL for linking
});
// Add tag nodes and edges
(post.data.tags || []).forEach(tag => {
const tagId = `tag-${tag}`;
// Add tag node only if it doesn't exist
if (!tagNodes.has(tagId)) {
graphNodes.push({
id: tagId,
label: `#${tag}`, // Prefix with # for clarity
type: 'tag' // Add type
});
tagNodes.set(tagId, true);
}
// Add edge connecting post to tag
graphEdges.push({
source: post.slug,
target: tagId,
type: 'tag-connection' // Add type
});
});
}
});
const graphData = { nodes: graphNodes, edges: graphEdges };
// Terminal commands for tech effect
const commands = [
{
prompt: "[laforceit@argobox]$ ",
command: "find ./posts -type f -name \"*.md\" | sort -n | wc -l",
output: [`${allPosts.length} posts found`]
},
{
prompt: "[laforceit@argobox]$ ",
command: "ls -la ./categories",
output: allCategories.map(cat => `${cat}`)
},
{
prompt: "[laforceit@argobox]$ ",
command: "grep -r \"kubernetes\" --include=\"*.md\" ./posts | wc -l",
output: [`${allPosts.filter(post =>
post.data.tags?.includes('kubernetes') ||
post.data.category === 'Kubernetes' ||
post.data.title?.toLowerCase().includes('kubernetes') ||
post.data.description?.toLowerCase().includes('kubernetes')
).length} matches found`]
}
];
---
<BaseLayout title="Blog | LaForce IT - Home Lab & DevOps Insights" description="Explore articles about Kubernetes, Infrastructure, DevOps, and Home Lab setups">
<Header slot="header" /> {/* Pass Header to slot */}
<main>
{/* Hero Section with Terminal */}
<section class="hero-section">
<div class="container">
<div class="hero-content">
<div class="hero-text">
<div class="hero-subtitle">Technical Articles & Guides</div>
<h1 class="hero-title">Exploring <span>advanced infrastructure</span> and automation</h1>
<p class="hero-description">
Dive into enterprise-grade home lab setups, Kubernetes deployments, and DevOps best practices for the modern tech enthusiast.
</p>
</div>
<div class="terminal-container">
<Terminal commands={commands} title="argobox:~/blog" />
</div>
</div>
</div>
</section>
{/* Blog Posts Section */}
<section class="blog-posts-section">
<div class="container">
<div class="section-header">
<h2 class="section-title">Latest Articles</h2>
<p class="section-description">
Technical insights, infrastructure guides, and DevOps best practices
</p>
</div>
{/* Search and Filter Section */}
<div class="search-filter-section">
<div class="search-bar">
<input type="search" id="search-input" placeholder="Search posts..." class="search-input" />
</div>
<span class="filter-label">Filter by Tag:</span>
<button class="tag-filter-btn active" data-tag="all">All</button>
{allTags.map(tag => (
<button class="tag-filter-btn" data-tag={tag}>{tag}</button>
))}
</div>
{/* Integrated Knowledge Graph */}
<div class="integrated-graph-container">
<KnowledgeGraph graphData={graphData} />
{/* We will update graphData generation later */}
</div>
</div>
{/* Blog Grid (will be populated by JS) */}
<div class="blog-grid" id="blog-grid">
<div class="loading-indicator">
<div class="loading-spinner"></div>
<span>Loading articles...</span>
</div>
</div>
</div>
</section>
</main>
<Footer slot="footer" /> {/* Pass Footer to slot */}
<!-- Client-side script for filtering and graph interactions -->
<script define:vars={{ postsData, graphData }}>
// DOM Elements
const searchInput = document.getElementById('search-input');
const tagButtons = document.querySelectorAll('.tag-filter-btn');
const blogGrid = document.getElementById('blog-grid');
// Removed graphFilters as category filtering is removed from graph
// State variables
let currentFilterTag = 'all';
let currentSearchTerm = '';
// Removed currentGraphFilter
let cy; // Cytoscape instance will be set by KnowledgeGraph component
// Wait for cytoscape instance to be available
document.addEventListener('graphReady', (e) => {
cy = e.detail.cy;
setupGraphInteractions();
});
// Setup graph interactions (Post and Tag nodes)
function setupGraphInteractions() {
if (!cy) {
console.error("Cytoscape instance not ready.");
return;
}
// Remove previous category filter logic if any existed
// graphFilters.forEach(...) logic removed
// Handle clicks on graph nodes
cy.on('tap', 'node', function(evt) {
const node = evt.target;
const nodeId = node.id();
const nodeType = node.data('type'); // Get type ('post' or 'tag')
console.log(`Node clicked: ID=${nodeId}, Type=${nodeType}`); // Debug log
if (nodeType === 'post') {
// Handle post node click: Find post, update search, filter grid, scroll
const post = postsData.find(p => p.slug === nodeId);
if (post) {
console.log(`Post node clicked: ${post.title}`);
// Reset tag filter to 'all' when a specific post is selected via graph
currentFilterTag = 'all';
tagButtons.forEach(btn => btn.classList.remove('active'));
const allButton = document.querySelector('.tag-filter-btn[data-tag="all"]');
if (allButton) allButton.classList.add('active');
// Update search bar and term
searchInput.value = post.title; // Show post title in search
currentSearchTerm = post.title; // Filter grid by title
// Update grid to show only this post (or matching search term)
updateGrid();
// Scroll to the blog section smoothly
const blogSection = document.querySelector('.blog-posts-section');
if (blogSection) {
blogSection.scrollIntoView({ behavior: 'smooth', block: 'start' });
}
} else {
console.warn(`Post data not found for slug: ${nodeId}`);
}
} else if (nodeType === 'tag') {
// Handle tag node click: Simulate click on corresponding tag filter button
const tagName = nodeId.replace(/^tag-/, ''); // Extract tag name (remove 'tag-' prefix)
console.log(`Tag node clicked: ${tagName}`);
const correspondingButton = document.querySelector(`.tag-filter-btn[data-tag="${tagName}"]`);
if (correspondingButton) {
console.log(`Found corresponding button for tag: ${tagName}`);
// Simulate click on the button
correspondingButton.click();
// Scroll to blog section smoothly
const blogSection = document.querySelector('.blog-posts-section');
if (blogSection) {
blogSection.scrollIntoView({ behavior: 'smooth', block: 'start' });
}
} else {
console.warn(`Could not find tag filter button for tag: ${tagName}`);
}
}
});
}
// Function to create HTML for a single post card
function createPostCardHTML(post) {
// Make sure tags is an array before stringifying
const tagsString = JSON.stringify(post.tags || []);
// Create tag pills HTML
const tagPills = post.tags.map(tag =>
`<span class="post-tag">${tag}</span>`
).join('');
return `
<article class="post-card" data-tags='${tagsString}' data-slug="${post.slug}">
<div class="post-card-inner">
<div class="post-image-container">
<img
width="720"
height="360"
src="${post.heroImage}"
alt=""
class="post-image"
loading="lazy"
/>
<div class="post-category-badge">${post.category}</div>
</div>
<div class="post-content">
<div class="post-meta">
<time datetime="${post.pubDateISO}">${post.pubDate}</time>
<span class="post-read-time">${post.readTime}</span>
</div>
<h3 class="post-title">
<a href="/posts/${post.slug}/">${post.title}</a>
${post.isDraft ? '<span class="draft-badge">Draft</span>' : ''}
</h3>
<p class="post-excerpt">${post.description}</p>
<div class="post-footer">
<div class="post-tags">
${tagPills}
</div>
<a href="/posts/${post.slug}/" class="read-more">Read More</a>
</div>
</div>
</div>
</article>
`;
}
// Function to filter and update the grid
function updateGrid() {
const searchTermLower = currentSearchTerm.toLowerCase();
// Ensure postsData is available
if (typeof postsData === 'undefined' || !postsData) {
console.error("postsData is not available in updateGrid");
if(blogGrid) blogGrid.innerHTML = '<p class="no-results">Error loading post data.</p>';
return;
}
const filteredPosts = postsData.filter(post => {
const postTags = post.tags || []; // Ensure tags is an array
const matchesTag = currentFilterTag === 'all' || postTags.includes(currentFilterTag);
const matchesSearch = searchTermLower === '' ||
post.title.toLowerCase().includes(searchTermLower) ||
post.description.toLowerCase().includes(searchTermLower) ||
postTags.some(tag => tag.toLowerCase().includes(searchTermLower));
return matchesTag && matchesSearch && !post.isDraft; // Exclude drafts
});
// Update the grid HTML
if (blogGrid) {
if (filteredPosts.length > 0) {
blogGrid.innerHTML = filteredPosts.map(createPostCardHTML).join('');
// If graph is available, highlight post nodes shown in the grid
if (cy) {
const matchingPostSlugs = filteredPosts.map(post => post.slug);
// Reset styles on all nodes first
cy.nodes().removeClass('highlighted').removeClass('faded');
// Highlight post nodes that are currently visible in the grid
cy.nodes('[type="post"]').forEach(node => {
if (matchingPostSlugs.includes(node.id())) {
node.removeClass('faded').addClass('highlighted');
} else {
node.removeClass('highlighted').addClass('faded'); // Fade non-matching posts
}
});
// Highlight tag nodes connected to visible posts OR the currently selected tag
cy.nodes('[type="tag"]').forEach(tagNode => {
const tagName = tagNode.id().replace(/^tag-/, '');
const isSelectedTag = tagName === currentFilterTag;
const isConnectedToVisiblePost = tagNode.connectedEdges().sources().some(postNode => matchingPostSlugs.includes(postNode.id()));
if (isSelectedTag || (currentFilterTag === 'all' && isConnectedToVisiblePost)) {
tagNode.removeClass('faded').addClass('highlighted');
} else {
tagNode.removeClass('highlighted').addClass('faded');
}
});
// Adjust edge visibility based on connected highlighted nodes
cy.edges().forEach(edge => {
if (edge.source().hasClass('highlighted') && edge.target().hasClass('highlighted')) {
edge.removeClass('faded').addClass('highlighted');
} else {
edge.removeClass('highlighted').addClass('faded');
}
});
}
} else {
blogGrid.innerHTML = '<p class="no-results">No posts found matching your criteria.</p>';
}
} else {
console.error("Blog grid element not found!");
}
}
// Event listener for search input
if (searchInput) {
searchInput.addEventListener('input', (e) => {
currentSearchTerm = e.target.value;
updateGrid();
});
} else {
console.error("Search input element not found!");
}
// Event listeners for tag buttons
tagButtons.forEach(button => {
button.addEventListener('click', () => {
// Update active button style
tagButtons.forEach(btn => btn.classList.remove('active'));
button.classList.add('active');
// Update filter and grid
currentFilterTag = button.dataset.tag;
updateGrid();
});
});
// Initial grid population on client side
document.addEventListener('DOMContentLoaded', () => {
updateGrid(); // Call after DOM is fully loaded
});
</script>
</BaseLayout>
<style>
/* Hero Section */
.hero-section {
padding: 6rem 0 4rem;
position: relative;
overflow: hidden;
background: linear-gradient(180deg, var(--bg-secondary), var(--bg-primary));
}
.container {
max-width: 1280px;
margin: 0 auto;
padding: 0 clamp(1rem, 5vw, 3rem);
}
.hero-content {
display: flex;
align-items: center;
gap: 4rem;
}
.hero-text {
flex: 1;
}
.hero-subtitle {
font-family: var(--font-mono);
color: var(--accent-primary);
font-size: var(--font-size-sm);
letter-spacing: 2px;
text-transform: uppercase;
margin-bottom: 1rem;
display: flex;
align-items: center;
gap: 0.5rem;
}
.hero-subtitle::before {
content: '>';
font-weight: bold;
}
.hero-title {
font-size: clamp(2.5rem, 5vw, 4rem);
line-height: 1.1;
margin-bottom: 1.5rem;
}
.hero-title span {
background: linear-gradient(90deg, var(--accent-primary), var(--accent-tertiary));
-webkit-background-clip: text;
background-clip: text;
color: transparent;
}
.hero-description {
color: var(--text-secondary);
font-size: var(--font-size-lg);
margin-bottom: 1.5rem;
max-width: 540px;
}
.terminal-container {
flex: 1;
max-width: 560px;
}
/* Graph Section */
.graph-section {
padding: 5rem 0;
position: relative;
background: linear-gradient(0deg, var(--bg-primary), var(--bg-secondary), var(--bg-primary));
}
.section-header {
text-align: center;
max-width: 800px;
margin: 0 auto 3rem;
}
.section-title {
font-size: clamp(1.75rem, 3vw, 2.5rem);
margin-bottom: 1rem;
position: relative;
display: inline-block;
}
.section-title::after {
content: '';
position: absolute;
height: 4px;
width: 60px;
background: linear-gradient(90deg, var(--accent-primary), var(--accent-secondary));
bottom: -10px;
left: 50%;
transform: translateX(-50%);
border-radius: 2px;
}
.section-description {
color: var(--text-secondary);
max-width: 600px;
margin: 0 auto;
}
.graph-container {
position: relative;
height: 60vh;
min-height: 500px;
max-height: 800px;
border-radius: 10px;
overflow: hidden;
border: 1px solid var(--card-border);
background: rgba(15, 23, 42, 0.2);
margin-bottom: 2rem;
}
.graph-controls {
display: flex;
flex-wrap: wrap;
gap: 1rem;
justify-content: center;
margin-top: 1.5rem;
}
.graph-filter {
padding: 0.5rem 1rem;
border-radius: 6px;
border: 1px solid var(--border-primary);
background: var(--bg-secondary);
color: var(--text-primary);
font-family: var(--font-mono);
font-size: var(--font-size-sm);
cursor: pointer;
transition: all 0.3s ease;
}
.graph-filter:hover {
border-color: var(--accent-primary);
box-shadow: 0 0 10px var(--glow-primary);
}
.graph-filter.active {
background: linear-gradient(135deg, var(--accent-primary), var(--accent-secondary));
border-color: transparent;
color: var(--bg-primary);
}
/* Blog Posts Section */
.blog-posts-section {
padding: 5rem 0;
}
.search-filter-section {
margin-bottom: 3rem;
padding: 1.5rem;
background: rgba(13, 21, 41, 0.5);
border: 1px solid var(--card-border);
border-radius: 8px;
}
.search-bar {
margin-bottom: 1.5rem;
}
.search-input {
width: 100%;
padding: 0.75rem 1rem;
background-color: var(--bg-primary);
border: 1px solid var(--card-border);
border-radius: 6px;
color: var(--text-primary);
font-size: 1rem;
font-family: var(--font-sans);
}
.search-input::placeholder {
color: var(--text-secondary);
}
.tag-filters {
display: flex;
flex-wrap: wrap;
gap: 0.75rem;
align-items: center;
}
.filter-label {
font-size: 0.9rem;
color: var(--text-secondary);
margin-right: 0.5rem;
font-family: var(--font-mono);
}
.tag-filter-btn {
background-color: rgba(226, 232, 240, 0.05);
color: var(--text-secondary);
border: 1px solid var(--card-border);
padding: 0.4rem 0.8rem;
border-radius: 20px;
cursor: pointer;
font-size: 0.85rem;
transition: all 0.2s ease;
font-family: var(--font-mono);
}
.tag-filter-btn:hover {
background-color: rgba(226, 232, 240, 0.1);
color: var(--text-primary);
border-color: rgba(56, 189, 248, 0.4);
}
.tag-filter-btn.active {
background-color: var(--accent-primary);
color: var(--bg-primary);
border-color: var(--accent-primary);
font-weight: 600;
}
/* Styles for the integrated graph container */
.integrated-graph-container {
margin-top: 2rem; /* Add space above the graph */
height: 400px; /* Adjust height as needed */
border: 1px solid var(--border-primary);
border-radius: 8px;
background: rgba(15, 23, 42, 0.3); /* Slightly different background */
position: relative; /* Needed for Cytoscape */
overflow: hidden; /* Hide scrollbars if graph overflows */
}
.blog-grid {
margin: 2rem 0 4rem;
display: grid;
grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
gap: 2rem;
}
/* Enhanced Post Card Styles */
.post-card {
height: 100%;
transition: all 0.3s ease;
position: relative;
z-index: 1;
}
.post-card-inner {
height: 100%;
background: var(--card-bg);
border-radius: 10px;
border: 1px solid var(--card-border);
overflow: hidden;
display: flex;
flex-direction: column;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.1);
transition: all 0.3s ease;
}
.post-card:hover .post-card-inner {
transform: translateY(-5px);
box-shadow: 0 10px 30px rgba(6, 182, 212, 0.15);
border-color: rgba(56, 189, 248, 0.4);
}
.post-image-container {
position: relative;
}
.post-image {
width: 100%;
height: 200px;
object-fit: cover;
}
.post-category-badge {
position: absolute;
top: 15px;
right: 15px;
background: linear-gradient(135deg, var(--accent-primary), var(--accent-secondary));
color: var(--bg-primary);
padding: 0.35rem 0.8rem;
border-radius: 20px;
font-size: 0.75rem;
font-weight: 600;
box-shadow: 0 2px 10px rgba(6, 182, 212, 0.25);
font-family: var(--font-mono);
}
.post-content {
padding: 1.5rem;
flex-grow: 1;
display: flex;
flex-direction: column;
}
.post-meta {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 1rem;
font-size: 0.85rem;
color: var(--text-secondary);
}
.post-title {
font-size: 1.25rem;
margin-bottom: 0.75rem;
line-height: 1.3;
}
.post-title a {
color: var(--text-primary);
text-decoration: none;
transition: color 0.3s ease;
}
.post-title a:hover {
color: var(--accent-primary);
}
.post-excerpt {
color: var(--text-secondary);
font-size: 0.9rem;
margin-bottom: 1.5rem;
display: -webkit-box;
-webkit-line-clamp: 3;
-webkit-box-orient: vertical;
overflow: hidden;
flex-grow: 1;
}
.post-footer {
display: flex;
justify-content: space-between;
align-items: center;
margin-top: auto;
}
.post-tags {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
}
.post-tag {
background: rgba(226, 232, 240, 0.05);
color: var(--text-secondary);
padding: 0.2rem 0.5rem;
border-radius: 4px;
font-size: 0.7rem;
font-family: var(--font-mono);
}
.read-more {
color: var(--accent-primary);
text-decoration: none;
font-weight: 500;
display: flex;
align-items: center;
gap: 0.25rem;
transition: color 0.3s ease;
}
.read-more:hover {
color: var(--accent-secondary);
}
.read-more::after {
content: '→';
}
.no-results {
grid-column: 1 / -1;
text-align: center;
padding: 3rem;
color: var(--text-secondary);
background: rgba(15, 23, 42, 0.2);
border-radius: 10px;
border: 1px dashed var(--card-border);
}
/* Loading Indicator */
.loading-indicator {
grid-column: 1 / -1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 3rem;
color: var(--text-secondary);
}
.loading-spinner {
width: 40px;
height: 40px;
border: 3px solid rgba(6, 182, 212, 0.3);
border-radius: 50%;
border-top-color: var(--accent-primary);
animation: spin 1s linear infinite;
margin-bottom: 1rem;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
/* Responsive Adjustments */
@media (max-width: 1024px) {
.hero-content {
flex-direction: column;
gap: 2rem;
}
.terminal-container {
max-width: 100%;
width: 100%;
}
.graph-container {
height: 50vh;
}
}
@media (max-width: 768px) {
.hero-section {
padding: 4rem 0 3rem;
}
.section-header {
margin-bottom: 2rem;
}
.search-filter-section {
padding: 1rem;
}
.blog-grid {
grid-template-columns: 1fr;
}
}
</style>