Merge pull request 'fresh-main' (#7) from fresh-main into main

Reviewed-on: https://gitea.argobox.com/KeyArgo/laforceit-blog/pulls/7
This commit is contained in:
argonaut 2025-04-26 20:46:11 +00:00
commit ccae3a8c86
21 changed files with 4925 additions and 3740 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

@ -423,8 +423,8 @@ const currentPath = Astro.url.pathname;
}
});
// Search functionality - client-side post filtering
const searchResults = document.getElementById('search-results');
// Search functionality - client-side site-wide filtering (User provided version)
const searchResults = document.getElementById('search-results'); // Assuming this ID exists in your dropdown HTML
// Function to perform search
const performSearch = async (query) => {
@ -437,39 +437,68 @@ const currentPath = Astro.url.pathname;
}
try {
// This would ideally be a server-side search or a pre-built index
// For now, we'll just fetch all posts and filter client-side
const response = await fetch('/search-index.json');
// Fetch the search index that contains all site content
const response = await fetch('/search-index.json'); // Ensure this path is correct based on your build output
if (!response.ok) throw new Error('Failed to fetch search data');
const posts = await response.json();
const results = posts.filter(post => {
const allContent = await response.json();
const results = allContent.filter(item => {
const lowerQuery = query.toLowerCase();
return (
post.title.toLowerCase().includes(lowerQuery) ||
post.description?.toLowerCase().includes(lowerQuery) ||
post.tags?.some(tag => tag.toLowerCase().includes(lowerQuery))
item.title.toLowerCase().includes(lowerQuery) ||
item.description?.toLowerCase().includes(lowerQuery) ||
item.tags?.some(tag => tag.toLowerCase().includes(lowerQuery)) ||
item.category?.toLowerCase().includes(lowerQuery)
);
}).slice(0, 5); // Limit to 5 results
}).slice(0, 8); // Limit to 8 results for better UI
// Display results
if (searchResults) {
if (results.length > 0) {
searchResults.innerHTML = results.map(post => `
<div class="search-result-item" data-url="/posts/${post.slug}/">
<div class="search-result-title">${post.title}</div>
<div class="search-result-snippet">${post.description || ''}</div>
searchResults.innerHTML = results.map(item => {
// Create type badge
let typeBadge = '';
switch(item.type) {
case 'post':
typeBadge = '<span class="result-type post">Blog</span>';
break;
case 'project':
typeBadge = '<span class="result-type project">Project</span>';
break;
case 'configuration':
typeBadge = '<span class="result-type config">Config</span>';
break;
case 'external':
typeBadge = '<span class="result-type external">External</span>';
break;
default:
typeBadge = '<span class="result-type">Content</span>';
}
return `
<div class="search-result-item" data-url="${item.url}">
<div class="search-result-header">
<div class="search-result-title">${item.title}</div>
${typeBadge}
</div>
`).join('');
<div class="search-result-snippet">${item.description || ''}</div>
${item.tags && item.tags.length > 0 ?
`<div class="search-result-tags">
${item.tags.slice(0, 3).map(tag => `<span class="search-tag">${tag}</span>`).join('')}
</div>` : ''
}
</div>
`;
}).join('');
// Add click handlers to results
document.querySelectorAll('.search-result-item').forEach(item => {
item.addEventListener('click', () => {
window.location.href = item.dataset.url;
window.location.href = item.dataset.url; // Navigate to the item's URL
});
});
} else {
searchResults.innerHTML = '<div class="no-results">No matching posts found</div>';
searchResults.innerHTML = '<div class="no-results">No matching content found</div>';
}
}
} catch (error) {
@ -480,7 +509,7 @@ const currentPath = Astro.url.pathname;
}
};
// Search input event handler
// Search input event handler with debounce
let searchTimeout;
searchInput?.addEventListener('input', (e) => {
clearTimeout(searchTimeout);
@ -489,17 +518,18 @@ const currentPath = Astro.url.pathname;
}, 300); // Debounce to avoid too many searches while typing
});
// Handle search form submission
// Handle search form submission (if your input is inside a form)
const searchForm = searchInput?.closest('form');
searchForm?.addEventListener('submit', (e) => {
e.preventDefault();
e.preventDefault(); // Prevent default form submission
performSearch(searchInput.value);
});
// Handle search-submit button click
const searchSubmit = document.querySelector('.search-submit');
// Handle search-submit button click (if you have a separate submit button)
const searchSubmit = document.querySelector('.search-submit'); // Adjust selector if needed
searchSubmit?.addEventListener('click', () => {
performSearch(searchInput?.value || '');
});
});
}); // End of DOMContentLoaded
</script>

View File

@ -8,6 +8,8 @@ import Footer from '../components/Footer.astro';
import Terminal from '../components/Terminal.astro';
import KnowledgeGraph from '../components/KnowledgeGraph.astro';
import { getCollection } from 'astro:content';
import { Image } from 'astro:assets';
import { COMMON_COMMANDS, TERMINAL_CONTENT } from '../config/terminal.js';
// Get all blog entries
const allPosts = await getCollection('posts');
@ -22,38 +24,8 @@ const sortedPosts = allPosts.sort((a, b) => {
// Get recent posts (latest 4)
const recentPosts = sortedPosts.slice(0, 4);
// Prepare terminal commands
const terminalCommands = [
{
prompt: "[laforceit@argobox]$ ",
command: "ls -la ./infrastructure",
output: [
"total 20",
"drwxr-xr-x 5 laforceit users 4096 Apr 23 09:15 <span class='highlight'>kubernetes/</span>",
"drwxr-xr-x 3 laforceit users 4096 Apr 20 17:22 <span class='highlight'>docker/</span>",
"drwxr-xr-x 2 laforceit users 4096 Apr 19 14:30 <span class='highlight'>networking/</span>",
"drwxr-xr-x 4 laforceit users 4096 Apr 22 21:10 <span class='highlight'>monitoring/</span>",
"drwxr-xr-x 3 laforceit users 4096 Apr 21 16:45 <span class='highlight'>storage/</span>",
]
},
{
prompt: "[laforceit@argobox]$ ",
command: "find ./posts -type f -name \"*.md\" | wc -l",
output: [`${allPosts.length} posts found`]
},
{
prompt: "[laforceit@argobox]$ ",
command: "kubectl get nodes",
output: [
"NAME STATUS ROLES AGE VERSION",
"argobox-cp1 Ready control-plane,master 92d v1.27.3",
"argobox-cp2 Ready control-plane,master 92d v1.27.3",
"argobox-cp3 Ready control-plane,master 92d v1.27.3",
"argobox-node1 Ready worker 92d v1.27.3",
"argobox-node2 Ready worker 92d v1.27.3"
]
}
];
// Prepare terminal commands - now imported from central config
const terminalCommands = COMMON_COMMANDS;
// Prepare graph data for knowledge map
// Extract categories and tags from posts
@ -364,6 +336,38 @@ const techStack = [
</div>
</div>
</section>
<!-- Terminal Section -->
<section class="terminal-section">
<div class="container">
<div class="row">
<div class="col-12 col-md-6">
<Terminal
title="argobox:~/kubernetes"
promptPrefix={TERMINAL_DEFAULTS.promptPrefix}
height="400px"
command="kubectl get pods -A | head -8"
output={`NAMESPACE NAME READY STATUS RESTARTS AGE
kube-system coredns-66bff467f8-8p7z2 1/1 Running 0 15d
kube-system coredns-66bff467f8-v68vr 1/1 Running 0 15d
kube-system etcd-control-plane 1/1 Running 0 15d
kube-system kube-apiserver-control-plane 1/1 Running 0 15d
kube-system kube-controller-manager-control-plane 1/1 Running 0 15d
kube-system kube-proxy-c84qf 1/1 Running 0 15d
kube-system kube-scheduler-control-plane 1/1 Running 0 15d`}
/>
</div>
<div class="col-12 col-md-6">
<Terminal
title="argobox:~/system"
promptPrefix={TERMINAL_DEFAULTS.promptPrefix}
height="400px"
content={SYSTEM_MONITOR_SEQUENCE.map(item => `<div class="term-blue">${item.prompt}</div><span>$</span> <span class="term-bold">${item.command}</span>\n${item.output.join('\n')}`).join('\n\n')}
/>
</div>
</div>
</div>
</section>
</main>
<Footer slot="footer" />
@ -993,6 +997,32 @@ const techStack = [
background: rgba(226, 232, 240, 0.2);
}
/* Terminal Section */
.terminal-section {
padding: 6rem 0;
background: var(--bg-primary);
position: relative;
}
.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;
}
/* Responsive Design */
@media (max-width: 1024px) {
.hero-content {

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,222 @@
---
// MiniGraph.astro - A standalone mini knowledge graph component
// This component is designed to work independently from the blog structure
// Define props interface
interface Props {
slug: string; // Current post slug
title: string; // Current post title
tags?: string[]; // Current post tags
category?: string; // Current post category
}
// Extract props with defaults
const {
slug,
title,
tags = [],
category = "Uncategorized"
} = Astro.props;
// Generate unique ID for the graph container
const graphId = `graph-${Math.random().toString(36).substring(2, 8)}`;
// Prepare simple graph data for just the post and its tags
const nodes = [
// Current post node
{
id: slug,
label: title,
type: "post"
},
// Tag nodes
...tags.map(tag => ({
id: `tag-${tag}`,
label: tag,
type: "tag"
}))
];
// Create edges connecting post to tags
const edges = tags.map(tag => ({
source: slug,
target: `tag-${tag}`,
type: "post-tag"
}));
// Prepare graph data object
const graphData = { nodes, edges };
---
<!-- Super simple HTML structure -->
<div class="knowledge-graph-wrapper">
<h4 class="graph-title">Post Connections</h4>
<div id={graphId} class="mini-graph-container"></div>
</div>
<!-- Minimal CSS -->
<style>
.knowledge-graph-wrapper {
width: 100%;
margin-bottom: 1rem;
}
.graph-title {
font-size: 1rem;
margin-bottom: 0.5rem;
color: var(--text-primary, #e2e8f0);
}
.mini-graph-container {
width: 100%;
height: 200px;
border-radius: 8px;
overflow: hidden;
border: 1px solid var(--card-border, rgba(56, 189, 248, 0.2));
background: rgba(15, 23, 42, 0.2);
}
</style>
<!-- Standalone initialization script -->
<script define:vars={{ graphId, graphData }}>
// Wait for page to fully load
window.addEventListener('load', function() {
// Retry initialization multiple times in case Cytoscape or the DOM isn't ready yet
let retries = 0;
const maxRetries = 5;
const retryInterval = 500; // ms
function initGraph() {
// Ensure Cytoscape is loaded
if (typeof cytoscape === 'undefined') {
console.warn(`[MiniGraph] Cytoscape not loaded, retry ${retries+1}/${maxRetries}...`);
if (retries < maxRetries) {
retries++;
setTimeout(initGraph, retryInterval);
} else {
console.error("[MiniGraph] Cytoscape library not available after multiple attempts.");
const container = document.getElementById(graphId);
if (container) {
container.innerHTML = '<div style="padding:10px;color:#a0aec0;text-align:center;">Graph library not loaded</div>';
}
}
return;
}
// Verify container exists
const container = document.getElementById(graphId);
if (!container) {
console.error(`[MiniGraph] Container #${graphId} not found.`);
return;
}
try {
// Check if we have any nodes
if (graphData.nodes.length === 0) {
container.innerHTML = '<div style="padding:10px;color:#a0aec0;text-align:center;">No connections</div>';
return;
}
// Initialize Cytoscape
const cy = cytoscape({
container,
elements: [
...graphData.nodes.map(node => ({
data: {
id: node.id,
label: node.label,
type: node.type
}
})),
...graphData.edges.map((edge, index) => ({
data: {
id: `e${index}`,
source: edge.source,
target: edge.target,
type: edge.type
}
}))
],
style: [
// Base node style
{
selector: 'node',
style: {
'background-color': '#3B82F6',
'label': 'data(label)',
'width': 20,
'height': 20,
'font-size': '8px',
'color': '#E2E8F0',
'text-valign': 'bottom',
'text-halign': 'center',
'text-margin-y': 5,
'text-wrap': 'ellipsis',
'text-max-width': '60px'
}
},
// Post node style
{
selector: 'node[type="post"]',
style: {
'background-color': '#06B6D4',
'width': 30,
'height': 30,
'font-size': '9px',
'text-max-width': '80px'
}
},
// Tag node style
{
selector: 'node[type="tag"]',
style: {
'background-color': '#10B981',
'shape': 'diamond',
'width': 18,
'height': 18
}
},
// Edge style
{
selector: 'edge',
style: {
'width': 1,
'line-color': 'rgba(16, 185, 129, 0.6)',
'line-style': 'dashed',
'curve-style': 'bezier',
'opacity': 0.7
}
}
],
// Simple layout for small space
layout: {
name: 'concentric',
concentric: function(node) {
return node.data('type') === 'post' ? 10 : 1;
},
levelWidth: function() { return 1; },
minNodeSpacing: 50,
animate: false
}
});
// Make nodes clickable
cy.on('tap', 'node[type="tag"]', function(evt) {
const node = evt.target;
const tagName = node.data('label');
window.location.href = `/tag/${tagName}`;
});
// Fit graph to container
cy.fit(undefined, 20);
} catch (error) {
console.error('[MiniGraph] Error initializing graph:', error);
container.innerHTML = '<div style="padding:10px;color:#a0aec0;text-align:center;">Error loading graph</div>';
}
}
// Start initialization attempt
initGraph();
});
</script>

View File

@ -0,0 +1,341 @@
---
// MiniKnowledgeGraph.astro - Inline version that replaces the Tags section
// Designed to work within the existing sidebar structure
export interface GraphNode {
id: string;
label: string;
type: 'post' | 'tag' | 'category';
url?: string;
}
export interface GraphEdge {
source: string;
target: string;
type: 'post-tag' | 'post-post';
}
interface Props {
currentPost: any;
relatedPosts?: any[];
}
const { currentPost, relatedPosts = [] } = Astro.props;
// Generate unique ID for the graph container
const graphId = `mini-cy-${Math.random().toString(36).substring(2, 9)}`;
// Ensure currentPost has necessary properties
const safeCurrentPost = {
id: currentPost.slug || 'current-post',
title: currentPost.data?.title || 'Current Post',
tags: currentPost.data?.tags || [],
category: currentPost.data?.category || 'Uncategorized',
};
// Prepare graph data
const nodes: GraphNode[] = [];
const edges: GraphEdge[] = [];
const addedTagIds = new Set<string>();
const addedPostIds = new Set<string>();
// Add current post node
nodes.push({
id: safeCurrentPost.id,
label: safeCurrentPost.title,
type: 'post',
url: `/posts/${safeCurrentPost.id}/`
});
addedPostIds.add(safeCurrentPost.id);
// Add tags from current post
safeCurrentPost.tags.forEach((tag: string) => {
const tagId = `tag-${tag}`;
// Only add if not already added
if (!addedTagIds.has(tagId)) {
nodes.push({
id: tagId,
label: tag,
type: 'tag',
url: `/tag/${tag}/`
});
addedTagIds.add(tagId);
}
// Add edge from current post to tag
edges.push({
source: safeCurrentPost.id,
target: tagId,
type: 'post-tag'
});
});
// Add related posts and their connections
if (relatedPosts && relatedPosts.length > 0) {
relatedPosts.forEach(post => {
if (!post) return;
const postId = post.slug || `post-${Math.random().toString(36).substring(2, 9)}`;
// Skip if already added or is the current post
if (addedPostIds.has(postId) || postId === safeCurrentPost.id) {
return;
}
// Add related post node
nodes.push({
id: postId,
label: post.data?.title || 'Related Post',
type: 'post',
url: `/posts/${postId}/`
});
addedPostIds.add(postId);
// Add edge from current post to related post
edges.push({
source: safeCurrentPost.id,
target: postId,
type: 'post-post'
});
// Add shared tags and their connections
const postTags = post.data?.tags || [];
postTags.forEach((tag: string) => {
// Only add connections for tags that the current post also has
if (safeCurrentPost.tags.includes(tag)) {
const tagId = `tag-${tag}`;
// Add edge from related post to shared tag
edges.push({
source: postId,
target: tagId,
type: 'post-tag'
});
}
});
});
}
// Generate graph data
const graphData = { nodes, edges };
---
<div class="sidebar-card knowledge-graph-card">
<h3 class="sidebar-title">Post Connections</h3>
<div class="mini-knowledge-graph">
<div id={graphId} class="mini-cy"></div>
</div>
</div>
<style>
.knowledge-graph-card {
margin-bottom: 1.5rem;
}
.sidebar-title {
margin-bottom: 1rem;
font-size: 1.1rem;
color: var(--text-primary);
}
.mini-knowledge-graph {
position: relative;
width: 100%;
height: 200px;
}
.mini-cy {
width: 100%;
height: 100%;
border-radius: 8px;
overflow: hidden;
border: 1px solid var(--card-border, rgba(56, 189, 248, 0.2));
background: rgba(15, 23, 42, 0.2);
}
</style>
<script define:vars={{ graphId, graphData }}>
// Initialize the miniature knowledge graph
function initializeMiniGraph() {
// Ensure Cytoscape is available
if (typeof cytoscape === 'undefined') {
console.error('[MiniKnowledgeGraph] Cytoscape library not loaded.');
return;
}
// Find the container
const container = document.getElementById(graphId);
if (!container) {
console.error(`[MiniKnowledgeGraph] Container #${graphId} not found.`);
return;
}
try {
// Check if we have any nodes to display
if (!graphData.nodes || graphData.nodes.length === 0) {
console.warn('[MiniKnowledgeGraph] No nodes to display.');
container.innerHTML = '<div style="display:flex;height:100%;align-items:center;justify-content:center;color:var(--text-secondary);">No connections available</div>';
return;
}
// Initialize Cytoscape with improved layout parameters for small space
const cy = cytoscape({
container,
elements: [
...graphData.nodes.map(node => ({
data: {
id: node.id,
label: node.label,
type: node.type,
url: node.url
}
})),
...graphData.edges.map((edge, index) => ({
data: {
id: `e${index}`,
source: edge.source,
target: edge.target,
type: edge.type
}
}))
],
style: [
// Node styling
{
selector: 'node',
style: {
'background-color': '#3B82F6', // Default blue for posts
'label': 'data(label)',
'width': 15,
'height': 15,
'font-size': '8px',
'color': '#E2E8F0',
'text-valign': 'bottom',
'text-halign': 'center',
'text-margin-y': 4,
'text-wrap': 'ellipsis',
'text-max-width': '60px',
'border-width': 1,
'border-color': '#0F1219',
'border-opacity': 0.8
}
},
// Post node specific styles
{
selector: 'node[type="post"]',
style: {
'background-color': '#3B82F6', // Blue for posts
'shape': 'ellipse',
'width': 18,
'height': 18
}
},
// Current post node (first in the nodes array)
{
selector: `#${graphData.nodes[0]?.id}`,
style: {
'background-color': '#06B6D4', // Cyan for current post
'width': 25,
'height': 25,
'border-width': 2,
'border-color': '#E2E8F0'
}
},
// Tag node specific styles
{
selector: 'node[type="tag"]',
style: {
'background-color': '#10B981', // Green for tags
'shape': 'diamond',
'width': 15,
'height': 15
}
},
// Edge styles
{
selector: 'edge',
style: {
'width': 1,
'line-color': 'rgba(226, 232, 240, 0.4)',
'curve-style': 'bezier',
'opacity': 0.6
}
},
// Post-tag edge specific styles
{
selector: 'edge[type="post-tag"]',
style: {
'line-color': 'rgba(16, 185, 129, 0.6)', // Green
'line-style': 'dashed'
}
},
// Post-post edge specific styles
{
selector: 'edge[type="post-post"]',
style: {
'line-color': 'rgba(59, 130, 246, 0.6)', // Blue
'line-style': 'solid',
'width': 1.5
}
},
// Hover styles
{
selector: 'node:hover',
style: {
'background-color': '#F59E0B', // Amber on hover
'border-color': '#FFFFFF',
'border-width': 2,
'cursor': 'pointer'
}
}
],
// Use a compact layout for sidebar
layout: {
name: 'cose',
animate: false,
fit: true,
padding: 5,
nodeRepulsion: function(node) {
return 10000; // Stronger repulsion to prevent overlap in small space
},
idealEdgeLength: 50,
edgeElasticity: 0.45,
nestingFactor: 0.1,
gravity: 0.25,
numIter: 1500,
initialTemp: 1000,
coolingFactor: 0.99,
minTemp: 1.0
}
});
// Add click event for nodes
cy.on('tap', 'node', function(evt) {
const node = evt.target;
const url = node.data('url');
if (url) {
window.location.href = url;
}
});
// Center the graph
cy.fit(undefined, 10);
} catch (error) {
console.error('[MiniKnowledgeGraph] Error initializing Cytoscape:', error);
container.innerHTML = '<div style="padding:10px;color:var(--text-secondary);">Error loading graph</div>';
}
}
// Wait for DOM to be ready and ensure proper initialization
document.addEventListener('DOMContentLoaded', function() {
// Delay initialization slightly to ensure container has dimensions
setTimeout(initializeMiniGraph, 100);
});
// Also handle the case where the script loads after DOMContentLoaded
if (document.readyState === 'complete' || document.readyState === 'interactive') {
setTimeout(initializeMiniGraph, 100);
}
</script>

View File

@ -2,37 +2,32 @@
// Terminal.astro
// A component that displays terminal-like interface with animated commands and outputs
interface Command {
export interface Props {
title?: string;
height?: string;
showTitleBar?: boolean;
showPrompt?: boolean;
commands?: {
prompt: string;
command: string;
output?: string[];
delay?: number;
}
interface Props {
commands: Command[];
title?: string;
theme?: 'dark' | 'light';
interactive?: boolean;
showTitleBar?: boolean;
}[];
}
const {
commands,
title = "argobox:~/homelab",
theme = "dark",
interactive = false,
showTitleBar = true
title = "terminal",
height = "auto",
showTitleBar = true,
showPrompt = true,
commands = []
} = Astro.props;
// Make the last command have the typing effect
const lastIndex = commands.length - 1;
// Conditionally add classes based on props
const terminalClasses = `terminal-box ${theme === 'light' ? 'terminal-light' : ''}`;
---
<div class={terminalClasses}>
<div class="terminal-box">
{showTitleBar && (
<div class="terminal-header">
<div class="terminal-dots">
@ -59,7 +54,7 @@ const terminalClasses = `terminal-box ${theme === 'light' ? 'terminal-light' : '
</div>
)}
<div class="terminal-content">
<div class="terminal-content" style={`height: ${height};`}>
{commands.map((cmd, index) => (
<div class="terminal-block">
<div class="terminal-line">
@ -78,15 +73,6 @@ const terminalClasses = `terminal-box ${theme === 'light' ? 'terminal-light' : '
</div>
))}
{interactive && (
<div class="terminal-block terminal-interactive">
<div class="terminal-line">
<span class="terminal-prompt">guest@argobox:~$</span>
<input type="text" class="terminal-input" placeholder="Type 'help' for available commands" />
</div>
</div>
)}
<div class="terminal-cursor"></div>
</div>
</div>
@ -107,34 +93,6 @@ const terminalClasses = `terminal-box ${theme === 'light' ? 'terminal-light' : '
position: relative;
}
/* Light theme */
.terminal-light {
background: #f0f4f8;
border-color: #d1dce5;
box-shadow: 0 0 30px rgba(0, 0, 0, 0.07);
color: #1a202c;
}
.terminal-light .terminal-prompt {
color: #2a7ac0;
}
.terminal-light .terminal-command {
color: #1a202c;
}
.terminal-light .terminal-output {
color: #4a5568;
}
.terminal-light .terminal-header {
border-bottom: 1px solid #e2e8f0;
}
.terminal-light .terminal-title {
color: #4a5568;
}
/* Header */
.terminal-header {
display: flex;
@ -261,22 +219,6 @@ const terminalClasses = `terminal-box ${theme === 'light' ? 'terminal-light' : '
color: #ef4444;
}
/* Interactive elements */
.terminal-interactive {
margin-top: 1rem;
}
.terminal-input {
background: transparent;
border: none;
color: var(--text-primary, #e2e8f0);
font-family: var(--font-mono, 'JetBrains Mono', monospace);
font-size: 0.9rem;
flex: 1;
outline: none;
caret-color: transparent; /* Hide default cursor */
}
/* Blinking cursor */
.terminal-cursor {
position: absolute;
@ -290,10 +232,6 @@ const terminalClasses = `terminal-box ${theme === 'light' ? 'terminal-light' : '
opacity: 0;
}
.terminal-interactive:has(.terminal-input:focus) ~ .terminal-cursor {
opacity: 1;
}
/* Typing effect */
.terminal-typing {
position: relative;
@ -335,13 +273,6 @@ const terminalClasses = `terminal-box ${theme === 'light' ? 'terminal-light' : '
.terminal-box:hover .terminal-dot-green {
background: #34d399;
}
@media (max-width: 768px) {
.terminal-box {
height: 300px;
font-size: 0.8rem;
}
}
</style>
<script>
@ -383,7 +314,8 @@ const terminalClasses = `terminal-box ${theme === 'light' ? 'terminal-light' : '
const cursor = typingElement.closest('.terminal-box').querySelector('.terminal-cursor');
if (cursor) {
const rect = typingElement.getBoundingClientRect();
const parentRect = typingElement.closest('.terminal-content').getBoundingClientRect();
if (terminalContent) {
const parentRect = terminalContent.getBoundingClientRect();
// Position cursor after the last character
cursor.style.opacity = '1';
@ -394,215 +326,12 @@ const terminalClasses = `terminal-box ${theme === 'light' ? 'terminal-light' : '
}
}
}
}
typeWriter();
}, 1000 * elementIndex); // Sequential delay for multiple typing elements
});
// Interactive terminal functionality
const interactiveTerminals = document.querySelectorAll('.terminal-interactive');
interactiveTerminals.forEach(terminal => {
const input = terminal.querySelector('.terminal-input');
const terminalContent = terminal.closest('.terminal-content');
const prompt = terminal.querySelector('.terminal-prompt').textContent;
if (!input || !terminalContent) return;
// Position cursor when input is focused
input.addEventListener('focus', () => {
const cursor = terminal.closest('.terminal-box').querySelector('.terminal-cursor');
if (cursor) {
const rect = input.getBoundingClientRect();
const parentRect = terminalContent.getBoundingClientRect();
cursor.style.left = `${rect.left - parentRect.left + input.value.length * 8}px`;
cursor.style.top = `${rect.top - parentRect.top}px`;
cursor.style.height = `${rect.height}px`;
}
});
// Update cursor position as user types
input.addEventListener('input', () => {
const cursor = terminal.closest('.terminal-box').querySelector('.terminal-cursor');
if (cursor) {
const rect = input.getBoundingClientRect();
const parentRect = terminalContent.getBoundingClientRect();
cursor.style.left = `${rect.left - parentRect.left + 8 * (prompt.length + input.value.length) + 8}px`;
}
});
// Process command on Enter
input.addEventListener('keydown', (e) => {
if (e.key === 'Enter') {
e.preventDefault();
const command = input.value.trim();
if (!command) return;
// Create new command block
const commandBlock = document.createElement('div');
commandBlock.className = 'terminal-block';
const commandLine = document.createElement('div');
commandLine.className = 'terminal-line';
const promptSpan = document.createElement('span');
promptSpan.className = 'terminal-prompt';
promptSpan.textContent = prompt;
const commandSpan = document.createElement('span');
commandSpan.className = 'terminal-command';
commandSpan.textContent = command;
commandLine.appendChild(promptSpan);
commandLine.appendChild(commandSpan);
commandBlock.appendChild(commandLine);
// Process command and add output
const output = processCommand(command);
if (output && output.length > 0) {
const outputDiv = document.createElement('div');
outputDiv.className = 'terminal-output';
output.forEach(line => {
const lineDiv = document.createElement('div');
lineDiv.className = 'terminal-output-line';
lineDiv.innerHTML = line;
outputDiv.appendChild(lineDiv);
});
commandBlock.appendChild(outputDiv);
}
// Insert before the interactive block
terminal.parentNode.insertBefore(commandBlock, terminal);
// Clear input
input.value = '';
// Scroll to bottom
terminalContent.scrollTop = terminalContent.scrollHeight;
}
});
// Define available commands and their outputs
function processCommand(cmd) {
const commands = {
'help': [
'<span class="highlight">Available commands:</span>',
' help - Display this help message',
' clear - Clear the terminal',
' ls - List available resources',
' cat [file] - View file contents',
' about - About this site',
' status - Check system status',
' uname -a - Display system information'
],
'clear': [],
'about': [
'<span class="highlight">LaForceIT</span>',
'A tech blog focused on home lab infrastructure, Kubernetes,',
'Docker, and DevOps best practices.',
'',
'Created by Daniel LaForce',
'Type <span class="highlight">\'help\'</span> to see available commands'
],
'uname -a': [
'ArgoBox-Lite 5.15.0-69-generic #76-Ubuntu SMP Fri Mar 17 17:19:29 UTC 2023 x86_64',
'Hardware: ProxmoxVE 8.0.4 | Intel(R) Core(TM) i7-12700K | 64GB RAM'
],
'status': [
'<span class="highlight">System Status:</span>',
'<span class="success">✓</span> ArgoBox: Online',
'<span class="success">✓</span> Kubernetes: Running',
'<span class="success">✓</span> Docker Registry: Active',
'<span class="success">✓</span> Gitea: Online',
'<span class="warning">⚠</span> Monitoring: Degraded - Check Grafana instance'
],
'ls': [
'<span class="highlight">Available resources:</span>',
'kubernetes/ docker/ networking/',
'homelab.md configs.yaml setup-guide.md',
'resources.json projects.md'
]
};
// Check for cat command
if (cmd.startsWith('cat ')) {
const file = cmd.split(' ')[1];
const fileContents = {
'homelab.md': [
'<span class="highlight">## HomeLab Setup Guide</span>',
'This document outlines my personal home lab setup,',
'including hardware specifications, network configuration,',
'and installed services.',
'',
'See the full guide at: /homelab'
],
'configs.yaml': [
'apiVersion: v1',
'kind: ConfigMap',
'metadata:',
' name: argobox-config',
' namespace: default',
'data:',
' POSTGRES_HOST: "db.local"',
' REDIS_HOST: "cache.local"',
' ...'
],
'setup-guide.md': [
'<span class="highlight">## Quick Start Guide</span>',
'1. Install Proxmox on bare metal hardware',
'2. Deploy K3s cluster using Ansible playbooks',
'3. Configure storage using Longhorn',
'4. Deploy ArgoCD for GitOps workflow',
'...'
],
'resources.json': [
'{',
' "cpu": "12 cores",',
' "memory": "64GB",',
' "storage": "8TB",',
' "network": "10Gbit"',
'}'
],
'projects.md': [
'<span class="highlight">## Current Projects</span>',
'- <span class="success">ArgoBox</span>: Self-hosted deployment platform',
'- <span class="success">K8s Monitor</span>: Custom Kubernetes dashboard',
'- <span class="warning">Media Server</span>: In progress',
'- <span class="highlight">See all projects at:</span> /projects'
]
};
if (fileContents[file]) {
return fileContents[file];
} else {
return [`<span class="error">Error: File '${file}' not found.</span>`];
}
}
// Handle unknown commands
if (!commands[cmd]) {
return [`<span class="error">Command not found: ${cmd}</span>`, 'Type <span class="highlight">\'help\'</span> to see available commands'];
}
// Handle clear command
if (cmd === 'clear') {
// Remove all blocks except the interactive one
const blocks = terminalContent.querySelectorAll('.terminal-block:not(.terminal-interactive)');
blocks.forEach(block => block.remove());
return [];
}
return commands[cmd];
}
});
// Button interactions
const minButtons = document.querySelectorAll('.terminal-button-minimize');
const maxButtons = document.querySelectorAll('.terminal-button-maximize');
@ -610,24 +339,31 @@ const terminalClasses = `terminal-box ${theme === 'light' ? 'terminal-light' : '
minButtons.forEach(button => {
button.addEventListener('click', () => {
const terminalBox = button.closest('.terminal-box');
if (terminalBox) {
terminalBox.classList.toggle('minimized');
if (terminalBox.classList.contains('minimized')) {
const content = terminalBox.querySelector('.terminal-content');
if (content) {
terminalBox.dataset.prevHeight = terminalBox.style.height;
terminalBox.style.height = '40px';
content.style.display = 'none';
}
} else {
const content = terminalBox.querySelector('.terminal-content');
if (content) {
terminalBox.style.height = terminalBox.dataset.prevHeight || '340px';
content.style.display = 'block';
}
}
}
});
});
maxButtons.forEach(button => {
button.addEventListener('click', () => {
const terminalBox = button.closest('.terminal-box');
if (terminalBox) {
terminalBox.classList.toggle('maximized');
if (terminalBox.classList.contains('maximized')) {
@ -644,7 +380,6 @@ const terminalClasses = `terminal-box ${theme === 'light' ? 'terminal-light' : '
terminalBox.style.zIndex = '9999';
terminalBox.style.borderRadius = '0';
} else {
const content = terminalBox.querySelector('.terminal-content');
terminalBox.style.position = terminalBox.dataset.prevPosition || 'relative';
terminalBox.style.width = terminalBox.dataset.prevWidth || '100%';
terminalBox.style.height = terminalBox.dataset.prevHeight || '340px';
@ -653,6 +388,7 @@ const terminalClasses = `terminal-box ${theme === 'light' ? 'terminal-light' : '
terminalBox.style.top = 'auto';
terminalBox.style.left = 'auto';
}
}
});
});
});

414
src/config/terminal.js Normal file
View File

@ -0,0 +1,414 @@
/**
* Terminal Configuration
* Central configuration for the Terminal component across the site
*/
// Default terminal prompt settings
export const TERMINAL_DEFAULTS = {
promptPrefix: "[laforceit@argobox]",
title: "argobox:~/blog",
theme: "dark", // Default theme (dark or light)
height: "auto",
showTitleBar: true,
showPrompt: true
};
// Commonly used commands
export const COMMON_COMMANDS = [
{
prompt: TERMINAL_DEFAULTS.promptPrefix + "$",
command: "ls -la ./infrastructure",
output: [
"total 20",
"drwxr-xr-x 5 laforceit users 4096 Apr 23 09:15 <span class='highlight'>kubernetes/</span>",
"drwxr-xr-x 3 laforceit users 4096 Apr 20 17:22 <span class='highlight'>docker/</span>",
"drwxr-xr-x 2 laforceit users 4096 Apr 19 14:30 <span class='highlight'>networking/</span>",
"drwxr-xr-x 4 laforceit users 4096 Apr 22 21:10 <span class='highlight'>monitoring/</span>",
"drwxr-xr-x 3 laforceit users 4096 Apr 21 16:45 <span class='highlight'>storage/</span>",
]
},
{
prompt: TERMINAL_DEFAULTS.promptPrefix + "$",
command: "grep -r \"kubernetes\" --include=\"*.md\" ./posts | wc -l",
output: ["7 matches found"]
},
{
prompt: TERMINAL_DEFAULTS.promptPrefix + "$",
command: "kubectl get nodes",
output: [
"NAME STATUS ROLES AGE VERSION",
"argobox-cp1 Ready control-plane,master 92d v1.27.3",
"argobox-cp2 Ready control-plane,master 92d v1.27.3",
"argobox-cp3 Ready control-plane,master 92d v1.27.3",
"argobox-node1 Ready worker 92d v1.27.3",
"argobox-node2 Ready worker 92d v1.27.3"
]
}
];
// Advanced blog search command sequence
export const BLOG_SEARCH_SEQUENCE = [
{
prompt: TERMINAL_DEFAULTS.promptPrefix + "$",
command: "cd ./posts && grep -r \"homelab\" --include=\"*.md\" | sort | head -5",
output: [
"<span class='term-green'>homelab-essentials.md</span>:<span class='term-blue'>title:</span> \"Essential Tools for Your Home Lab Setup\"",
"<span class='term-green'>homelab-essentials.md</span>:<span class='term-blue'>description:</span> \"A curated list of must-have tools for building your home lab infrastructure\"",
"<span class='term-green'>kubernetes-at-home.md</span>:<span class='term-blue'>title:</span> \"Running Kubernetes in Your Homelab\"",
"<span class='term-green'>proxmox-cluster.md</span>:<span class='term-blue'>description:</span> \"Building a resilient homelab foundation with Proxmox VE cluster\"",
"<span class='term-green'>storage-solutions.md</span>:<span class='term-blue'>body:</span> \"...affordable homelab storage solutions for a growing collection of VMs and containers...\""
]
},
{
prompt: TERMINAL_DEFAULTS.promptPrefix + "$",
command: "find ./posts -type f -name \"*.md\" | xargs wc -l | sort -nr | head -3",
output: [
"2567 total",
" 842 ./posts/kubernetes-the-hard-way.md",
" 756 ./posts/home-automation-guide.md",
" 523 ./posts/proxmox-cluster.md"
]
}
];
// System monitoring sequence
export const SYSTEM_MONITOR_SEQUENCE = [
{
prompt: TERMINAL_DEFAULTS.promptPrefix + "$",
command: "htop",
output: [
"<span class='term-purple'>Tasks:</span> <span class='term-cyan'>143</span> total, <span class='term-green'>4</span> running, <span class='term-yellow'>139</span> sleeping, <span class='term-red'>0</span> stopped, <span class='term-red'>0</span> zombie",
"<span class='term-purple'>%Cpu(s):</span> <span class='term-green'>12.5</span> us, <span class='term-blue'>4.2</span> sy, <span class='term-cyan'>0.0</span> ni, <span class='term-green'>82.3</span> id, <span class='term-yellow'>0.7</span> wa, <span class='term-red'>0.0</span> hi, <span class='term-red'>0.3</span> si, <span class='term-cyan'>0.0</span> st",
"<span class='term-purple'>MiB Mem:</span> <span class='term-cyan'>32102.3</span> total, <span class='term-green'>12023.4</span> free, <span class='term-yellow'>10654.8</span> used, <span class='term-blue'>9424.1</span> buff/cache",
"<span class='term-purple'>MiB Swap:</span> <span class='term-cyan'>16384.0</span> total, <span class='term-green'>16384.0</span> free, <span class='term-yellow'>0.0</span> used. <span class='term-green'>20223.3</span> avail Mem",
"",
" <span class='term-cyan'>PID USER PR NI VIRT RES SHR S %CPU %MEM TIME+ COMMAND</span>",
"<span class='term-yellow'> 23741 laforcei 20 0 4926.0m 257.9m 142.1m S 25.0 0.8 42:36.76 node</span>",
" 22184 root 20 0 743.9m 27.7m 17.6m S 6.2 0.1 27:57.21 dockerd",
" 15532 root 20 0 1735.9m 203.5m 122.1m S 6.2 0.6 124:29.93 k3s-server",
" 1126 prometheu 20 0 1351.5m 113.9m 41.3m S 0.0 0.4 3:12.52 prometheus"
]
},
{
prompt: TERMINAL_DEFAULTS.promptPrefix + "$",
command: "df -h",
output: [
"Filesystem Size Used Avail Use% Mounted on",
"/dev/nvme0n1p2 932G 423G 462G 48% /",
"/dev/nvme1n1 1.8T 1.1T 638G 64% /data",
"tmpfs 16G 12M 16G 1% /run",
"tmpfs 32G 0 32G 0% /dev/shm"
]
},
{
prompt: TERMINAL_DEFAULTS.promptPrefix + "$",
command: "docker stats --no-stream",
output: [
"CONTAINER ID NAME CPU % MEM USAGE / LIMIT MEM % NET I/O BLOCK I/O PIDS",
"7d9915b1f946 blog-site 0.15% 145.6MiB / 32GiB 0.44% 648kB / 4.21MB 12.3MB / 0B 24",
"c7823beac704 prometheus 2.33% 175.2MiB / 32GiB 0.53% 15.5MB / 25.4MB 29.6MB / 12.4MB 15",
"db9d8512f471 postgres 0.03% 96.45MiB / 32GiB 0.29% 85.1kB / 106kB 21.9MB / 63.5MB 11",
"f3b1c9e2a147 grafana 0.42% 78.32MiB / 32GiB 0.24% 5.42MB / 12.7MB 86.4MB / 1.21MB 13"
]
}
];
// Blog deployment sequence
export const BLOG_DEPLOYMENT_SEQUENCE = [
{
prompt: TERMINAL_DEFAULTS.promptPrefix + "$",
command: "git status",
output: [
"On branch <span class='term-cyan'>main</span>",
"Your branch is up to date with 'origin/main'.",
"",
"Changes not staged for commit:",
" (use \"git add <file>...\" to update what will be committed)",
" (use \"git restore <file>...\" to discard changes in working directory)",
" <span class='term-red'>modified: src/content/posts/kubernetes-at-home.md</span>",
" <span class='term-red'>modified: src/components/Terminal.astro</span>",
"",
"Untracked files:",
" (use \"git add <file>...\" to include in what will be committed)",
" <span class='term-red'>src/content/posts/new-homelab-upgrades.md</span>",
"",
"no changes added to commit (use \"git add\" and/or \"git commit -a\")"
]
},
{
prompt: TERMINAL_DEFAULTS.promptPrefix + "$",
command: "git add . && git commit -m \"feat: add new post about homelab upgrades\"",
output: [
"[main <span class='term-green'>f92d47a</span>] <span class='term-cyan'>feat: add new post about homelab upgrades</span>",
" 3 files changed, 214 insertions(+), 12 deletions(-)",
" create mode 100644 src/content/posts/new-homelab-upgrades.md"
]
},
{
prompt: TERMINAL_DEFAULTS.promptPrefix + "$",
command: "npm run build && npm run deploy",
output: [
"<span class='term-green'>✓</span> Building for production...",
"<span class='term-green'>✓</span> Generating static routes",
"<span class='term-green'>✓</span> Client side rendering with hydration",
"<span class='term-green'>✓</span> Applying optimizations",
"<span class='term-green'>✓</span> Complete! 187 pages generated in 43.2 seconds",
"",
"<span class='term-blue'>Deploying to production environment...</span>",
"<span class='term-green'>✓</span> Upload complete",
"<span class='term-green'>✓</span> CDN cache invalidated",
"<span class='term-green'>✓</span> DNS configuration verified",
"<span class='term-green'>✓</span> Blog is live at https://laforceit.com!"
]
}
];
// Kubernetes operation sequence
export const K8S_OPERATION_SEQUENCE = [
{
prompt: TERMINAL_DEFAULTS.promptPrefix + "$",
command: "kubectl create namespace blog-prod",
output: [
"namespace/blog-prod created"
]
},
{
prompt: TERMINAL_DEFAULTS.promptPrefix + "$",
command: "kubectl apply -f kubernetes/blog-deployment.yaml",
output: [
"deployment.apps/blog-frontend created",
"service/blog-frontend created",
"configmap/blog-config created",
"secret/blog-secrets created"
]
},
{
prompt: TERMINAL_DEFAULTS.promptPrefix + "$",
command: "kubectl get pods -n blog-prod",
output: [
"NAME READY STATUS RESTARTS AGE",
"blog-frontend-7d9b5c7b8d-2xprm 1/1 Running 0 35s",
"blog-frontend-7d9b5c7b8d-8bkpl 1/1 Running 0 35s",
"blog-frontend-7d9b5c7b8d-f9j7s 1/1 Running 0 35s"
]
},
{
prompt: TERMINAL_DEFAULTS.promptPrefix + "$",
command: "kubectl get ingress -n blog-prod",
output: [
"NAME CLASS HOSTS ADDRESS PORTS AGE",
"blog-ingress <none> blog.laforceit.com 192.168.1.50 80, 443 42s"
]
}
];
// Predefined terminal content blocks
export const TERMINAL_CONTENT = {
fileExplorer: `<div class="term-blue">${TERMINAL_DEFAULTS.promptPrefix}</div><span>$</span> <span class="term-bold">ls -la</span>
total 42
drwxr-xr-x 6 laforceit users 4096 Nov 7 22:15 .
drwxr-xr-x 12 laforceit users 4096 Nov 7 20:32 ..
-rw-r--r-- 1 laforceit users 182 Nov 7 22:15 .astro
drwxr-xr-x 2 laforceit users 4096 Nov 7 21:03 components
drwxr-xr-x 3 laforceit users 4096 Nov 7 21:14 content
drwxr-xr-x 4 laforceit users 4096 Nov 7 21:42 layouts
drwxr-xr-x 5 laforceit users 4096 Nov 7 22:10 pages
-rw-r--r-- 1 laforceit users 1325 Nov 7 22:12 package.json`,
tags: `<div class="term-blue">${TERMINAL_DEFAULTS.promptPrefix}</div><span>$</span> <span class="term-bold">cat ./content/tags.txt</span>
cloudflare
coding
containers
devops
digital-garden
docker
file-management
filebrowser
flux
git
gitea
gitops
grafana
homelab
infrastructure
k3s
knowledge-management
kubernetes
learning-in-public
monitoring
networking
observability
obsidian
prometheus
proxmox
quartz
rancher
remote-development
security
self-hosted
terraform
test
tunnels
tutorial
virtualization
vscode`,
blogDeployment: `<div class="term-blue">${TERMINAL_DEFAULTS.promptPrefix}</div><span>$</span> <span class="term-bold">git add src/content/posts/kubernetes-monitoring.md</span>
<div class="term-blue">${TERMINAL_DEFAULTS.promptPrefix}</div><span>$</span> <span class="term-bold">git commit -m "feat: add new article on Kubernetes monitoring"</span>
[main <span class="term-green">8fd43a9</span>] <span class="term-cyan">feat: add new article on Kubernetes monitoring</span>
1 file changed, 147 insertions(+)
create mode 100644 src/content/posts/kubernetes-monitoring.md
<div class="term-blue">${TERMINAL_DEFAULTS.promptPrefix}</div><span>$</span> <span class="term-bold">git push origin main</span>
Enumerating objects: 8, done.
Counting objects: 100% (8/8), done.
Delta compression using up to 8 threads
Compressing objects: 100% (5/5), done.
Writing objects: 100% (5/5), 2.12 KiB | 2.12 MiB/s, done.
Total 5 (delta 3), reused 0 (delta 0), pack-reused 0
remote: Resolving deltas: 100% (3/3), completed with 3 local objects.
<span class="term-green"></span> Deployed to https://laforceit.com
<span class="term-green"></span> Article published successfully`,
k8sInstall: `<div class="term-blue">${TERMINAL_DEFAULTS.promptPrefix}</div><span>$</span> <span class="term-bold">curl -sfL https://get.k3s.io | sh -</span>
[INFO] Finding release for channel stable
[INFO] Using v1.27.4+k3s1 as release
[INFO] Downloading hash https://github.com/k3s-io/k3s/releases/download/v1.27.4+k3s1/sha256sum-amd64.txt
[INFO] Downloading binary https://github.com/k3s-io/k3s/releases/download/v1.27.4+k3s1/k3s
[INFO] Verifying binary download
[INFO] Installing k3s to /usr/local/bin/k3s
[INFO] Creating /usr/local/bin/kubectl symlink to k3s
[INFO] Creating /usr/local/bin/crictl symlink to k3s
[INFO] Creating /usr/local/bin/ctr symlink to k3s
[INFO] Creating killall script /usr/local/bin/k3s-killall.sh
[INFO] Creating uninstall script /usr/local/bin/k3s-uninstall.sh
[INFO] env: Creating environment file /etc/systemd/system/k3s.service.env
[INFO] systemd: Creating service file /etc/systemd/system/k3s.service
[INFO] systemd: Enabling k3s unit
Created symlink /etc/systemd/system/multi-user.target.wants/k3s.service /etc/systemd/system/k3s.service.
[INFO] systemd: Starting k3s
<span class="term-green"></span> K3s has been installed successfully
<div class="term-blue">${TERMINAL_DEFAULTS.promptPrefix}</div><span>$</span> <span class="term-bold">kubectl get pods -A</span>
NAMESPACE NAME READY STATUS RESTARTS AGE
kube-system helm-install-traefik-crd-k7gxl 0/1 Completed 0 2m43s
kube-system helm-install-traefik-pvvhg 0/1 Completed 1 2m43s
kube-system metrics-server-67c658dc48-mxnxp 1/1 Running 0 2m43s
kube-system local-path-provisioner-7b7dc8d6f5-q99nl 1/1 Running 0 2m43s
kube-system coredns-b96499967-nkvnz 1/1 Running 0 2m43s
kube-system svclb-traefik-bd0bfb17-ht8gq 2/2 Running 0 96s
kube-system traefik-7d586bdc47-d6lzr 1/1 Running 0 96s`,
dockerCompose: `<div class="term-blue">${TERMINAL_DEFAULTS.promptPrefix}</div><span>$</span> <span class="term-bold">cat docker-compose.yaml</span>
version: '3.8'
services:
blog:
image: node:18-alpine
restart: unless-stopped
volumes:
- ./:/app
working_dir: /app
command: sh -c "npm install && npm run dev"
ports:
- "3000:3000"
environment:
- NODE_ENV=development
db:
image: postgres:14-alpine
restart: unless-stopped
volumes:
- postgres_data:/var/lib/postgresql/data
environment:
- POSTGRES_PASSWORD=secure_password
- POSTGRES_USER=bloguser
- POSTGRES_DB=blogdb
ports:
- "5432:5432"
volumes:
postgres_data:
<div class="term-blue">${TERMINAL_DEFAULTS.promptPrefix}</div><span>$</span> <span class="term-bold">docker-compose up -d</span>
Creating network "laforceit-blog_default" with the default driver
Creating volume "laforceit-blog_postgres_data" with default driver
Pulling blog (node:18-alpine)...
Pulling db (postgres:14-alpine)...
Creating laforceit-blog_db_1 ... done
Creating laforceit-blog_blog_1 ... done`
};
// Helper function to create terminal presets
export function createTerminalPreset(type) {
switch (type) {
case 'blog-search':
return BLOG_SEARCH_SEQUENCE[Math.floor(Math.random() * BLOG_SEARCH_SEQUENCE.length)];
case 'system-monitor':
return SYSTEM_MONITOR_SEQUENCE[Math.floor(Math.random() * SYSTEM_MONITOR_SEQUENCE.length)];
case 'blog-deploy':
return BLOG_DEPLOYMENT_SEQUENCE[Math.floor(Math.random() * BLOG_DEPLOYMENT_SEQUENCE.length)];
case 'k8s-ops':
return K8S_OPERATION_SEQUENCE[Math.floor(Math.random() * K8S_OPERATION_SEQUENCE.length)];
case 'k8s':
return {
title: "argobox:~/kubernetes",
command: "kubectl get pods -A",
output: `NAMESPACE NAME READY STATUS RESTARTS AGE
kube-system coredns-66bff467f8-8p7z2 1/1 Running 0 15d
kube-system coredns-66bff467f8-v68vr 1/1 Running 0 15d
kube-system etcd-control-plane 1/1 Running 0 15d
kube-system kube-apiserver-control-plane 1/1 Running 0 15d
kube-system kube-controller-manager-control-plane 1/1 Running 0 15d
kube-system kube-proxy-c84qf 1/1 Running 0 15d
kube-system kube-scheduler-control-plane 1/1 Running 0 15d`
};
case 'docker':
return {
title: "argobox:~/docker",
command: "docker ps",
output: `CONTAINER ID IMAGE COMMAND STATUS PORTS NAMES
d834f0efcf2f nginx:latest "/docker-entrypoint.…" Up 2 days 0.0.0.0:80->80/tcp, 0.0.0.0:443->443/tcp web
0b292940b4c0 postgres:13 "docker-entrypoint.s…" Up 2 days 0.0.0.0:5432->5432/tcp db
a834fa3ede06 redis:6 "docker-entrypoint.s…" Up 2 days 0.0.0.0:6379->6379/tcp cache`
};
case 'search':
return {
title: "argobox:~/blog",
command: "grep -r \"kubernetes\" --include=\"*.md\" ./posts | wc -l",
output: "7 matches found"
};
case 'random-cool':
// Pick a random sequence for a cool effect
const sequences = [
TERMINAL_CONTENT.k8sInstall,
TERMINAL_CONTENT.blogDeployment,
TERMINAL_CONTENT.dockerCompose,
...BLOG_SEARCH_SEQUENCE.map(item => `<div class="term-blue">${item.prompt}</div><span>$</span> <span class="term-bold">${item.command}</span>\n${item.output.join('\n')}`),
...SYSTEM_MONITOR_SEQUENCE.map(item => `<div class="term-blue">${item.prompt}</div><span>$</span> <span class="term-bold">${item.command}</span>\n${item.output.join('\n')}`),
...BLOG_DEPLOYMENT_SEQUENCE.map(item => `<div class="term-blue">${item.prompt}</div><span>$</span> <span class="term-bold">${item.command}</span>\n${item.output.join('\n')}`),
...K8S_OPERATION_SEQUENCE.map(item => `<div class="term-blue">${item.prompt}</div><span>$</span> <span class="term-bold">${item.command}</span>\n${item.output.join('\n')}`)
];
return {
title: "argobox:~/cool-stuff",
content: sequences[Math.floor(Math.random() * sequences.length)]
};
default:
return {
title: TERMINAL_DEFAULTS.title,
command: "echo 'Hello from LaForceIT Terminal'",
output: "Hello from LaForceIT Terminal"
};
}
}

View File

@ -57,7 +57,7 @@ const {
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<!-- Theme CSS -->
<link rel="stylesheet" href="/styles/theme.css" />
<link rel="stylesheet" href="/src/styles/theme.css" />
<!-- Cytoscape Library for Knowledge Graph -->
<script src="https://unpkg.com/cytoscape@3.25.0/dist/cytoscape.min.js" is:inline></script>
@ -360,5 +360,90 @@ const {
}
});
</script>
<!-- Add copy to clipboard functionality for code blocks -->
<script>
document.addEventListener('DOMContentLoaded', function() {
// Find all code blocks
const codeBlocks = document.querySelectorAll('pre code');
// Add copy button to each
codeBlocks.forEach((codeBlock, index) => {
// Create container for copy button (to enable positioning)
const container = document.createElement('div');
container.className = 'code-block-container';
container.style.position = 'relative';
// Create copy button
const copyButton = document.createElement('button');
copyButton.className = 'copy-code-button';
copyButton.setAttribute('aria-label', 'Copy code to clipboard');
copyButton.innerHTML = `
<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="copy-icon">
<rect x="9" y="9" width="13" height="13" rx="2" ry="2"></rect>
<path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"></path>
</svg>
<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="check-icon" style="display: none;">
<polyline points="20 6 9 17 4 12"></polyline>
</svg>
`;
// Style the button
copyButton.style.position = 'absolute';
copyButton.style.top = '0.5rem';
copyButton.style.right = '0.5rem';
copyButton.style.padding = '0.25rem';
copyButton.style.background = 'rgba(45, 55, 72, 0.5)';
copyButton.style.border = '1px solid rgba(255, 255, 255, 0.2)';
copyButton.style.borderRadius = '0.25rem';
copyButton.style.cursor = 'pointer';
copyButton.style.zIndex = '10';
copyButton.style.opacity = '0';
copyButton.style.transition = 'opacity 0.2s';
// Add click handler
copyButton.addEventListener('click', () => {
// Get code text
const code = codeBlock.textContent;
// Copy to clipboard
navigator.clipboard.writeText(code).then(() => {
// Show success UI
copyButton.querySelector('.copy-icon').style.display = 'none';
copyButton.querySelector('.check-icon').style.display = 'block';
// Reset after 2 seconds
setTimeout(() => {
copyButton.querySelector('.copy-icon').style.display = 'block';
copyButton.querySelector('.check-icon').style.display = 'none';
}, 2000);
});
});
// Clone the code block
const preElement = codeBlock.parentElement;
const wrapper = preElement.parentElement;
// Create the container structure
container.appendChild(preElement.cloneNode(true));
container.appendChild(copyButton);
// Replace the original pre with our container
wrapper.replaceChild(container, preElement);
// Update the reference to the new code block
const newCodeBlock = container.querySelector('code');
// Add hover behavior
container.addEventListener('mouseenter', () => {
copyButton.style.opacity = '1';
});
container.addEventListener('mouseleave', () => {
copyButton.style.opacity = '0';
});
});
});
</script>
</body>
</html>

File diff suppressed because it is too large Load Diff

View File

@ -2,7 +2,8 @@
import BaseLayout from './BaseLayout.astro';
import Header from '../components/Header.astro';
import Footer from '../components/Footer.astro';
import Newsletter from '../components/Newsletter.astro';
import MiniKnowledgeGraph from '../components/MiniKnowledgeGraph.astro'; // Restore original or keep if needed
import { getCollection } from 'astro:content';
interface Props {
frontmatter: {
@ -11,20 +12,73 @@ interface Props {
pubDate: Date;
updatedDate?: Date;
heroImage?: string;
category?: string; // Keep category for potential filtering, but don't display in header
category?: string;
tags?: string[];
readTime?: string;
draft?: boolean;
author?: string; // Keep author field if needed elsewhere
// Add other potential frontmatter fields as optional
github?: string;
live?: string;
technologies?: string[];
}
author?: string;
// Field for explicitly related posts
related_posts?: string[];
},
slug: string // Add slug to props
}
const { frontmatter } = Astro.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',
@ -47,10 +101,10 @@ const displayImage = frontmatter.heroImage || '/images/placeholders/default.jpg'
<div class="blog-post-container">
<article class="blog-post">
<header class="blog-post-header">
{/* Display Draft Badge First */}
{/* Display Draft Badge if needed */}
{frontmatter.draft && <span class="draft-badge mb-4">DRAFT</span>}
{/* Title (Smaller) */}
{/* Title */}
<h1 class="blog-post-title mb-2">{frontmatter.title}</h1>
{/* Description */}
@ -63,7 +117,7 @@ const displayImage = frontmatter.heroImage || '/images/placeholders/default.jpg'
<span class="blog-post-updated">(Updated {formattedUpdatedDate})</span>
)}
{frontmatter.readTime && <span class="blog-post-read-time">{frontmatter.readTime}</span>}
{/* Category removed from display here */}
{frontmatter.category && <span class="blog-post-category">{frontmatter.category}</span>}
</div>
{/* Tags */}
@ -83,93 +137,199 @@ const displayImage = frontmatter.heroImage || '/images/placeholders/default.jpg'
</div>
)}
{/* Content Connections - Knowledge Graph */}
<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>
{/* Future Feature Placeholders remain commented out */}
{/* ... */}
{/* 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 Updated */}
{/* Author Card */}
<div class="sidebar-card author-card">
<div class="author-avatar">
<img src="/images/avatar.jpg" alt="LaForceIT Tech Blogs" />
<div class="avatar-placeholder">DL</div>
</div>
<div class="author-info">
<h3>LaForceIT.com Tech Blogs</h3>
<p>For Home Labbers, Technologists & Engineers</p>
<h3>Daniel LaForce</h3>
<p>Infrastructure & DevOps Engineer</p>
</div>
<p class="author-bio">
Exploring enterprise-grade infrastructure, automation, Kubernetes, and zero-trust networking in the home lab and beyond.
Exploring enterprise-grade infrastructure, automation, Kubernetes, and self-hosted solutions for the modern home lab.
</p>
{/* Social links removed */}
<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>
{/* 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 TOC...</p>
<p class="text-sm text-gray-400">Loading Table of Contents...</p>
</nav>
</div>
{/* Future Feature Placeholders remain commented out */}
{/* ... */}
</aside>
</div>
<Newsletter />
<Footer slot="footer" />
</BaseLayout>
{/* Script for Table of Contents Generation (Unchanged) */}
<script>
function generateToc() {
// 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) {
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) => {
let id = heading.id;
if (!id) {
id = heading.textContent?.toLowerCase().replace(/[^\w\s-]/g, '').replace(/\s+/g, '-').replace(/--+/g, '-') || `heading-${Math.random().toString(36).substring(7)}`;
heading.id = id;
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 = `#${id}`;
link.href = `#${heading.id}`;
link.textContent = heading.textContent;
link.addEventListener('click', (e) => {
e.preventDefault();
document.getElementById(id)?.scrollIntoView({ behavior: 'smooth' });
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);
} else {
tocContainer.innerHTML = '<p class="text-sm text-gray-400">No sections found.</p>';
}
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', generateToc);
} else {
generateToc();
}
// 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>
{/* Styles Updated */}
<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;
}
</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;
@ -179,98 +339,162 @@ const displayImage = frontmatter.heroImage || '/images/placeholders/default.jpg'
font-size: 0.8rem;
border-radius: 0.25rem;
font-weight: 600;
font-family: 'JetBrains Mono', monospace;
}
.blog-post-container {
display: grid;
/* Adjusted grid for wider TOC/Sidebar */
grid-template-columns: 7fr 2fr;
gap: 3rem; /* Wider gap */
max-width: 1400px; /* Wider max width */
margin: 2rem auto;
padding: 0 1.5rem;
}
.blog-post-header {
margin-bottom: 2.5rem;
border-bottom: 1px solid var(--card-border);
padding-bottom: 1.5rem;
font-family: var(--font-mono);
}
.blog-post-title {
/* Made title slightly smaller */
font-size: clamp(1.8rem, 4vw, 2.5rem);
line-height: 1.25; /* Adjusted line height */
margin-bottom: 0.75rem; /* Adjusted margin */
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; /* Increased margin */
max-width: 75ch; /* Adjusted width */
margin-bottom: 1.5rem;
max-width: 75ch;
}
.blog-post-meta {
display: flex;
flex-wrap: wrap;
gap: 0.5rem 1.5rem;
margin-bottom: 1.5rem; /* Increased margin */
margin-bottom: 1.5rem;
font-size: 0.85rem;
color: var(--text-secondary);
}
/* Removed .blog-post-category style */
.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-top: 0rem; /* Removed top margin */
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: 'JetBrains Mono', monospace;
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: 2.5rem;
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;
}
.blog-post-content {
/* Styles inherited from prose */
/* 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);
border-radius: 10px;
padding: 1.5rem;
margin-bottom: 1.5rem;
}
/* Author Card */
.author-card {
text-align: center;
}
.author-avatar {
width: 80px;
height: 80px;
@ -280,67 +504,92 @@ const displayImage = frontmatter.heroImage || '/images/placeholders/default.jpg'
border: 2px solid var(--accent-primary);
background-color: var(--bg-secondary);
}
.author-avatar img {
.avatar-placeholder {
width: 100%;
height: 100%;
object-fit: cover;
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.1rem;
font-size: 1.2rem;
}
.author-info p { /* Target the subtitle */
.author-info p {
color: var(--text-secondary);
margin-bottom: 1rem;
font-size: 0.9rem;
}
.author-bio { /* Target the main bio */
.author-bio {
font-size: 0.9rem;
margin-bottom: 0; /* Remove bottom margin */
margin-bottom: 1.5rem;
color: var(--text-secondary);
text-align: left;
}
/* Social links removed */
.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-list {
list-style: none;
padding: 0;
margin: 0;
max-height: 60vh;
.toc-container {
max-height: 500px;
overflow-y: auto;
}
.toc-item {
margin-bottom: 0.9rem; /* Increased spacing */
}
.toc-item a {
color: var(--text-secondary);
text-decoration: none;
transition: color 0.3s ease;
font-size: 0.9rem;
display: block;
padding-left: 0;
line-height: 1.4; /* Improve readability */
}
.toc-item a:hover {
color: var(--accent-primary);
}
.toc-h3 a {
padding-left: 1.5rem; /* Increased indent */
font-size: 0.85rem;
opacity: 0.9;
}
@media (max-width: 1024px) {
.blog-post-container {
grid-template-columns: 1fr; /* Stack on smaller screens */
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>

View File

@ -215,6 +215,9 @@ const commands = [
});
// Function to create HTML for a single post card
// Update the post card HTML creation function in the blog/index.astro file
// Find the function that creates post cards (might be called createPostCardHTML)
function createPostCardHTML(post) {
// Make sure tags is an array before stringifying
const tagsString = JSON.stringify(post.tags || []);
@ -949,4 +952,34 @@ const commands = [
display: none;
}
}
/* Add CSS to make the image link more obvious on hover */
.post-image-link {
display: block;
position: relative;
overflow: hidden;
border-radius: 8px 8px 0 0; /* Match card radius */
}
.post-image-link:hover .post-image {
transform: scale(1.05);
transition: transform 0.5s ease;
}
.post-image-link::after {
content: '';
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(6, 182, 212, 0.1); /* Use accent color with alpha */
opacity: 0;
transition: opacity 0.3s ease;
pointer-events: none; /* Allow clicks through */
}
.post-image-link:hover::after {
opacity: 1;
}
</style>

View File

@ -1,10 +1,11 @@
---
// src/pages/blog/index.astro - Blog page with enhanced knowledge graph and filtering
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
import BaseLayout from '../layouts/BaseLayout.astro';
import KnowledgeGraph from '../components/KnowledgeGraph.astro';
import Terminal from '../components/Terminal.astro';
import Header from '../components/Header.astro';
import Footer from '../components/Footer.astro';
// Get all blog entries
const allPosts = await getCollection('posts');
@ -36,44 +37,64 @@ const postsData = sortedPosts.map(post => ({
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
// Prepare enhanced graph data with both posts and tags
const graphData = {
nodes: [
// Add post nodes
sortedPosts.forEach(post => {
if (!post.data.draft) { // Exclude drafts from graph
graphNodes.push({
...sortedPosts
.filter(post => !post.data.draft)
.map(post => ({
id: post.slug,
label: post.data.title,
type: 'post', // Add type for styling/interaction
url: `/posts/${post.slug}/` // Add URL for linking
});
type: 'post',
category: post.data.category || 'Uncategorized',
tags: post.data.tags || [],
url: `/posts/${post.slug}/`
})),
// 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({
// Add tag nodes
...allTags.map(tag => ({
id: `tag-${tag}`,
label: tag,
type: 'tag',
url: `/tag/${tag}/`
}))
],
edges: []
};
// Create edges between posts and their tags
sortedPosts
.filter(post => !post.data.draft)
.forEach(post => {
const postTags = post.data.tags || [];
// Add edges from post to tags
postTags.forEach(tag => {
graphData.edges.push({
source: post.slug,
target: tagId,
type: 'tag-connection' // Add type
target: `tag-${tag}`,
type: 'post-tag',
strength: 1
});
});
// Check if post references other posts (optional)
// This requires a related_posts field in frontmatter
if (post.data.related_posts && Array.isArray(post.data.related_posts)) {
post.data.related_posts.forEach(relatedSlug => {
// Make sure related post exists
if (sortedPosts.some(p => p.slug === relatedSlug)) {
graphData.edges.push({
source: post.slug,
target: relatedSlug,
type: 'post-post',
strength: 2
});
}
});
}
});
const graphData = { nodes: graphNodes, edges: graphEdges };
// Terminal commands for tech effect
const commands = [
@ -84,8 +105,8 @@ const commands = [
},
{
prompt: "[laforceit@argobox]$ ",
command: "ls -la ./categories",
output: allCategories.map(cat => `${cat}`)
command: "ls -la ./tags",
output: allTags.map(tag => `${tag}`)
},
{
prompt: "[laforceit@argobox]$ ",
@ -101,10 +122,11 @@ const commands = [
---
<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 */}
<Header slot="header" />
<main>
{/* Hero Section with Terminal */}
<!-- Hero Section with Terminal -->
<section class="hero-section">
<div class="hero-bg"></div>
<div class="container">
<div class="hero-content">
<div class="hero-text">
@ -121,36 +143,47 @@ const commands = [
</div>
</section>
{/* Blog Posts Section */}
<section class="blog-posts-section">
<!-- Blog Content Section -->
<section class="blog-content-section">
<div class="container">
<!-- Search and Filter Section with integrated Knowledge Graph -->
<div class="search-filter-container">
<div class="section-header">
<h2 class="section-title">Latest Articles</h2>
<h2 class="section-title">Knowledge Graph & Content Explorer</h2>
<p class="section-description">
Technical insights, infrastructure guides, and DevOps best practices
Explore connections between articles and topics, or search by keyword
</p>
</div>
{/* Search and Filter Section */}
<!-- Knowledge Graph Visualization -->
<div class="knowledge-graph-wrapper">
<KnowledgeGraph graphData={graphData} height="500px" />
</div>
<div class="search-filter-section">
<div class="search-bar">
<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" class="search-icon"><circle cx="11" cy="11" r="8"></circle><line x1="21" y1="21" x2="16.65" y2="16.65"></line></svg>
<input type="search" id="search-input" placeholder="Search posts..." class="search-input" />
</div>
<div class="tag-filters">
<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) */}
<!-- Blog Grid (populated by JS) -->
<div class="blog-section">
<div class="section-header">
<h2 class="section-title">All Articles</h2>
<p class="section-description">
Technical insights, infrastructure guides, and DevOps best practices
</p>
</div>
<div class="blog-grid" id="blog-grid">
<div class="loading-indicator">
<div class="loading-spinner"></div>
@ -158,9 +191,10 @@ const commands = [
</div>
</div>
</div>
</div>
</section>
</main>
<Footer slot="footer" /> {/* Pass Footer to slot */}
<Footer slot="footer" />
<!-- Client-side script for filtering and graph interactions -->
<script define:vars={{ postsData, graphData }}>
@ -168,101 +202,35 @@ const commands = [
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();
console.log('Graph ready and connected to filtering system');
});
// 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
// Update the post card HTML creation function in the blog/index.astro file
// Find the function that creates post cards (might be called createPostCardHTML)
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>`
`<span class="post-tag" data-tag="${tag}">${tag}</span>`
).join('');
return `
<article class="post-card" data-tags='${tagsString}' data-slug="${post.slug}">
<div class="post-card-inner">
<a href="/posts/${post.slug}/" class="post-image-link">
<div class="post-image-container">
<img
width="720"
@ -274,6 +242,7 @@ const commands = [
/>
<div class="post-category-badge">${post.category}</div>
</div>
</a>
<div class="post-content">
<div class="post-meta">
<time datetime="${post.pubDateISO}">${post.pubDate}</time>
@ -314,7 +283,7 @@ const commands = [
post.title.toLowerCase().includes(searchTermLower) ||
post.description.toLowerCase().includes(searchTermLower) ||
postTags.some(tag => tag.toLowerCase().includes(searchTermLower));
return matchesTag && matchesSearch && !post.isDraft; // Exclude drafts
return matchesTag && matchesSearch;
});
// Update the grid HTML
@ -322,46 +291,73 @@ const commands = [
if (filteredPosts.length > 0) {
blogGrid.innerHTML = filteredPosts.map(createPostCardHTML).join('');
// If graph is available, highlight post nodes shown in the grid
// Add click handlers to post tag spans
document.querySelectorAll('.post-tag').forEach(tagSpan => {
tagSpan.addEventListener('click', (e) => {
e.preventDefault();
const tag = tagSpan.dataset.tag;
// Find and click the matching tag filter button
const tagBtn = Array.from(tagButtons).find(btn => btn.dataset.tag === tag);
if (tagBtn) {
tagBtn.click();
}
});
});
// If graph is available, highlight matching nodes
if (cy) {
const matchingPostSlugs = filteredPosts.map(post => post.slug);
// Get matching slugs for posts
const matchingSlugs = filteredPosts.map(post => post.slug);
// Reset styles on all nodes first
cy.nodes().removeClass('highlighted').removeClass('faded');
if (currentFilterTag !== 'all') {
// We're filtering by tag - highlight tag node and connected posts
cy.elements().addClass('faded').removeClass('highlighted filtered');
// 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)) {
// Highlight the tag node
const tagNode = cy.getElementById(`tag-${currentFilterTag}`);
if (tagNode.length > 0) {
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');
// Get connected posts and highlight them
const connectedPosts = tagNode.neighborhood('node[type="post"]');
connectedPosts.removeClass('faded').addClass('filtered');
// Highlight connecting edges
tagNode.connectedEdges().removeClass('faded').addClass('highlighted');
}
}
else if (currentSearchTerm) {
// We're searching - highlight matching posts
cy.elements().addClass('faded').removeClass('highlighted filtered');
// Find and highlight matching post nodes
matchingSlugs.forEach(slug => {
const node = cy.getElementById(slug);
if (node.length > 0) {
node.removeClass('faded').addClass('highlighted');
// Also show connected tags
const connectedTags = node.neighborhood('node[type="tag"]');
connectedTags.removeClass('faded').addClass('filtered');
// And highlight edges
node.connectedEdges().removeClass('faded');
}
});
}
else {
// Reset graph view
cy.elements().removeClass('faded highlighted filtered');
}
}
} else {
blogGrid.innerHTML = '<p class="no-results">No posts found matching your criteria.</p>';
blogGrid.innerHTML = '<p class="no-results">No posts found matching your criteria. Try adjusting your search or filters.</p>';
// Reset graph view
if (cy) {
cy.elements().removeClass('faded highlighted filtered');
}
}
} else {
console.error("Blog grid element not found!");
@ -388,12 +384,53 @@ const commands = [
// Update filter and grid
currentFilterTag = button.dataset.tag;
updateGrid();
// If tag changes but search is active, keep it integrated
if (cy && currentFilterTag !== 'all') {
// Find the tag node
const tagNode = cy.getElementById(`tag-${currentFilterTag}`);
if (tagNode.length > 0) {
// Center the view on this tag
cy.animate({
center: { eles: tagNode },
zoom: 1.5
}, {
duration: 500
});
}
}
});
});
// Initial grid population on client side
document.addEventListener('DOMContentLoaded', () => {
updateGrid(); // Call after DOM is fully loaded
// Create link between graph and grid
document.addEventListener('graphReady', (e) => {
// Add a scroll-to-graph button
const searchSection = document.querySelector('.search-filter-section');
if (searchSection) {
const graphButton = document.createElement('button');
graphButton.className = 'graph-toggle-btn';
graphButton.innerHTML = `
<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">
<circle cx="11" cy="11" r="8"></circle>
<line x1="21" y1="21" x2="16.65" y2="16.65"></line>
<line x1="11" y1="8" x2="11" y2="14"></line>
<line x1="8" y1="11" x2="14" y2="11"></line>
</svg>
Explore Knowledge Graph
`;
graphButton.addEventListener('click', () => {
document.querySelector('.knowledge-graph-wrapper').scrollIntoView({
behavior: 'smooth',
block: 'start'
});
});
searchSection.appendChild(graphButton);
}
});
});
</script>
</BaseLayout>
@ -407,6 +444,32 @@ const commands = [
background: linear-gradient(180deg, var(--bg-secondary), var(--bg-primary));
}
.hero-bg {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
background-image:
radial-gradient(circle at 20% 35%, rgba(6, 182, 212, 0.1) 0%, transparent 50%),
radial-gradient(circle at 75% 15%, rgba(59, 130, 246, 0.1) 0%, transparent 45%),
radial-gradient(circle at 85% 70%, rgba(139, 92, 246, 0.1) 0%, transparent 40%);
z-index: -1;
}
.hero-bg::before {
content: "";
position: absolute;
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;
}
.container {
max-width: 1280px;
margin: 0 auto;
@ -465,17 +528,15 @@ const commands = [
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));
/* Blog Content Section */
.blog-content-section {
padding: 2rem 0 5rem;
}
.section-header {
text-align: center;
max-width: 800px;
margin: 0 auto 3rem;
margin: 0 auto 2rem;
}
.section-title {
@ -503,75 +564,56 @@ const commands = [
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;
/* Search Filter Container with Knowledge Graph */
.search-filter-container {
margin-bottom: 4rem;
background: rgba(15, 23, 42, 0.3);
border-radius: 12px;
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;
overflow: hidden;
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.15);
}
.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;
.knowledge-graph-wrapper {
width: 100%;
padding: 0;
margin: 0 0 1.5rem;
}
.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;
position: relative;
}
.search-bar {
margin-bottom: 1.5rem;
position: relative;
}
.search-icon {
position: absolute;
left: 1rem;
top: 50%;
transform: translateY(-50%);
color: var(--text-tertiary);
}
.search-input {
width: 100%;
padding: 0.75rem 1rem;
padding: 0.75rem 1rem 0.75rem 2.5rem;
background-color: var(--bg-primary);
border: 1px solid var(--card-border);
border-radius: 6px;
border-radius: 8px;
color: var(--text-primary);
font-size: 1rem;
font-family: var(--font-sans);
transition: all 0.3s ease;
}
.search-input:focus {
border-color: var(--accent-primary);
box-shadow: 0 0 0 3px rgba(6, 182, 212, 0.2);
outline: none;
}
.search-input::placeholder {
@ -608,6 +650,7 @@ const commands = [
background-color: rgba(226, 232, 240, 0.1);
color: var(--text-primary);
border-color: rgba(56, 189, 248, 0.4);
transform: translateY(-2px);
}
.tag-filter-btn.active {
@ -617,19 +660,36 @@ const commands = [
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);
.graph-toggle-btn {
position: absolute;
top: 1.5rem;
right: 1.5rem;
background: linear-gradient(90deg, var(--accent-primary), var(--accent-secondary));
color: var(--bg-primary);
border: none;
padding: 0.6rem 1rem;
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 */
font-size: 0.9rem;
cursor: pointer;
display: flex;
align-items: center;
gap: 0.5rem;
transition: all 0.3s ease;
box-shadow: 0 4px 10px rgba(6, 182, 212, 0.2);
}
.graph-toggle-btn:hover {
transform: translateY(-2px);
box-shadow: 0 6px 15px rgba(6, 182, 212, 0.3);
}
/* Blog Section and Grid */
.blog-section {
margin-top: 4rem;
}
.blog-grid {
margin: 2rem 0 4rem;
margin: 2rem 0;
display: grid;
grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
gap: 2rem;
@ -661,6 +721,11 @@ const commands = [
border-color: rgba(56, 189, 248, 0.4);
}
.post-image-link {
display: block;
text-decoration: none;
}
.post-image-container {
position: relative;
}
@ -669,6 +734,11 @@ const commands = [
width: 100%;
height: 200px;
object-fit: cover;
transition: transform 0.5s ease;
}
.post-card:hover .post-image {
transform: scale(1.05);
}
.post-category-badge {
@ -717,6 +787,18 @@ const commands = [
color: var(--accent-primary);
}
.draft-badge {
display: inline-block;
background: rgba(245, 158, 11, 0.2);
color: #F59E0B;
font-size: 0.7rem;
padding: 0.2rem 0.5rem;
border-radius: 4px;
margin-left: 0.5rem;
vertical-align: middle;
font-family: var(--font-mono);
}
.post-excerpt {
color: var(--text-secondary);
font-size: 0.9rem;
@ -742,12 +824,19 @@ const commands = [
}
.post-tag {
background: rgba(226, 232, 240, 0.05);
color: var(--text-secondary);
background: rgba(16, 185, 129, 0.1);
color: #10B981;
padding: 0.2rem 0.5rem;
border-radius: 4px;
font-size: 0.7rem;
font-family: var(--font-mono);
cursor: pointer;
transition: all 0.2s ease;
}
.post-tag:hover {
background: rgba(16, 185, 129, 0.2);
transform: translateY(-2px);
}
.read-more {
@ -766,6 +855,11 @@ const commands = [
.read-more::after {
content: '→';
transition: transform 0.3s ease;
}
.post-card:hover .read-more::after {
transform: translateX(3px);
}
.no-results {
@ -815,8 +909,19 @@ const commands = [
width: 100%;
}
.graph-container {
height: 50vh;
.hero-text {
text-align: center;
align-items: center;
}
.hero-description {
max-width: 100%;
}
.graph-toggle-btn {
top: auto;
bottom: 1.5rem;
right: 1.5rem;
}
}
@ -836,5 +941,45 @@ const commands = [
.blog-grid {
grid-template-columns: 1fr;
}
.graph-toggle-btn {
padding: 0.5rem;
right: 1rem;
bottom: 1rem;
}
.graph-toggle-btn span {
display: none;
}
}
/* Add CSS to make the image link more obvious on hover */
.post-image-link {
display: block;
position: relative;
overflow: hidden;
border-radius: 8px 8px 0 0; /* Match card radius */
}
.post-image-link:hover .post-image {
transform: scale(1.05);
transition: transform 0.5s ease;
}
.post-image-link::after {
content: '';
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(6, 182, 212, 0.1); /* Use accent color with alpha */
opacity: 0;
transition: opacity 0.3s ease;
pointer-events: none; /* Allow clicks through */
}
.post-image-link:hover::after {
opacity: 1;
}
</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'
}
}
};
}

View File

@ -1,265 +1,483 @@
---
// src/pages/tag/[tag].astro
// Dynamic route for tag pages
// Dynamic route for tag pages with enhanced visualization
import BaseLayout from '../../layouts/BaseLayout.astro';
import Header from '../../components/Header.astro';
import Footer from '../../components/Footer.astro';
import KnowledgeGraph from '../../components/KnowledgeGraph.astro';
import { getCollection } from 'astro:content';
export async function getStaticPaths() {
const allPosts = await getCollection('blog');
const uniqueTags = [...new Set(allPosts.map((post) => post.data.tags).flat())];
try {
// Get all posts
const allPosts = await getCollection('posts', ({ data }) => {
// Exclude draft posts in production
return import.meta.env.PROD ? !data.draft : true;
});
// Extract all unique tags
const allTags = [...new Set(allPosts.flatMap(post => post.data.tags || []))];
// Create a path for each tag
return allTags.map((tag) => {
// Filter posts to only those with this tag
const filteredPosts = allPosts.filter((post) =>
(post.data.tags || []).includes(tag)
);
return uniqueTags.map((tag) => {
const filteredPosts = allPosts.filter((post) => post.data.tags.includes(tag));
return {
params: { tag },
props: { posts: filteredPosts },
props: {
posts: filteredPosts,
tag,
allPosts // Pass all posts for knowledge graph
},
};
});
} catch (error) {
console.error("Error in getStaticPaths:", error);
// Return empty array as fallback
return [];
}
}
const { tag } = Astro.params;
const { posts } = Astro.props;
const { posts, allPosts } = Astro.props;
// Format date
// Format dates
const formatDate = (dateStr) => {
if (!dateStr) return '';
const date = new Date(dateStr);
return date.toLocaleDateString('en-US', { year: 'numeric', month: 'short', day: 'numeric' });
return date.toLocaleDateString('en-US', {
year: 'numeric',
month: 'short',
day: 'numeric'
});
};
// Sort posts by date (newest first)
const sortedPosts = posts.sort((a, b) => {
const dateA = new Date(a.data.pubDate);
const dateB = new Date(b.data.pubDate);
const sortedPosts = [...posts].sort((a, b) => {
const dateA = new Date(a.data.pubDate || 0);
const dateB = new Date(b.data.pubDate || 0);
return dateB.getTime() - dateA.getTime();
});
// Prepare Knowledge Graph data
const graphData = {
nodes: [
// Add the current tag as a central node
{
id: `tag-${tag}`,
label: tag,
type: 'tag',
url: `/tag/${tag}`
},
// Add posts with this tag
...sortedPosts.map(post => ({
id: post.slug,
label: post.data.title,
type: 'post',
category: post.data.category || 'Uncategorized',
tags: post.data.tags || [],
url: `/posts/${post.slug}/`
})),
// Add related tags (tags that appear alongside this tag in posts)
...posts.flatMap(post =>
(post.data.tags || [])
.filter(t => t !== tag) // Don't include current tag
.map(relatedTag => ({
id: `tag-${relatedTag}`,
label: relatedTag,
type: 'tag',
url: `/tag/${relatedTag}`
}))
).filter((v, i, a) => a.findIndex(t => t.id === v.id) === i) // Deduplicate
],
edges: [
// Connect posts to the current tag
...sortedPosts.map(post => ({
source: post.slug,
target: `tag-${tag}`,
type: 'post-tag',
strength: 2
})),
// Connect related tags to their posts
...posts.flatMap(post =>
(post.data.tags || [])
.filter(t => t !== tag) // Skip current tag
.map(relatedTag => ({
source: post.slug,
target: `tag-${relatedTag}`,
type: 'post-tag',
strength: 1
}))
)
]
};
---
<BaseLayout title={`Posts tagged with "${tag}" | LaForce IT Blog`} description={`Articles and guides related to ${tag}`}>
<div class="container tag-page">
<header class="tag-hero">
<h1>Posts tagged with <span class="tag-highlight">{tag}</span></h1>
<p>Explore {sortedPosts.length} {sortedPosts.length === 1 ? 'article' : 'articles'} related to {tag}</p>
</header>
<Header slot="header" />
<main class="tag-page-container">
<section class="tag-hero">
<div class="container">
<h1>Posts tagged with <span class="tag-highlight">{tag}</span></h1>
<p class="tag-description">
Explore {sortedPosts.length} {sortedPosts.length === 1 ? 'article' : 'articles'} related to {tag}
</p>
</div>
</section>
<section class="tag-content container">
<div class="knowledge-graph-section">
<h2>Content Connections</h2>
<p class="section-description">
Explore how {tag} relates to other content and tags
</p>
<div class="graph-wrapper">
<KnowledgeGraph graphData={graphData} height="400px" initialFilter="all" />
</div>
</div>
<div class="posts-section">
<h2>Articles</h2>
<div class="posts-grid">
{sortedPosts.map((post) => (
{sortedPosts.length > 0 ? sortedPosts.map((post) => (
<article class="post-card">
<!-- Simplified image rendering that works reliably -->
<a href={`/posts/${post.slug}/`} class="post-card-link">
{post.data.heroImage && (
<div class="post-image-wrapper">
<img
width={720}
height={360}
src={post.data.heroImage || "/images/placeholders/default.jpg"}
src={post.data.heroImage}
alt=""
class="post-image"
width="400"
height="225"
loading="lazy"
/>
</div>
)}
<div class="post-content">
<time datetime={post.data.pubDate}>{formatDate(post.data.pubDate)}</time>
<h2 class="post-title">
<a href={`/posts/${post.slug}/`}>{post.data.title}</a>
</h2>
<p class="post-excerpt">{post.data.description}</p>
<div class="post-meta">
<span class="reading-time">{post.data.minutesRead || '5 min'} read</span>
<ul class="post-tags">
{post.data.tags.map((tagName) => (
<li>
<a href={`/tag/${tagName}`} class={tagName === tag ? 'current-tag' : ''}>
{tagName}
</a>
</li>
))}
</ul>
</div>
</div>
</article>
))}
<time datetime={post.data.pubDate?.toISOString()}>
{formatDate(post.data.pubDate)}
</time>
{post.data.readTime && (
<span class="read-time">{post.data.readTime}</span>
)}
</div>
<a href="/tags" class="all-tags-link">
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<h3 class="post-title">{post.data.title}</h3>
{post.data.description && (
<p class="post-description">{post.data.description}</p>
)}
{post.data.tags && post.data.tags.length > 0 && (
<div class="post-tags">
{post.data.tags.map(postTag => (
<span class={`post-tag ${postTag === tag ? 'current-tag' : ''}`}>
#{postTag}
</span>
))}
</div>
)}
</div>
</a>
</article>
)) : (
<div class="no-posts">
<p>No posts found with the tag "{tag}".</p>
<a href="/blog" class="back-to-blog">Browse all posts</a>
</div>
)}
</div>
</div>
<div class="tag-navigation">
<a href="/blog" class="back-button">
<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">
<line x1="19" y1="12" x2="5" y2="12"></line>
<polyline points="12 19 5 12 12 5"></polyline>
</svg>
View all tags
Back to All Posts
</a>
</div>
</section>
</main>
<Footer slot="footer" />
</BaseLayout>
<style>
.tag-page {
padding-top: 2rem;
.tag-page-container {
padding-bottom: 4rem;
}
.tag-hero {
text-align: center;
margin-bottom: 3rem;
animation: fadeIn 0.5s ease-out;
.container {
max-width: 1280px;
margin: 0 auto;
padding: 0 var(--container-padding, 1.5rem);
}
.tag-hero {
padding: 5rem 0 3rem;
background: linear-gradient(to bottom, var(--bg-secondary), var(--bg-primary));
text-align: center;
position: relative;
overflow: hidden;
}
.tag-hero::before {
content: "";
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: radial-gradient(circle at 30% 50%, rgba(6, 182, 212, 0.05) 0%, transparent 50%);
pointer-events: none;
}
.tag-hero h1 {
font-size: clamp(1.8rem, 4vw, 3rem);
margin-bottom: 1rem;
animation: fadeInUp 0.6s ease-out;
}
.tag-highlight {
background: linear-gradient(90deg, var(--accent-primary), var(--accent-secondary));
-webkit-background-clip: text;
background-clip: text;
color: transparent;
font-weight: 700;
}
.tag-description {
color: var(--text-secondary);
font-size: clamp(1rem, 2vw, 1.2rem);
max-width: 600px;
margin: 0 auto;
animation: fadeInUp 0.8s ease-out;
}
@keyframes fadeInUp {
from {
opacity: 0;
transform: translateY(20px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.tag-content {
margin-top: 3rem;
}
.knowledge-graph-section,
.posts-section {
margin-bottom: 4rem;
}
.knowledge-graph-section h2,
.posts-section h2 {
font-size: 1.8rem;
margin-bottom: 0.5rem;
text-align: center;
}
.section-description {
text-align: center;
color: var(--text-secondary);
margin-bottom: 2rem;
}
.graph-wrapper {
border-radius: 12px;
overflow: hidden;
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.1);
}
.posts-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(350px, 1fr));
gap: 2rem;
}
.post-card {
height: 100%;
animation: fadeIn 0.6s ease-out forwards;
opacity: 0;
}
.post-card:nth-child(1) { animation-delay: 0.1s; }
.post-card:nth-child(2) { animation-delay: 0.2s; }
.post-card:nth-child(3) { animation-delay: 0.3s; }
.post-card:nth-child(4) { animation-delay: 0.4s; }
.post-card:nth-child(5) { animation-delay: 0.5s; }
.post-card:nth-child(6) { animation-delay: 0.6s; }
@keyframes fadeIn {
from { opacity: 0; transform: translateY(10px); }
to { opacity: 1; transform: translateY(0); }
}
.tag-hero h1 {
font-size: var(--font-size-3xl);
margin-bottom: 0.5rem;
line-height: 1.2;
}
.tag-highlight {
background: linear-gradient(90deg, var(--accent-primary), var(--accent-secondary));
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
color: transparent;
font-weight: 700;
}
.tag-hero p {
color: var(--text-secondary);
font-size: var(--font-size-lg);
}
.posts-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
gap: 2rem;
margin-bottom: 3rem;
}
.post-card {
background: var(--card-bg);
border-radius: 12px;
overflow: hidden;
border: 1px solid var(--border-primary);
transition: transform 0.3s ease, box-shadow 0.3s ease;
animation: fadeIn 0.5s ease-out forwards;
animation-delay: calc(var(--animation-order, 0) * 0.1s);
opacity: 0;
.post-card-link {
display: flex;
flex-direction: column;
height: 100%;
background: var(--card-bg);
border: 1px solid var(--card-border);
border-radius: 12px;
overflow: hidden;
text-decoration: none;
transition: all 0.3s ease;
}
.post-card:hover {
.post-card-link:hover {
transform: translateY(-5px);
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.2);
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.15);
border-color: var(--accent-primary);
}
.post-image-wrapper {
height: 200px;
overflow: hidden;
border-bottom: 1px solid var(--card-border);
}
.post-image {
width: 100%;
height: 200px;
height: 100%;
object-fit: cover;
border-bottom: 1px solid var(--border-primary);
transition: transform 0.5s ease;
}
.post-card-link:hover .post-image {
transform: scale(1.05);
}
.post-content {
padding: 1.5rem;
flex-grow: 1;
display: flex;
flex-direction: column;
}
.post-content time {
color: var(--text-tertiary);
font-size: var(--font-size-sm);
font-family: var(--font-mono);
}
.post-title {
font-size: var(--font-size-xl);
margin: 0.5rem 0 1rem;
line-height: 1.3;
}
.post-title a {
color: var(--text-primary);
text-decoration: none;
transition: color 0.2s ease;
}
.post-title a:hover {
color: var(--accent-primary);
}
.post-excerpt {
color: var(--text-secondary);
font-size: var(--font-size-md);
margin-bottom: 1.5rem;
line-height: 1.6;
flex-grow: 1;
}
.post-meta {
display: flex;
flex-direction: column;
gap: 0.75rem;
margin-top: auto;
justify-content: space-between;
font-size: 0.85rem;
color: var(--text-tertiary);
margin-bottom: 1rem;
}
.reading-time {
color: var(--text-tertiary);
font-size: var(--font-size-sm);
.read-time {
font-family: var(--font-mono);
}
.post-title {
font-size: 1.2rem;
color: var(--text-primary);
margin-bottom: 0.75rem;
line-height: 1.4;
}
.post-description {
color: var(--text-secondary);
font-size: 0.95rem;
margin-bottom: 1.5rem;
flex-grow: 1;
display: -webkit-box;
-webkit-line-clamp: 3;
-webkit-box-orient: vertical;
overflow: hidden;
}
.post-tags {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
list-style: none;
padding: 0;
margin-top: auto;
}
.post-tags li a {
display: block;
padding: 0.25rem 0.75rem;
background: rgba(56, 189, 248, 0.1);
border-radius: 20px;
.post-tag {
font-size: 0.75rem;
color: var(--text-secondary);
background: rgba(226, 232, 240, 0.05);
padding: 0.25rem 0.5rem;
border-radius: 4px;
}
.post-tag.current-tag {
background: rgba(16, 185, 129, 0.2);
color: var(--accent-primary);
font-size: var(--font-size-xs);
text-decoration: none;
transition: all 0.2s ease;
}
.post-tags li a:hover {
background: rgba(56, 189, 248, 0.2);
transform: translateY(-2px);
.no-posts {
grid-column: 1 / -1;
text-align: center;
padding: 3rem;
background: var(--card-bg);
border: 1px dashed var(--card-border);
border-radius: 12px;
color: var(--text-secondary);
}
.post-tags li a.current-tag {
background: var(--accent-primary);
.back-to-blog {
display: inline-block;
margin-top: 1rem;
padding: 0.75rem 1.5rem;
background: linear-gradient(90deg, var(--accent-primary), var(--accent-secondary));
color: var(--bg-primary);
border-radius: 6px;
text-decoration: none;
font-weight: 500;
transition: all 0.3s ease;
}
.all-tags-link {
.back-to-blog:hover {
transform: translateY(-3px);
box-shadow: 0 5px 15px rgba(6, 182, 212, 0.2);
}
.tag-navigation {
display: flex;
justify-content: center;
margin-top: 2rem;
}
.back-button {
display: flex;
align-items: center;
gap: 0.5rem;
margin: 0 auto;
padding: 0.75rem 1.5rem;
padding: 0.8rem 1.5rem;
background: var(--bg-secondary);
border: 1px solid var(--border-primary);
border-radius: 30px;
color: var(--text-primary);
font-size: var(--font-size-md);
text-decoration: none;
transition: all 0.2s ease;
width: fit-content;
transition: all 0.3s ease;
}
.all-tags-link:hover {
.back-button:hover {
background: var(--bg-tertiary);
transform: translateY(-2px);
box-shadow: 0 5px 15px rgba(0, 0, 0, 0.1);
}
@media (max-width: 768px) {
.tag-hero h1 {
font-size: var(--font-size-2xl);
.tag-hero {
padding: 4rem 0 2rem;
}
.posts-grid {

179
src/pages/test-graph.astro Normal file
View File

@ -0,0 +1,179 @@
---
import BaseLayout from '../layouts/BaseLayout.astro';
import Header from '../components/Header.astro';
import Footer from '../components/Footer.astro';
import MiniKnowledgeGraph from '../components/MiniKnowledgeGraph.astro';
import { getCollection } from 'astro:content';
// Get all posts
const allPosts = await getCollection('posts').catch(error => {
console.error('Error fetching posts collection:', error);
return [];
});
// Try blog collection if posts doesn't exist
const blogPosts = allPosts.length === 0 ? await getCollection('blog').catch(() => []) : [];
const combinedPosts = [...allPosts, ...blogPosts];
// Use the first post as a test post
const testPost = combinedPosts.length > 0 ? combinedPosts[0] : {
slug: 'test-post',
data: {
title: 'Test Post',
tags: ['test', 'graph'],
category: 'Test'
}
};
// Create related posts - use the next 3 posts in the collection or create test posts
const relatedPosts = combinedPosts.length > 1
? combinedPosts.slice(1, 4)
: [
{
slug: 'related-1',
data: {
title: 'Related Post 1',
tags: ['test', 'graph'],
category: 'Test'
}
},
{
slug: 'related-2',
data: {
title: 'Related Post 2',
tags: ['test'],
category: 'Test'
}
}
];
---
<BaseLayout title="Test MiniKnowledgeGraph">
<Header slot="header" />
<div class="container mx-auto px-4 py-8">
<h1 class="text-3xl font-bold mb-6">MiniKnowledgeGraph Test Page</h1>
<div class="bg-slate-800 rounded-lg p-6 mb-8">
<p class="mb-4">This is a test page to ensure the MiniKnowledgeGraph component is working properly.</p>
<div class="border border-slate-700 rounded-lg p-4 mb-6">
<h2 class="text-xl font-bold mb-4">Test Post Details:</h2>
<p><strong>Title:</strong> {testPost.data.title}</p>
<p><strong>Slug:</strong> {testPost.slug}</p>
<p><strong>Tags:</strong> {testPost.data.tags?.join(', ') || 'None'}</p>
<p><strong>Category:</strong> {testPost.data.category || 'None'}</p>
<p><strong>Related Posts:</strong> {relatedPosts.length}</p>
</div>
<div class="mini-knowledge-graph-area">
<h2 class="text-xl font-bold mb-4">MiniKnowledgeGraph Component:</h2>
<div class="mini-knowledge-graph-wrapper">
<MiniKnowledgeGraph
currentPost={testPost}
relatedPosts={relatedPosts}
height="300px"
title="Test Graph"
/>
</div>
<!-- Debug Information Display -->
<div class="debug-info mt-4 p-4 bg-gray-900 rounded-lg">
<h3 class="text-lg font-bold mb-2">Debug Info</h3>
<div id="debug-container">Loading debug info...</div>
</div>
</div>
</div>
</div>
<Footer slot="footer" />
</BaseLayout>
<style>
.mini-knowledge-graph-area {
margin-top: 2rem;
}
.mini-knowledge-graph-wrapper {
width: 100%;
border-radius: 10px;
overflow: hidden;
display: block !important;
position: relative;
min-height: 300px;
height: 300px;
background: var(--card-bg, #1e293b);
border: 1px solid var(--card-border, #334155);
visibility: visible !important;
}
.debug-info {
font-family: monospace;
font-size: 0.8rem;
line-height: 1.4;
}
</style>
<script>
// Debug utility for testing the knowledge graph
document.addEventListener('DOMContentLoaded', function() {
setTimeout(checkGraphStatus, 500);
// Also check after window load
window.addEventListener('load', function() {
setTimeout(checkGraphStatus, 1000);
});
});
function checkGraphStatus() {
const debugContainer = document.getElementById('debug-container');
if (!debugContainer) return;
// Get container info
const container = document.querySelector('.mini-knowledge-graph-wrapper');
const cyContainer = document.getElementById('mini-cy');
// Check for cytoscape instance
const cyInstance = window.miniCy;
let html = '<ul>';
// Container dimensions
if (container) {
html += `<li>Container: ${container.offsetWidth}x${container.offsetHeight}px</li>`;
html += `<li>Display: ${getComputedStyle(container).display}</li>`;
html += `<li>Visibility: ${getComputedStyle(container).visibility}</li>`;
} else {
html += '<li>Container: Not found</li>';
}
// Cytoscape container
if (cyContainer) {
html += `<li>Cy Container: ${cyContainer.offsetWidth}x${cyContainer.offsetHeight}px</li>`;
} else {
html += '<li>Cy Container: Not found</li>';
}
// Cytoscape instance
html += `<li>Cytoscape object: ${typeof cytoscape !== 'undefined' ? 'Available' : 'Not available'}</li>`;
html += `<li>Cytoscape instance: ${cyInstance ? 'Initialized' : 'Not initialized'}</li>`;
// If instance exists, get more details
if (cyInstance) {
html += `<li>Nodes: ${cyInstance.nodes().length}</li>`;
html += `<li>Edges: ${cyInstance.edges().length}</li>`;
}
html += '</ul>';
// Add refresh button
html += '<button id="refresh-debug" class="mt-2 px-3 py-1 bg-blue-700 text-white rounded hover:bg-blue-600">' +
'Refresh Debug Info</button>';
debugContainer.innerHTML = html;
// Add event listener to refresh button
document.getElementById('refresh-debug')?.addEventListener('click', checkGraphStatus);
}
</script>

View File

@ -37,12 +37,12 @@
--bg-secondary-rgb: 22, 26, 36; /* RGB for gradients */
}
/* Light Mode Variables */
/* Enhanced Light Mode Variables - More tech-focused, less plain white */
:root.light-mode {
--bg-primary: #ffffff;
--bg-secondary: #f8fafc; /* Lighter secondary */
--bg-tertiary: #f1f5f9; /* Even lighter tertiary */
--bg-code: #f1f5f9;
--bg-primary: #f0f4f8; /* Subtle blue-gray instead of white */
--bg-secondary: #e5eaf2; /* Slightly darker secondary */
--bg-tertiary: #dae2ef; /* Even more blue tint for tertiary */
--bg-code: #e5edf7;
--text-primary: #1e293b; /* Darker primary text */
--text-secondary: #475569; /* Darker secondary text */
--text-tertiary: #64748b; /* Darker tertiary text */
@ -52,14 +52,14 @@
--glow-primary: rgba(8, 145, 178, 0.15);
--glow-secondary: rgba(37, 99, 235, 0.15);
--glow-tertiary: rgba(124, 58, 237, 0.15);
--border-primary: rgba(0, 0, 0, 0.1); /* Darker borders */
--border-secondary: rgba(0, 0, 0, 0.05);
--card-bg: rgba(255, 255, 255, 0.8); /* White card with opacity */
--card-border: rgba(37, 99, 235, 0.3); /* Blue border */
--ui-element: #e2e8f0; /* Lighter UI elements */
--ui-element-hover: #cbd5e1;
--bg-primary-rgb: 255, 255, 255; /* RGB for gradients */
--bg-secondary-rgb: 248, 250, 252; /* RGB for gradients */
--border-primary: rgba(37, 99, 235, 0.15); /* More visible blue-tinted borders */
--border-secondary: rgba(8, 145, 178, 0.1);
--card-bg: rgba(255, 255, 255, 0.6); /* More transparent card background */
--card-border: rgba(37, 99, 235, 0.2); /* Subtle blue border */
--ui-element: rgba(226, 232, 240, 0.7); /* More transparent UI elements */
--ui-element-hover: rgba(203, 213, 225, 0.8);
--bg-primary-rgb: 240, 244, 248; /* RGB for gradients */
--bg-secondary-rgb: 229, 234, 242; /* RGB for gradients */
}
/* Ensure transitions for smooth theme changes */
@ -67,24 +67,40 @@
transition: background-color 0.3s ease, color 0.3s ease, border-color 0.3s ease, box-shadow 0.3s ease;
}
/* Knowledge Graph specific theme adjustments */
/* Knowledge Graph specific theme adjustments - More transparent in light mode */
:root.light-mode .graph-container {
background: rgba(248, 250, 252, 0.3);
border: 1px solid var(--card-border);
background: rgba(248, 250, 252, 0.08); /* Much more transparent - lighter gray */
backdrop-filter: blur(2px);
border: 1px solid rgba(37, 99, 235, 0.15);
box-shadow: 0 4px 20px rgba(37, 99, 235, 0.05);
}
:root.light-mode .node-details {
background: rgba(255, 255, 255, 0.8);
box-shadow: 0 10px 25px rgba(0, 0, 0, 0.1);
background: rgba(255, 255, 255, 0.8); /* More opaque for readability */
backdrop-filter: blur(5px);
box-shadow: 0 8px 20px rgba(0, 0, 0, 0.05);
border: 1px solid rgba(37, 99, 235, 0.1);
}
:root.light-mode .graph-filters {
background: rgba(248, 250, 252, 0.7);
background: rgba(248, 250, 252, 0.6); /* Slightly more opaque */
backdrop-filter: blur(3px);
border: 1px solid rgba(37, 99, 235, 0.1);
}
:root.light-mode .graph-filter {
color: var(--text-secondary);
border-color: var(--border-primary);
background: rgba(255, 255, 255, 0.5);
}
:root.light-mode .graph-filter:hover {
background: rgba(255, 255, 255, 0.7);
}
:root.light-mode .graph-filter.active {
background: linear-gradient(135deg, rgba(8, 145, 178, 0.1), rgba(37, 99, 235, 0.1));
border-color: rgba(37, 99, 235, 0.3);
}
:root.light-mode .connections-list a {
@ -92,7 +108,12 @@
}
:root.light-mode .node-link {
box-shadow: 0 4px 10px rgba(8, 145, 178, 0.15);
box-shadow: 0 4px 10px rgba(8, 145, 178, 0.1);
background: linear-gradient(135deg, rgba(8, 145, 178, 0.1), rgba(37, 99, 235, 0.1));
}
:root.light-mode .node-link:hover {
background: linear-gradient(135deg, rgba(8, 145, 178, 0.2), rgba(37, 99, 235, 0.2));
}
/* Fix for code blocks in light mode */
@ -100,6 +121,39 @@
:root.light-mode code {
background-color: var(--bg-code);
color: var(--text-secondary);
border: 1px solid rgba(37, 99, 235, 0.1);
}
/* Services and Newsletter sections - More transparent in light mode */
:root.light-mode .service-card {
background: rgba(255, 255, 255, 0.5);
backdrop-filter: blur(5px);
border: 1px solid rgba(37, 99, 235, 0.1);
}
:root.light-mode .newsletter-container,
:root.light-mode .cta-container {
background: rgba(255, 255, 255, 0.5);
backdrop-filter: blur(5px);
border: 1px solid rgba(37, 99, 235, 0.15);
}
:root.light-mode .newsletter-input {
background: rgba(255, 255, 255, 0.7);
border: 1px solid rgba(37, 99, 235, 0.2);
}
/* Enhanced light mode body background with more pronounced grid pattern */
:root.light-mode body {
background-color: var(--bg-primary);
background-image:
radial-gradient(circle at 20% 35%, rgba(8, 145, 178, 0.08) 0%, transparent 50%),
radial-gradient(circle at 75% 15%, rgba(37, 99, 235, 0.08) 0%, transparent 45%),
radial-gradient(circle at 85% 70%, rgba(124, 58, 237, 0.08) 0%, transparent 40%),
linear-gradient(rgba(37, 99, 235, 0.05) 1px, transparent 1px),
linear-gradient(90deg, rgba(37, 99, 235, 0.05) 1px, transparent 1px);
background-size: auto, auto, auto, 16px 16px, 16px 16px;
background-position: 0 0, 0 0, 0 0, center center, center center;
}
/* Apply base styles using variables */
@ -130,3 +184,43 @@ input, select, textarea {
background-color: var(--card-bg);
border-color: var(--card-border);
}
/* Light mode buttons are more attractive */
:root.light-mode button {
background: linear-gradient(135deg, rgba(8, 145, 178, 0.05), rgba(37, 99, 235, 0.05));
border: 1px solid rgba(37, 99, 235, 0.1);
}
:root.light-mode button:hover {
background: linear-gradient(135deg, rgba(8, 145, 178, 0.1), rgba(37, 99, 235, 0.1));
border-color: rgba(37, 99, 235, 0.2);
}
/* Other light mode improvements */
:root.light-mode .primary-button,
:root.light-mode .cta-primary-button {
background: linear-gradient(90deg, var(--accent-primary), var(--accent-secondary));
box-shadow: 0 4px 12px rgba(37, 99, 235, 0.2);
}
:root.light-mode .secondary-button,
:root.light-mode .cta-secondary-button {
background: rgba(255, 255, 255, 0.5);
border: 1px solid rgba(37, 99, 235, 0.2);
}
:root.light-mode .hero-section {
background: linear-gradient(to bottom, var(--bg-secondary), var(--bg-primary));
}
:root.light-mode .hero-bg {
background-image:
radial-gradient(circle at 20% 35%, rgba(8, 145, 178, 0.1) 0%, transparent 50%),
radial-gradient(circle at 75% 15%, rgba(37, 99, 235, 0.1) 0%, transparent 45%),
radial-gradient(circle at 85% 70%, rgba(124, 58, 237, 0.1) 0%, transparent 40%);
}
/* Fix for knowledge graph in both themes */
.graph-container {
backdrop-filter: blur(2px);
}