feat: Implement enhanced Knowledge Graph with post/tag connections

This commit is contained in:
Daniel LaForce 2025-04-23 19:40:50 -06:00
parent f3eccd8084
commit 21505d84d8
7 changed files with 129 additions and 1639 deletions

View File

@ -1,112 +0,0 @@
---
import '../styles/global.css';
import Header from '../components/Header.astro';
import Footer from '../components/Footer.astro';
interface Props {
title: string;
description?: string;
}
const { title, description = "LaForce IT - Home Lab & DevOps Insights" } = Astro.props;
---
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{title}</title>
<meta name="description" content={description}>
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500;700&family=Space+Grotesk:wght@300;400;500;600;700&display=swap" rel="stylesheet">
<!-- Open Graph / Social Media Meta Tags -->
<meta property="og:title" content={title} />
<meta property="og:description" content={description} />
<meta property="og:type" content="website" />
<meta property="og:url" content={Astro.url} />
<meta property="og:image" content="/blog/images/placeholders/default.jpg" />
<!-- Twitter Meta Tags -->
<meta name="twitter:card" content="summary_large_image" />
<meta property="twitter:domain" content="laforceit.blog" />
<meta property="twitter:url" content={Astro.url} />
<meta name="twitter:title" content={title} />
<meta name="twitter:description" content={description} />
<meta name="twitter:image" content="/blog/images/placeholders/default.jpg" />
</head>
<body>
<!-- Neural network nodes - Added via JavaScript -->
<div id="neural-network"></div>
<!-- Floating shapes for background effect -->
<div class="floating-shapes">
<div class="floating-shape shape-1"></div>
<div class="floating-shape shape-2"></div>
<div class="floating-shape shape-3"></div>
</div>
<Header />
<slot />
<Footer />
<script>
// Create neural network nodes
document.addEventListener('DOMContentLoaded', () => {
const neuralNetwork = document.getElementById('neural-network');
if (!neuralNetwork) return;
const nodeCount = Math.min(window.innerWidth / 20, 70); // Responsive node count
for (let i = 0; i < nodeCount; i++) {
const node = document.createElement('div');
node.classList.add('neural-node');
// Random position
node.style.left = `${Math.random() * 100}%`;
node.style.top = `${Math.random() * 100}%`;
// Random animation delay
node.style.animationDelay = `${Math.random() * 4}s`;
neuralNetwork.appendChild(node);
}
});
// Terminal typing effect
document.addEventListener('DOMContentLoaded', () => {
const terminalTyping = document.querySelector('.terminal-typing');
if (!terminalTyping) return;
const typingCommands = [
'cloudflared tunnel status',
'kubectl get pods -A',
'helm list -n monitoring',
'flux reconcile kustomization --all'
];
let currentCommandIndex = 0;
function typeCommand(command: string, element: Element, index = 0) {
if (index < command.length) {
element.textContent = command.substring(0, index + 1);
setTimeout(() => typeCommand(command, element, index + 1), 100);
} else {
// Move to next command after delay
setTimeout(() => {
currentCommandIndex = (currentCommandIndex + 1) % typingCommands.length;
typeCommand(typingCommands[currentCommandIndex], element, 0);
}, 3000);
}
}
typeCommand(typingCommands[currentCommandIndex], terminalTyping);
});
</script>
</body>
</html>

View File

@ -1,108 +0,0 @@
---
import { getCollection } from 'astro:content';
import BaseLayout from '../../layouts/BaseLayout.astro';
// Get all blog entries
const allPosts = await getCollection('blog');
// 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();
});
---
<BaseLayout title="Blog | LaForce IT - Home Lab & DevOps Insights" description="Explore articles about Kubernetes, Infrastructure, DevOps, and Home Lab setups">
<main class="container">
<section class="blog-header">
<h1 class="blog-title">Blog</h1>
<p class="blog-description">
Technical insights, infrastructure guides, and DevOps best practices from my home lab to production environments.
</p>
</section>
<div class="blog-grid">
{sortedPosts.map((post) => (
<article class="post-card">
{post.data.heroImage ? (
<img
width={720}
height={360}
src={post.data.heroImage}
alt=""
class="post-image"
/>
) : (
<img
width={720}
height={360}
src="/blog/images/placeholders/default.jpg"
alt=""
class="post-image"
/>
)}
<div class="post-content">
<div class="post-meta">
<time datetime={post.data.pubDate ? new Date(post.data.pubDate).toISOString() : ''}>
{post.data.pubDate ? new Date(post.data.pubDate).toLocaleDateString('en-us', {
year: 'numeric',
month: 'short',
day: 'numeric',
}) : 'No date'}
</time>
{post.data.category && (
<span class="post-category">
{post.data.category}
</span>
)}
</div>
<h3 class="post-title">
<a href={`/blog/${post.slug}/`}>{post.data.title}</a>
{post.data.draft && <span class="ml-2 px-2 py-1 bg-gray-200 text-gray-700 text-xs rounded">Draft</span>}
</h3>
<p class="post-excerpt">{post.data.description}</p>
<div class="post-footer">
<span class="post-read-time">{post.data.readTime || '5 min read'}</span>
<a href={`/blog/${post.slug}/`} class="read-more">Read More</a>
</div>
</div>
</article>
))}
</div>
</main>
</BaseLayout>
<style>
.blog-header {
margin: 3rem 0;
text-align: center;
}
.blog-title {
font-size: clamp(2rem, 5vw, 3.5rem);
margin-bottom: 1rem;
background: linear-gradient(90deg, var(--accent-primary), var(--accent-tertiary));
-webkit-background-clip: text;
background-clip: text;
color: transparent;
display: inline-block;
}
.blog-description {
color: var(--text-secondary);
font-size: clamp(1rem, 2vw, 1.2rem);
max-width: 700px;
margin: 0 auto;
}
.blog-grid {
margin: 2rem 0 4rem;
}
@media (max-width: 768px) {
.blog-header {
margin: 2rem 0;
}
}
</style>

View File

@ -1,832 +0,0 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
:root {
--bg-primary: #050a18;
--bg-secondary: #0d1529;
--text-primary: #e2e8f0;
--text-secondary: #94a3b8;
--accent-primary: #06b6d4;
--accent-secondary: #3b82f6;
--accent-tertiary: #8b5cf6;
--glow-primary: rgba(6, 182, 212, 0.3);
--glow-secondary: rgba(59, 130, 246, 0.3);
--card-bg: rgba(15, 23, 42, 0.8);
--card-border: rgba(56, 189, 248, 0.2);
}
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: 'Space Grotesk', sans-serif;
background: var(--bg-primary);
color: var(--text-primary);
line-height: 1.6;
overflow-x: hidden;
background-image:
radial-gradient(circle at 20% 35%, rgba(6, 182, 212, 0.05) 0%, transparent 50%),
radial-gradient(circle at 75% 15%, rgba(59, 130, 246, 0.05) 0%, transparent 45%),
radial-gradient(circle at 85% 70%, rgba(139, 92, 246, 0.05) 0%, transparent 40%);
position: relative;
}
/* Grid overlay effect */
body::before {
content: "";
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background-image:
linear-gradient(rgba(226, 232, 240, 0.03) 1px, transparent 1px),
linear-gradient(90deg, rgba(226, 232, 240, 0.03) 1px, transparent 1px);
background-size: 30px 30px;
pointer-events: none;
z-index: -1;
}
/* Neural network nodes */
.neural-node {
position: fixed;
width: 2px;
height: 2px;
background: rgba(226, 232, 240, 0.2);
border-radius: 50%;
animation: pulse 4s infinite alternate ease-in-out;
z-index: -1;
}
@keyframes pulse {
0% {
transform: scale(1);
opacity: 0.3;
}
100% {
transform: scale(1.5);
opacity: 0.6;
}
}
/* Terminal cursor animation */
@keyframes blink {
0%, 100% { opacity: 1; }
50% { opacity: 0; }
}
/* Header styles */
header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 1.5rem clamp(1rem, 5%, 3rem);
position: relative;
background: linear-gradient(180deg, var(--bg-secondary), transparent);
border-bottom: 1px solid rgba(56, 189, 248, 0.1);
}
.logo {
display: flex;
align-items: center;
gap: 1rem;
font-weight: 600;
font-size: 1.5rem;
text-decoration: none;
color: var(--text-primary);
}
.logo span {
background: linear-gradient(90deg, var(--accent-primary), var(--accent-secondary));
-webkit-background-clip: text;
background-clip: text;
color: transparent;
}
.logo-symbol {
width: 2.5rem;
height: 2.5rem;
border-radius: 10px;
background: linear-gradient(135deg, var(--accent-primary), var(--accent-secondary));
display: flex;
align-items: center;
justify-content: center;
font-family: 'JetBrains Mono', monospace;
font-weight: bold;
font-size: 1.25rem;
color: var(--bg-primary);
box-shadow: 0 0 15px var(--glow-primary);
}
nav {
display: flex;
gap: 2rem;
}
nav a {
color: var(--text-secondary);
text-decoration: none;
font-weight: 500;
transition: all 0.3s ease;
position: relative;
}
nav a:hover {
color: var(--text-primary);
}
nav a::after {
content: '';
position: absolute;
width: 0;
height: 2px;
bottom: -5px;
left: 0;
background: linear-gradient(90deg, var(--accent-primary), var(--accent-secondary));
transition: width 0.3s ease;
}
nav a:hover::after {
width: 100%;
}
.mobile-menu-btn {
display: none;
background: none;
border: none;
color: var(--text-primary);
font-size: 1.5rem;
cursor: pointer;
}
/* Floating shapes */
.floating-shapes {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
pointer-events: none;
z-index: 0;
}
.floating-shape {
position: absolute;
border-radius: 50%;
opacity: 0.05;
filter: blur(30px);
}
.shape-1 {
width: 300px;
height: 300px;
background: var(--accent-primary);
top: 20%;
right: 0;
}
.shape-2 {
width: 200px;
height: 200px;
background: var(--accent-secondary);
bottom: 10%;
left: 10%;
}
.shape-3 {
width: 150px;
height: 150px;
background: var(--accent-tertiary);
top: 70%;
right: 20%;
}
/* Blog post cards */
.post-card {
background: var(--card-bg);
border-radius: 10px;
border: 1px solid var(--card-border);
overflow: hidden;
transition: all 0.3s ease;
position: relative;
z-index: 1;
}
.post-card:hover {
transform: translateY(-5px);
box-shadow: 0 10px 30px rgba(6, 182, 212, 0.1);
border-color: rgba(56, 189, 248, 0.4);
}
.post-card::before {
content: '';
position: absolute;
inset: 0;
background: linear-gradient(135deg, rgba(6, 182, 212, 0.05), rgba(139, 92, 246, 0.05));
z-index: -1;
opacity: 0;
transition: opacity 0.3s ease;
}
.post-card:hover::before {
opacity: 1;
}
.post-image {
width: 100%;
height: 200px;
object-fit: cover;
border-bottom: 1px solid var(--card-border);
}
.post-content {
padding: 1.5rem;
}
.post-meta {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 1rem;
font-size: 0.85rem;
color: var(--text-secondary);
}
.post-category {
background: rgba(6, 182, 212, 0.1);
color: var(--accent-primary);
padding: 0.25rem 0.5rem;
border-radius: 4px;
font-family: 'JetBrains Mono', monospace;
font-size: 0.75rem;
}
.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;
}
.post-footer {
display: flex;
justify-content: space-between;
align-items: center;
color: var(--text-secondary);
font-size: 0.85rem;
}
.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: '→';
}
/* Section styles */
.section-title {
font-size: clamp(1.5rem, 3vw, 2.5rem);
margin-bottom: 1rem;
position: relative;
display: inline-block;
color: var(--text-primary);
}
.section-title::after {
content: '';
position: absolute;
height: 4px;
width: 60px;
background: linear-gradient(90deg, var(--accent-primary), var(--accent-secondary));
bottom: -10px;
left: 0;
border-radius: 2px;
}
/* Footer styles */
footer {
background: var(--bg-secondary);
padding: 3rem clamp(1rem, 5%, 3rem);
position: relative;
border-top: 1px solid rgba(56, 189, 248, 0.1);
margin-top: 5rem;
}
.footer-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 2rem;
margin-bottom: 3rem;
}
.footer-col h4 {
font-size: 1.1rem;
margin-bottom: 1.5rem;
color: var(--text-primary);
}
.footer-links {
list-style: none;
}
.footer-links li {
margin-bottom: 0.75rem;
}
.footer-links a {
color: var(--text-secondary);
text-decoration: none;
transition: color 0.3s ease;
}
.footer-links a:hover {
color: var(--accent-primary);
}
.social-links {
display: flex;
gap: 1rem;
margin-top: 1rem;
}
.social-link {
width: 36px;
height: 36px;
border-radius: 50%;
background: rgba(226, 232, 240, 0.05);
display: flex;
align-items: center;
justify-content: center;
color: var(--text-secondary);
text-decoration: none;
transition: all 0.3s ease;
}
.social-link:hover {
background: var(--accent-primary);
color: var(--bg-primary);
transform: translateY(-3px);
}
.footer-bottom {
text-align: center;
padding-top: 2rem;
border-top: 1px solid rgba(226, 232, 240, 0.05);
color: var(--text-secondary);
font-size: 0.9rem;
}
.footer-bottom a {
color: var(--accent-primary);
text-decoration: none;
}
/* Hero section for homepage */
.hero {
min-height: 80vh;
display: flex;
align-items: center;
padding: 3rem clamp(1rem, 5%, 3rem);
position: relative;
overflow: hidden;
}
.hero-content {
max-width: 650px;
z-index: 1;
}
.hero-subtitle {
font-family: 'JetBrains Mono', monospace;
color: var(--accent-primary);
font-size: 0.9rem;
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: 1.1rem;
margin-bottom: 2rem;
max-width: 85%;
}
.cta-button {
display: inline-flex;
align-items: center;
gap: 0.5rem;
background: linear-gradient(135deg, var(--accent-primary), var(--accent-secondary));
color: var(--bg-primary);
font-weight: 600;
padding: 0.75rem 1.5rem;
border-radius: 8px;
text-decoration: none;
transition: all 0.3s ease;
box-shadow: 0 0 20px var(--glow-primary);
}
.cta-button:hover {
transform: translateY(-2px);
box-shadow: 0 0 30px var(--glow-primary);
}
/* Terminal box */
.terminal-box {
width: 100%;
background: var(--bg-secondary);
border-radius: 10px;
border: 1px solid var(--card-border);
box-shadow: 0 0 30px rgba(6, 182, 212, 0.1);
padding: 1.5rem;
font-family: 'JetBrains Mono', monospace;
font-size: 0.9rem;
display: flex;
flex-direction: column;
z-index: 1;
overflow: hidden;
margin: 2rem 0;
}
.terminal-header {
display: flex;
align-items: center;
margin-bottom: 1rem;
padding-bottom: 0.5rem;
border-bottom: 1px solid rgba(226, 232, 240, 0.1);
}
.terminal-dots {
display: flex;
gap: 0.5rem;
}
.terminal-dot {
width: 12px;
height: 12px;
border-radius: 50%;
}
.terminal-dot-red {
background: #ef4444;
}
.terminal-dot-yellow {
background: #eab308;
}
.terminal-dot-green {
background: #22c55e;
}
.terminal-title {
margin-left: auto;
margin-right: auto;
color: var(--text-secondary);
font-size: 0.8rem;
}
.terminal-content {
flex: 1;
color: var(--text-secondary);
}
.terminal-line {
margin-bottom: 0.75rem;
display: flex;
}
.terminal-prompt {
color: var(--accent-primary);
margin-right: 0.5rem;
}
.terminal-command {
color: var(--text-primary);
}
.terminal-output {
color: var(--text-secondary);
padding-left: 1.5rem;
margin-bottom: 0.75rem;
}
.terminal-typing {
position: relative;
}
.terminal-typing::after {
content: '|';
position: absolute;
right: -10px;
animation: blink 1s infinite;
}
/* Container and content layout */
.container {
width: 100%;
max-width: 1200px;
margin: 0 auto;
padding: 0 1.5rem;
}
main {
padding: 2rem 0;
}
/* Blog content styling */
.blog-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
gap: 2rem;
margin-top: 2rem;
}
/* Digital Garden */
.digital-garden-intro {
color: var(--text-secondary);
font-size: 1.1rem;
margin-bottom: 2rem;
max-width: 800px;
}
/* Responsive adjustments */
@media (max-width: 1024px) {
.hero {
flex-direction: column;
align-items: flex-start;
}
.hero-content {
max-width: 100%;
}
}
@media (max-width: 768px) {
header {
flex-direction: column;
align-items: flex-start;
gap: 1rem;
}
nav {
width: 100%;
justify-content: space-between;
}
.hero-description {
max-width: 100%;
}
.blog-grid {
grid-template-columns: 1fr;
gap: 1.5rem;
}
.hero {
padding: 2rem 1rem;
flex-direction: column;
gap: 2rem;
}
.hero-content {
max-width: 100%;
}
.hero-title {
font-size: clamp(1.5rem, 6vw, 2.5rem);
}
.terminal-box {
width: 100%;
min-height: 300px;
max-width: 100%;
}
.footer-grid {
grid-template-columns: 1fr;
gap: 2rem;
}
.post-card {
min-height: auto;
}
.featured-grid {
grid-template-columns: 1fr;
}
.post-metadata {
flex-direction: column;
align-items: flex-start;
gap: 0.75rem;
}
.post-info {
flex-wrap: wrap;
}
.post-tags {
margin-top: 1rem;
}
/* Add mobile menu functionality */
.mobile-menu-btn {
display: block;
}
nav.desktop-nav {
display: none;
}
nav.mobile-nav-active {
display: flex;
flex-direction: column;
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100vh;
background: var(--bg-primary);
z-index: 1000;
padding: 2rem;
align-items: flex-start;
}
nav.mobile-nav-active a {
font-size: 1.5rem;
margin-bottom: 1.5rem;
}
.mobile-menu-close {
align-self: flex-end;
background: none;
border: none;
color: var(--text-primary);
font-size: 1.5rem;
cursor: pointer;
margin-bottom: 2rem;
}
}
@media (max-width: 640px) {
.mobile-menu-btn {
display: block;
position: absolute;
right: 1.5rem;
top: 1.5rem;
}
nav {
display: none;
}
.blog-grid {
grid-template-columns: 1fr;
}
}
/* Additional mobile optimizations for very small screens */
@media (max-width: 480px) {
:root {
--container-padding: 0.75rem;
}
.post-title {
font-size: 1.5rem;
}
.post-card {
border-radius: 0.5rem;
}
.post-image {
height: 160px;
}
.section-title {
font-size: 1.5rem;
margin-bottom: 1rem;
}
.hero-subtitle {
font-size: 0.8rem;
}
.cta-button {
width: 100%;
text-align: center;
}
.post-content {
font-size: 1rem;
line-height: 1.6;
}
/* Adjust footer layout */
.footer-col {
margin-bottom: 1.5rem;
}
.footer-bottom {
flex-direction: column;
gap: 1rem;
text-align: center;
}
}
/* Touch device optimizations */
@media (hover: none) {
.post-card:hover {
transform: none;
}
.cta-button:hover {
transform: none;
}
nav a:hover::after {
width: 100%;
}
.social-link:hover {
transform: none;
}
.post-tag:hover {
transform: none;
}
/* Increase tap target sizes */
nav a {
padding: 0.5rem 0;
}
.footer-links li {
margin-bottom: 0.75rem;
}
.footer-links a {
padding: 0.5rem 0;
display: inline-block;
}
.post-footer {
padding: 1rem;
}
}

View File

@ -1,566 +0,0 @@
---
import { getCollection } from 'astro:content';
import type { CollectionEntry } from 'astro:content';
import BaseLayout from '../layouts/BaseLayout.astro';
import DigitalGardenGraph from '../components/DigitalGardenGraph.astro';
type Post = CollectionEntry<'posts'>;
type Config = CollectionEntry<'configurations'>;
type Project = CollectionEntry<'projects'>;
// Get all blog posts (excluding configurations and specific guides)
const posts = (await getCollection('blog'))
.filter(item =>
!item.slug.startsWith('configurations/') &&
!item.slug.startsWith('projects/') &&
!item.data.category?.toLowerCase().includes('configuration') &&
!item.slug.includes('setup-guide') &&
!item.slug.includes('config')
)
.sort((a, b) => new Date(b.data.pubDate || 0).valueOf() - new Date(a.data.pubDate || 0).valueOf());
// Get configuration posts
const configurations = (await getCollection('blog'))
.filter(item =>
item.slug.startsWith('configurations/') ||
item.data.category?.toLowerCase().includes('configuration') ||
item.slug.includes('setup-guide') ||
item.slug.includes('config') ||
item.slug.includes('monitoring') ||
item.slug.includes('server') ||
item.slug.includes('tunnel')
)
.sort((a, b) => new Date(b.data.pubDate || 0).valueOf() - new Date(a.data.pubDate || 0).valueOf());
// Get project posts
const projects = (await getCollection('blog'))
.filter(item =>
item.slug.startsWith('projects/') ||
item.data.category?.toLowerCase().includes('project')
)
.sort((a, b) => new Date(b.data.pubDate || 0).valueOf() - new Date(a.data.pubDate || 0).valueOf());
---
<BaseLayout title="LaForce IT - Home Lab & DevOps Insights">
<!-- Hero section -->
<section class="hero">
<div class="hero-content">
<div class="hero-subtitle">Home Lab & DevOps</div>
<h1 class="hero-title">Exploring <span>advanced infrastructure</span> and automation</h1>
<p class="hero-description">
Join me on a journey through enterprise-grade home lab setups, Kubernetes deployments, and DevOps best practices for the modern tech enthusiast.
</p>
<div class="social-links-hero">
<a href="https://github.com/keyargo" target="_blank" rel="noopener noreferrer" class="social-link-hero github">
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" 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>
</a>
<a href="https://linkedin.com/in/danlaforce" target="_blank" rel="noopener noreferrer" class="social-link-hero linkedin">
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" 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"></path><rect x="2" y="9" width="4" height="12"></rect><circle cx="4" cy="4" r="2"></circle></svg>
</a>
</div>
<a href="#posts" class="cta-button">
Explore Latest Posts
</a>
</div>
<div class="terminal-box">
<div class="terminal-header">
<div class="terminal-dots">
<div class="terminal-dot terminal-dot-red"></div>
<div class="terminal-dot terminal-dot-yellow"></div>
<div class="terminal-dot terminal-dot-green"></div>
</div>
<div class="terminal-title">argobox:~/homelab</div>
</div>
<div class="terminal-content">
<div class="terminal-line">
<span class="terminal-prompt">$</span>
<span class="terminal-command">kubectl get nodes</span>
</div>
<div class="terminal-output">
NAME STATUS ROLES AGE VERSION<br>
argobox Ready &lt;none&gt; 47d v1.28.3+k3s1<br>
argobox-lite Ready control-plane,master 47d v1.28.3+k3s1
</div>
<div class="terminal-line">
<span class="terminal-prompt">$</span>
<span class="terminal-command">helm list -A</span>
</div>
<div class="terminal-output">
NAME NAMESPACE REVISION STATUS CHART<br>
cloudnative-pg postgres 1 deployed cloudnative-pg-0.18.0<br>
prometheus monitoring 2 deployed kube-prometheus-stack-51.2.0
</div>
<div class="terminal-line">
<span class="terminal-prompt">$</span>
<span class="terminal-command terminal-typing">cloudflared tunnel status</span>
</div>
</div>
</div>
</section>
<!-- Digital Garden Visualization -->
<section class="container">
<h2 class="section-title">My Digital Garden</h2>
<p class="digital-garden-intro">
This blog functions as my personal digital garden - a collection of interconnected ideas, guides, and projects.
Browse through the visualization below to see how different concepts relate to each other.
</p>
<DigitalGardenGraph />
</section>
<!-- Main content sections -->
<main class="container">
<section id="posts" class="mb-16">
<h2 class="section-title">Latest Posts</h2>
<div class="blog-grid">
{posts.map((post) => (
<article class="post-card">
{post.data.heroImage ? (
<img
width={720}
height={360}
src={post.data.heroImage}
alt=""
class="post-image"
/>
) : (
<img
width={720}
height={360}
src="/blog/images/placeholders/default.jpg"
alt=""
class="post-image"
/>
)}
<div class="post-content">
<div class="post-meta">
<time datetime={post.data.pubDate ? new Date(post.data.pubDate).toISOString() : ''}>
{post.data.pubDate ? new Date(post.data.pubDate).toLocaleDateString('en-us', {
year: 'numeric',
month: 'short',
day: 'numeric',
}) : 'No date'}
</time>
{post.data.category && (
<span class="post-category">
{post.data.category}
</span>
)}
</div>
<h3 class="post-title">
<a href={`/blog/${post.slug}/`}>{post.data.title}</a>
{post.data.draft && <span class="ml-2 px-2 py-1 bg-gray-200 text-gray-700 text-xs rounded">Draft</span>}
</h3>
<p class="post-excerpt">{post.data.description}</p>
<div class="post-footer">
<span class="post-read-time">{post.data.readTime || '5 min read'}</span>
<a href={`/blog/${post.slug}/`} class="read-more">Read More</a>
</div>
</div>
</article>
))}
</div>
</section>
<section id="configurations" class="mb-16">
<h2 class="section-title">Configurations</h2>
<div class="blog-grid">
{configurations.map((config) => (
<article class="post-card">
{config.data.heroImage ? (
<img
width={720}
height={360}
src={config.data.heroImage}
alt=""
class="post-image"
/>
) : (
<img
width={720}
height={360}
src="/blog/images/placeholders/default.jpg"
alt=""
class="post-image"
/>
)}
<div class="post-content">
<div class="post-meta">
<time datetime={config.data.pubDate ? new Date(config.data.pubDate).toISOString() : ''}>
{config.data.pubDate ? new Date(config.data.pubDate).toLocaleDateString('en-us', {
year: 'numeric',
month: 'short',
day: 'numeric',
}) : 'No date'}
</time>
{config.data.category && (
<span class="post-category">
{config.data.category}
</span>
)}
</div>
<h3 class="post-title">
<a href={`/blog/${config.slug}/`}>{config.data.title}</a>
{config.data.draft && <span class="ml-2 px-2 py-1 bg-gray-200 text-gray-700 text-xs rounded">Draft</span>}
</h3>
<p class="post-excerpt">{config.data.description}</p>
<div class="post-footer">
<span class="post-read-time">{config.data.readTime || '5 min read'}</span>
<a href={`/blog/${config.slug}/`} class="read-more">Read More</a>
</div>
</div>
</article>
))}
</div>
</section>
<section id="projects" class="mb-16">
<h2 class="section-title">Projects</h2>
<div class="blog-grid">
{projects.map((project) => (
<article class="post-card">
{project.data.heroImage ? (
<img
width={720}
height={360}
src={project.data.heroImage}
alt=""
class="post-image"
/>
) : (
<img
width={720}
height={360}
src="/blog/images/placeholders/default.jpg"
alt=""
class="post-image"
/>
)}
<div class="post-content">
<div class="post-meta">
<time datetime={project.data.pubDate ? new Date(project.data.pubDate).toISOString() : ''}>
{project.data.pubDate ? new Date(project.data.pubDate).toLocaleDateString('en-us', {
year: 'numeric',
month: 'short',
day: 'numeric',
}) : 'No date'}
</time>
{project.data.category && (
<span class="post-category">
{project.data.category}
</span>
)}
</div>
<h3 class="post-title">
<a href={`/blog/${project.slug}/`}>{project.data.title}</a>
{project.data.draft && <span class="ml-2 px-2 py-1 bg-gray-200 text-gray-700 text-xs rounded">Draft</span>}
</h3>
{project.data.technologies && (
<div class="mb-2 flex flex-wrap gap-2">
{project.data.technologies.map((tech) => (
<span class="post-category">
{tech}
</span>
))}
</div>
)}
<p class="post-excerpt">{project.data.description}</p>
<div class="post-footer">
<div class="flex gap-4">
{project.data.github && (
<a href={project.data.github} target="_blank" rel="noopener noreferrer" class="read-more">
GitHub
</a>
)}
{project.data.live && (
<a href={project.data.live} target="_blank" rel="noopener noreferrer" class="read-more">
Live Demo
</a>
)}
</div>
<a href={`/blog/${project.slug}/`} class="read-more">View Project</a>
</div>
</div>
</article>
))}
</div>
</section>
<!-- Featured section -->
<section class="featured-section">
<div class="featured-grid">
<div class="featured-content">
<div class="featured-subtitle">Featured Project</div>
<h2 class="featured-title">ArgoBox <span>Home Lab Architecture</span></h2>
<p class="featured-description">
A complete enterprise-grade home infrastructure built on Kubernetes, featuring high availability, zero-trust networking, and fully automated deployments.
</p>
<ul class="featured-list">
<li class="featured-list-item">
<div class="featured-list-icon">✓</div>
<div>Multi-node K3s cluster with automatic failover</div>
</li>
<li class="featured-list-item">
<div class="featured-list-icon">✓</div>
<div>Gitea + Flux CD for GitOps-based continuous deployment</div>
</li>
<li class="featured-list-item">
<div class="featured-list-icon">✓</div>
<div>Cloudflare Tunnels for secure, zero-trust remote access</div>
</li>
<li class="featured-list-item">
<div class="featured-list-icon">✓</div>
<div>Synology NAS integration with Kubernetes volumes</div>
</li>
</ul>
<a href="#" class="cta-button">
View Project Details
</a>
</div>
</div>
</section>
<!-- About Me Section -->
<section class="about-section mb-16">
<h2 class="section-title">About Me</h2>
<div class="about-content">
<div class="about-text">
<p>
Hi, I'm Daniel LaForce, a passionate DevOps and infrastructure engineer with a focus on Kubernetes,
automation, and cloud technologies. When I'm not working on enterprise systems, I'm building and
refining my home lab environment to test and learn new technologies.
</p>
<p>
This site serves as both my technical blog and digital garden - a place to share what I've learned
and document my ongoing projects. Feel free to connect with me on GitHub or LinkedIn!
</p>
<div class="social-links">
<a href="https://github.com/keyargo" target="_blank" rel="noopener noreferrer" class="social-link">
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" 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>
<span>GitHub</span>
</a>
<a href="https://linkedin.com/in/danlaforce" target="_blank" rel="noopener noreferrer" class="social-link">
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" 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"></path><rect x="2" y="9" width="4" height="12"></rect><circle cx="4" cy="4" r="2"></circle></svg>
<span>LinkedIn</span>
</a>
</div>
</div>
</div>
</section>
</main>
</BaseLayout>
<style>
.featured-section {
margin-top: 4rem;
background: var(--card-bg);
border-radius: 1rem;
border: 1px solid var(--card-border);
padding: 2rem;
position: relative;
overflow: hidden;
}
.featured-grid {
display: grid;
grid-template-columns: 1fr;
gap: 2rem;
}
.featured-subtitle {
font-family: 'JetBrains Mono', monospace;
color: var(--accent-primary);
font-size: 0.9rem;
letter-spacing: 2px;
text-transform: uppercase;
margin-bottom: 1rem;
}
.featured-title {
font-size: clamp(1.8rem, 4vw, 2.5rem);
line-height: 1.2;
margin-bottom: 1.5rem;
}
.featured-title span {
background: linear-gradient(90deg, var(--accent-primary), var(--accent-tertiary));
-webkit-background-clip: text;
background-clip: text;
color: transparent;
}
.featured-description {
color: var(--text-secondary);
font-size: 1.1rem;
margin-bottom: 1.5rem;
max-width: 600px;
}
.featured-list {
list-style: none;
margin-bottom: 2rem;
}
.featured-list-item {
display: flex;
margin-bottom: 0.75rem;
align-items: flex-start;
}
.featured-list-icon {
color: var(--accent-primary);
margin-right: 1rem;
font-weight: bold;
}
.mb-16 {
margin-bottom: 4rem;
}
.flex {
display: flex;
}
.flex-wrap {
flex-wrap: wrap;
}
.gap-2 {
gap: 0.5rem;
}
.gap-4 {
gap: 1rem;
}
.ml-2 {
margin-left: 0.5rem;
}
.px-2 {
padding-left: 0.5rem;
padding-right: 0.5rem;
}
.py-1 {
padding-top: 0.25rem;
padding-bottom: 0.25rem;
}
.bg-gray-200 {
background-color: rgba(226, 232, 240, 0.2);
}
.text-gray-700 {
color: #94a3b8;
}
.text-xs {
font-size: 0.75rem;
}
.rounded {
border-radius: 0.25rem;
}
.social-links-hero {
display: flex;
gap: 1rem;
margin-bottom: 1.5rem;
}
.social-link-hero {
display: flex;
align-items: center;
justify-content: center;
width: 40px;
height: 40px;
border-radius: 50%;
background-color: var(--card-bg);
color: var(--text-primary);
transition: all 0.3s ease;
border: 1px solid var(--card-border);
}
.social-link-hero:hover {
transform: translateY(-3px);
box-shadow: 0 10px 20px rgba(0, 0, 0, 0.2);
}
.social-link-hero.github:hover {
background-color: #24292e;
border-color: #24292e;
}
.social-link-hero.linkedin:hover {
background-color: #0077b5;
border-color: #0077b5;
}
.about-section {
background: var(--card-bg);
border-radius: 1rem;
border: 1px solid var(--card-border);
padding: 2rem;
position: relative;
overflow: hidden;
}
.about-content {
display: grid;
grid-template-columns: 1fr;
gap: 2rem;
}
.about-text {
color: var(--text-secondary);
font-size: 1.1rem;
line-height: 1.6;
}
.about-text p {
margin-bottom: 1.5rem;
}
.social-links {
display: flex;
gap: 1.5rem;
margin-top: 2rem;
}
.social-link {
display: flex;
align-items: center;
gap: 0.5rem;
color: var(--text-primary);
text-decoration: none;
padding: 0.5rem 1rem;
border-radius: 0.5rem;
background-color: rgba(226, 232, 240, 0.05);
transition: all 0.3s ease;
}
.social-link:hover {
background-color: rgba(226, 232, 240, 0.1);
transform: translateY(-2px);
}
@media (min-width: 768px) {
.featured-grid {
grid-template-columns: 1fr;
}
.about-content {
grid-template-columns: 1fr;
}
}
@media (min-width: 1024px) {
.about-content {
grid-template-columns: 1fr;
}
}
</style>

View File

@ -1,5 +1,5 @@
---
// Footer.astro
// src/components/Footer.astro
// High-quality footer with navigation, social links and additional elements
const currentYear = new Date().getFullYear();
@ -9,11 +9,11 @@ const categories = [
{
title: 'Technology',
links: [
{ name: 'Kubernetes', path: '/blog/category/kubernetes' },
{ name: 'Docker', path: '/blog/category/docker' },
{ name: 'DevOps', path: '/blog/category/devops' },
{ name: 'Networking', path: '/blog/category/networking' },
{ name: 'Storage', path: '/blog/category/storage' }
{ name: 'Kubernetes', path: '/categories/kubernetes' },
{ name: 'Docker', path: '/categories/docker' },
{ name: 'DevOps', path: '/categories/devops' },
{ name: 'Networking', path: '/categories/networking' },
{ name: 'Storage', path: '/categories/storage' }
]
},
{
@ -29,8 +29,8 @@ const categories = [
{
title: 'Projects',
links: [
{ name: 'HomeLab Setup', path: '/projects/homelab' },
{ name: 'Tech Stack', path: '/projects/tech-stack' },
{ name: 'HomeLab Setup', url: 'https://argobox.com' },
{ name: 'Tech Stack', url: 'https://argobox.com/#services' },
{ name: 'Github Repos', path: '/projects/github' },
{ name: 'Live Services', path: '/projects/services' },
{ name: 'Obsidian Templates', path: '/projects/obsidian' }
@ -42,7 +42,7 @@ const categories = [
const socialLinks = [
{
name: 'GitHub',
url: 'https://github.com/yourusername',
url: 'https://github.com/KeyArgo/',
icon: '<path fill-rule="evenodd" clip-rule="evenodd" d="M12 0C5.37 0 0 5.37 0 12c0 5.31 3.435 9.795 8.205 11.385c.6.105.825-.255.825-.57c0-.285-.015-1.23-.015-2.235c-3.015.555-3.795-.735-4.035-1.41c-.135-.345-.72-1.41-1.23-1.695c-.42-.225-1.02-.78-.015-.795c.945-.015 1.62.87 1.845 1.23c1.08 1.815 2.805 1.305 3.495.99c.105-.78.42-1.305.765-1.605c-2.67-.3-5.46-1.335-5.46-5.925c0-1.305.465-2.385 1.23-3.225c-.12-.3-.54-1.53.12-3.18c0 0 1.005-.315 3.3 1.23c.96-.27 1.98-.405 3-.405s2.04.135 3 .405c2.295-1.56 3.3-1.23 3.3-1.23c.66 1.65.24 2.88.12 3.18c.765.84 1.23 1.905 1.23 3.225c0 4.605-2.805 5.625-5.475 5.925c.435.375.81 1.095.81 2.22c0 1.605-.015 2.895-.015 3.3c0 .315.225.69.825.57A12.02 12.02 0 0 0 24 12c0-6.63-5.37-12-12-12z" />'
},
{
@ -130,7 +130,14 @@ const services = [
<ul class="footer-links">
{category.links.map(link => (
<li>
<a href={link.path} class="footer-link">{link.name}</a>
<a
href={link.url || link.path}
class="footer-link"
target={link.url ? "_blank" : undefined}
rel={link.url ? "noopener noreferrer" : undefined}
>
{link.name}
</a>
</li>
))}
</ul>

View File

@ -309,7 +309,13 @@ const nodeTypeCounts = {
{ selector: '.filtered', style: { 'background-color': 'data(color)', 'border-color': '#FFFFFF', 'border-width': '2px', 'color': '#FFFFFF', 'text-background-opacity': 0.8, 'opacity': 0.8, 'z-index': 15 } },
{ selector: '.faded', style: { 'opacity': 0.15, 'text-opacity': 0.3, 'background-opacity': 0.3, 'z-index': 1 } },
{ selector: 'node:selected', style: { 'border-width': '4px', 'border-color': '#FFFFFF', 'border-opacity': 1, 'background-color': 'data(color)', 'text-opacity': 1, 'color': '#FFFFFF', 'z-index': 30 } },
{ selector: 'edge:selected', style: { 'width': 'mapData(weight, 1, 10, 2, 6)', 'line-color': '#FFFFFF', 'opacity': 1, 'z-index': 30 } }
{ selector: 'edge:selected', style: { 'width': 'mapData(weight, 1, 10, 2, 6)', 'line-color': '#FFFFFF', 'opacity': 1, 'z-index': 30 } },
// Styles for dragging effects
{ selector: 'node.grabbed', style: {
'border-width': '3px',
'border-color': '#FFFFFF',
'border-opacity': 1
}}
],
// Update layout for better visualization of post-tag connections
layout: {
@ -537,11 +543,20 @@ const nodeTypeCounts = {
// Zoom controls
document.getElementById('zoom-in')?.addEventListener('click', () => cy.zoom(cy.zoom() * 1.2));
document.getElementById('zoom-out')?.addEventListener('click', () => cy.zoom(cy.zoom() / 1.2));
// Reset button functionality
document.getElementById('reset-graph')?.addEventListener('click', () => {
// Reset zoom and position
cy.fit(null, 30);
// Reset all filters and highlighting
cy.elements().removeClass('faded highlighted filtered');
// Reset active filter buttons
const allFilterButton = document.querySelector('.graph-filter[data-filter="all"]');
if (allFilterButton) allFilterButton.click();
// Close node details panel if open
if (nodeDetailsEl) nodeDetailsEl.classList.remove('active');
// Reset any selected nodes
cy.$(':selected').unselect();
});
// Add mouse wheel zoom controls
@ -561,6 +576,41 @@ const nodeTypeCounts = {
}
}
// Add Obsidian-like dragging behavior
// When dragging a node, connected nodes follow with a damping effect
cy.on('drag', 'node', function(e) {
const node = e.target;
const neighbors = node.neighborhood('node');
// Add grabbed class for styling
node.addClass('grabbed');
if (neighbors.length > 0) {
neighbors.forEach(neighbor => {
// Don't move nodes that are being manually dragged by the user
if (!neighbor.grabbed()) {
// Calculate the position to move the neighbor node
// This creates a "pull" effect where neighbors follow but with resistance
// The 0.2 factor controls how much the neighbor follows (smaller = less movement)
const damping = 0.2;
const dx = node.position('x') - neighbor.position('x');
const dy = node.position('y') - neighbor.position('y');
// Apply the position change with damping
neighbor.position({
x: neighbor.position('x') + dx * damping,
y: neighbor.position('y') + dy * damping
});
}
});
}
});
cy.on('dragfree', 'node', function(e) {
// Remove grabbed class when drag ends
e.target.removeClass('grabbed');
});
// Connect search input if it exists
const searchInput = document.getElementById('search-input');
if (searchInput) {
@ -1108,3 +1158,4 @@ const nodeTypeCounts = {
font-size: 0.75rem;
}
}
</style>

View File

@ -1,17 +1,21 @@
// src/pages/search-index.json.js
// Generates a JSON file with all posts for client-side search
// Generates a JSON file with content from all collections for site-wide search
import { getCollection } from 'astro:content';
export async function get() {
// Get all posts
const allPosts = await getCollection('posts', ({ data }) => {
// Get content from all collections
const posts = await getCollection('posts', ({ data }) => {
// Exclude draft posts in production
return import.meta.env.PROD ? !data.draft : true;
});
}).catch(() => []);
const projects = await getCollection('projects').catch(() => []);
const configurations = await getCollection('configurations').catch(() => []);
const externalPosts = await getCollection('external-posts').catch(() => []);
// Transform posts into search-friendly format
const searchablePosts = allPosts.map(post => ({
const searchablePosts = posts.map(post => ({
slug: post.slug,
title: post.data.title,
description: post.data.description || '',
@ -19,14 +23,60 @@ export async function get() {
category: post.data.category || 'Uncategorized',
tags: post.data.tags || [],
readTime: post.data.readTime || '5 min read',
type: 'post',
url: `/posts/${post.slug}/`
}));
// Transform projects
const searchableProjects = projects.map(project => ({
slug: project.slug,
title: project.data.title,
description: project.data.description || '',
pubDate: project.data.pubDate ? new Date(project.data.pubDate).toISOString() : '',
category: project.data.category || 'Projects',
tags: project.data.tags || [],
type: 'project',
url: `/projects/${project.slug}/`
}));
// Transform configurations
const searchableConfigurations = configurations.map(config => ({
slug: config.slug,
title: config.data.title,
description: config.data.description || '',
pubDate: config.data.pubDate ? new Date(config.data.pubDate).toISOString() : '',
category: config.data.category || 'Configurations',
tags: config.data.tags || [],
type: 'configuration',
url: `/configurations/${config.slug}/`
}));
// Transform external posts
const searchableExternalPosts = externalPosts.map(post => ({
slug: post.slug,
title: post.data.title,
description: post.data.description || '',
pubDate: post.data.pubDate ? new Date(post.data.pubDate).toISOString() : '',
category: post.data.category || 'External',
tags: post.data.tags || [],
type: 'external',
url: post.data.url // Use the external URL directly
}));
// Combine all searchable content
const allSearchableContent = [
...searchablePosts,
...searchableProjects,
...searchableConfigurations,
...searchableExternalPosts
];
// Return JSON
return {
body: JSON.stringify(searchablePosts),
body: JSON.stringify(allSearchableContent),
headers: {
'Content-Type': 'application/json',
'Cache-Control': 'max-age=3600'
}
}
};
}