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

Reviewed-on: https://gitea.argobox.com/KeyArgo/laforceit-blog/pulls/6
This commit is contained in:
Daniel LaForce 2025-04-24 00:59:46 +00:00
commit 4e5dff4673
11 changed files with 2523 additions and 957 deletions

View File

@ -1,439 +1,505 @@
--- ---
// Header.astro // src/components/Header.astro
// Primary navigation component with premium design elements import ThemeToggler from './ThemeToggler.astro';
// Get current path to highlight active nav item // Define navigation items with proper URLs
const pathname = new URL(Astro.request.url).pathname;
const currentPath = pathname.split('/')[1]; // Get the first path segment
// Define navigation items
const navItems = [ const navItems = [
{ name: 'Home', path: '/' }, { name: 'Home', url: '/' },
{ name: 'Blog', path: '/blog/' }, { name: 'Blog', url: '/blog' },
{ name: 'Projects', path: '/projects/' }, { name: 'Projects', url: '/projects' },
{ name: 'Home Lab', path: '/homelab/' }, { name: 'Home Lab', url: 'https://argobox.com' },
{ name: 'Resources', path: '/resources/' }, { name: 'Resources', url: '/resources' },
{ name: 'About', path: '/about/' }, { name: 'About', url: 'https://laforceit.com' },
{ name: 'Contact', path: '/contact/' } { name: 'Contact', url: 'https://laforceit.com/index.html#contact' }
]; ];
// Get current URL path for active nav item highlighting
const currentPath = Astro.url.pathname;
--- ---
<header class="site-header"> <header class="site-header">
<div class="nebula-bg"></div> <div class="container header-container">
<div class="container"> <div class="logo-container">
<div class="header-container"> <a href="/" class="logo-link">
<a href="/" class="logo"> <div class="logo">LF</div>
<div class="logo-symbol"> <div class="site-name">
<span class="logo-text">LF</span> <span class="site-title">LaForceIT</span>
<div class="logo-glow"></div> <span class="site-subtitle">Infrastructure & Automation</span>
</div>
<div class="logo-text-container">
<span class="logo-name">LaForceIT</span>
<span class="logo-tagline">Infrastructure & Automation</span>
</div> </div>
</a> </a>
</div>
<nav>
<div class="main-nav"> <nav class="main-nav">
{navItems.map((item) => ( <ul class="nav-list">
{navItems.map(item => (
<li class="nav-item">
<a <a
href={item.path} href={item.url}
class={`nav-link ${currentPath === item.path.replace(/\//g, '') ? 'active' : ''}`} class={`nav-link ${currentPath === item.url ||
(currentPath.startsWith(item.url) && item.url !== '/') ? 'active' : ''}`}
target={item.url.startsWith('http') ? '_blank' : undefined}
rel={item.url.startsWith('http') ? 'noopener noreferrer' : undefined}
> >
{item.name} {item.name}
<div class="nav-highlight"></div>
</a> </a>
))} </li>
))}
</ul>
</nav>
<div class="header-actions">
<div class="search-container">
<button class="search-toggle" aria-label="Toggle search">
<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">
<circle cx="11" cy="11" r="8"></circle>
<line x1="21" y1="21" x2="16.65" y2="16.65"></line>
</svg>
</button>
<div class="search-dropdown">
<div class="search-input-wrapper">
<input
type="search"
id="header-search"
placeholder="Search blog posts..."
class="search-input"
/>
<button class="search-submit">
<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">
<polyline points="9 18 15 12 9 6"></polyline>
</svg>
</button>
</div>
<div class="search-results" id="search-results">
<!-- Results will be populated by JS -->
</div>
</div> </div>
</div>
<div class="header-actions"> <ThemeToggler />
<button id="theme-toggle" class="theme-toggle" aria-label="Toggle dark mode">
<svg class="icon-sun" 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">
<circle cx="12" cy="12" r="5"></circle>
<line x1="12" y1="1" x2="12" y2="3"></line>
<line x1="12" y1="21" x2="12" y2="23"></line>
<line x1="4.22" y1="4.22" x2="5.64" y2="5.64"></line>
<line x1="18.36" y1="18.36" x2="19.78" y2="19.78"></line>
<line x1="1" y1="12" x2="3" y2="12"></line>
<line x1="21" y1="12" x2="23" y2="12"></line>
<line x1="4.22" y1="19.78" x2="5.64" y2="18.36"></line>
<line x1="18.36" y1="5.64" x2="19.78" y2="4.22"></line>
</svg>
<svg class="icon-moon" 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">
<path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"></path>
</svg>
</button>
<button id="search-button" class="search-button" aria-label="Search">
<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">
<circle cx="11" cy="11" r="8"></circle>
<line x1="21" y1="21" x2="16.65" y2="16.65"></line>
</svg>
</button>
<button id="mobile-menu-btn" class="mobile-menu-btn" aria-label="Toggle menu">
<svg class="icon-menu" 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">
<line x1="3" y1="12" x2="21" y2="12"></line>
<line x1="3" y1="6" x2="21" y2="6"></line>
<line x1="3" y1="18" x2="21" y2="18"></line>
</svg>
<svg class="icon-close" 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">
<line x1="18" y1="6" x2="6" y2="18"></line>
<line x1="6" y1="6" x2="18" y2="18"></line>
</svg>
</button>
</div>
</nav>
</div> </div>
<button class="mobile-menu-toggle" aria-label="Toggle menu">
<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">
<line x1="3" y1="12" x2="21" y2="12"></line>
<line x1="3" y1="6" x2="21" y2="6"></line>
<line x1="3" y1="18" x2="21" y2="18"></line>
</svg>
</button>
</div> </div>
<!-- Animated network lines effect -->
<div class="network-lines"></div>
</header> </header>
<style> <style>
.site-header { .site-header {
background: linear-gradient(180deg, rgba(15, 18, 25, 0.9), rgba(13, 16, 23, 0.8));
backdrop-filter: blur(10px);
-webkit-backdrop-filter: blur(10px);
padding: 1rem 0;
position: sticky; position: sticky;
top: 0; top: 0;
z-index: 100; z-index: 100;
padding: 0.5rem 0;
background: rgba(var(--bg-primary-rgb), 0.8);
backdrop-filter: blur(10px);
border-bottom: 1px solid var(--border-primary);
transition: all 0.3s ease; transition: all 0.3s ease;
border-bottom: 1px solid rgba(255, 255, 255, 0.05);
}
.site-header.scrolled {
padding: 0.6rem 0;
background: rgba(10, 12, 20, 0.95);
box-shadow: 0 5px 20px rgba(0, 0, 0, 0.2);
} }
.header-container { .header-container {
display: flex; display: flex;
justify-content: space-between;
align-items: center; align-items: center;
justify-content: space-between;
max-width: 1280px;
margin: 0 auto;
padding: 0 var(--container-padding);
}
.logo-container {
display: flex;
align-items: center;
}
.logo-link {
display: flex;
align-items: center;
text-decoration: none;
color: var(--text-primary);
} }
.logo { .logo {
display: flex; width: 40px;
align-items: center; height: 40px;
gap: 0.75rem;
color: var(--text-primary);
text-decoration: none;
position: relative;
}
.logo-symbol {
width: 2.75rem;
height: 2.75rem;
border-radius: 10px;
background: linear-gradient(135deg, var(--accent-primary), var(--accent-secondary));
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
position: relative; background: linear-gradient(135deg, var(--accent-primary), var(--accent-secondary));
overflow: hidden;
transition: transform 0.3s ease;
}
.logo:hover .logo-symbol {
transform: translateY(-3px);
}
.logo-text {
font-family: var(--font-mono);
font-weight: bold;
font-size: 1.25rem;
color: var(--bg-primary); color: var(--bg-primary);
z-index: 2; font-weight: bold;
border-radius: 8px;
margin-right: 0.75rem;
font-family: var(--font-sans);
} }
.logo-glow { .site-name {
position: absolute;
width: 150%;
height: 150%;
background: radial-gradient(circle, var(--accent-primary) 0%, transparent 70%);
opacity: 0.5;
filter: blur(15px);
z-index: 1;
animation: pulse 4s infinite alternate ease-in-out;
}
.logo-text-container {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
} }
.logo-name { .site-title {
font-weight: 600; font-weight: 600;
font-size: 1.4rem; font-size: 1.25rem;
background: linear-gradient(90deg, var(--accent-primary), var(--accent-secondary)); color: var(--text-primary);
-webkit-background-clip: text;
background-clip: text;
color: transparent;
letter-spacing: 0.02em;
} }
.logo-tagline { .site-subtitle {
font-size: 0.75rem; font-size: 0.75rem;
color: var(--text-secondary); color: var(--text-tertiary);
font-family: var(--font-mono); letter-spacing: 0.5px;
letter-spacing: 0.05em;
} }
.main-nav { .main-nav {
display: flex; display: flex;
gap: 1.5rem; margin-left: auto;
align-items: center; }
.nav-list {
display: flex;
list-style: none;
padding: 0;
margin: 0;
gap: 1rem;
} }
.nav-link { .nav-link {
color: var(--text-secondary); color: var(--text-secondary);
position: relative;
text-decoration: none; text-decoration: none;
font-weight: 500; font-size: 0.9rem;
transition: color 0.3s ease; padding: 0.5rem 0.75rem;
padding: 0.5rem 0; border-radius: 6px;
transition: all 0.2s ease;
position: relative;
} }
.nav-link:hover, .nav-link.active { .nav-link:hover {
color: var(--text-primary); color: var(--text-primary);
background: rgba(255, 255, 255, 0.05);
} }
.nav-highlight { .nav-link.active {
color: var(--accent-primary);
font-weight: 500;
}
.nav-link.active::after {
content: '';
position: absolute; position: absolute;
bottom: -2px; bottom: -2px;
left: 0; left: 0.75rem;
width: 0; right: 0.75rem;
height: 2px; height: 2px;
background: linear-gradient(90deg, var(--accent-primary), var(--accent-secondary)); background: var(--accent-primary);
transition: width 0.3s ease; border-radius: 1px;
border-radius: 2px;
}
.nav-link:hover .nav-highlight,
.nav-link.active .nav-highlight {
width: 100%;
} }
.header-actions { .header-actions {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 1rem; gap: 0.75rem;
margin-left: 2rem; margin-left: 1rem;
} }
.theme-toggle, /* Search Dropdown Styles */
.search-button { .search-container {
position: relative;
}
.search-toggle {
background: none; background: none;
border: none; border: none;
color: var(--text-secondary);
padding: 0.5rem; padding: 0.5rem;
cursor: pointer;
border-radius: 50%; border-radius: 50%;
transition: all 0.3s ease;
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
color: var(--text-secondary);
cursor: pointer;
transition: all 0.2s ease;
} }
.theme-toggle:hover, .search-toggle:hover {
.search-button:hover {
color: var(--text-primary); color: var(--text-primary);
background: rgba(255, 255, 255, 0.1); background: rgba(255, 255, 255, 0.05);
} }
.icon-sun { .search-dropdown {
display: none; position: absolute;
top: calc(100% + 0.5rem);
right: 0;
width: 300px;
background: var(--bg-secondary);
border: 1px solid var(--border-primary);
border-radius: 8px;
box-shadow: 0 10px 25px rgba(0, 0, 0, 0.15);
padding: 0.75rem;
z-index: 10;
transform-origin: top right;
transform: scale(0.95);
opacity: 0;
visibility: hidden;
transition: all 0.2s cubic-bezier(0.5, 0, 0, 1.25);
} }
:global(.light-mode) .icon-moon { .search-dropdown.active {
display: none; transform: scale(1);
opacity: 1;
visibility: visible;
} }
:global(.light-mode) .icon-sun { .search-input-wrapper {
display: block; display: flex;
align-items: center;
position: relative;
} }
.mobile-menu-btn { .search-input {
width: 100%;
padding: 0.6rem 2.5rem 0.6rem 0.75rem;
border: 1px solid var(--border-primary);
border-radius: 6px;
background: var(--bg-primary);
color: var(--text-primary);
font-size: 0.9rem;
}
.search-input:focus {
outline: none;
border-color: var(--accent-primary);
box-shadow: 0 0 0 3px var(--glow-primary);
}
.search-submit {
position: absolute;
right: 0.5rem;
background: none;
border: none;
color: var(--text-secondary);
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
padding: 0.25rem;
}
.search-submit:hover {
color: var(--accent-primary);
}
.search-results {
max-height: 300px;
overflow-y: auto;
margin-top: 0.75rem;
}
.search-result-item {
padding: 0.5rem 0.75rem;
border-bottom: 1px solid var(--border-primary);
cursor: pointer;
transition: all 0.2s ease;
}
.search-result-item:last-child {
border-bottom: none;
}
.search-result-item:hover {
background: rgba(var(--bg-tertiary-rgb), 0.5);
}
.search-result-title {
font-size: 0.9rem;
font-weight: 500;
color: var(--text-primary);
margin-bottom: 0.25rem;
}
.search-result-snippet {
font-size: 0.8rem;
color: var(--text-secondary);
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
.no-results {
padding: 1rem;
text-align: center;
color: var(--text-secondary);
font-size: 0.9rem;
}
/* Mobile Menu Toggle */
.mobile-menu-toggle {
display: none; display: none;
background: none; background: none;
border: none; border: none;
color: var(--text-primary); color: var(--text-secondary);
cursor: pointer; cursor: pointer;
} padding: 0.5rem;
.icon-close {
display: none;
}
.mobile-menu-active .icon-menu {
display: none;
}
.mobile-menu-active .icon-close {
display: block;
}
/* Animated network lines effect */
.network-lines {
position: absolute;
bottom: -1px;
left: 0;
width: 100%;
height: 1px;
overflow: hidden;
opacity: 0.5;
}
.network-lines::before {
content: '';
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: linear-gradient(90deg, transparent 0%, var(--accent-primary) 50%, transparent 100%);
animation: network-scan 8s infinite linear;
}
/* Nebula background */
.nebula-bg {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
background-image:
radial-gradient(circle at 20% 50%, rgba(6, 182, 212, 0.1) 0%, transparent 50%),
radial-gradient(circle at 80% 50%, rgba(139, 92, 246, 0.1) 0%, transparent 50%);
opacity: 0.3;
z-index: -1;
}
@keyframes pulse {
0% {
opacity: 0.4;
transform: scale(0.8);
}
100% {
opacity: 0.8;
transform: scale(1.2);
}
}
@keyframes network-scan {
0% {
transform: translateX(-100%);
}
100% {
transform: translateX(100%);
}
} }
/* Responsive Adjustments */ /* Responsive Adjustments */
@media (max-width: 1024px) { @media (max-width: 1024px) {
.main-nav { .nav-link {
gap: 1rem; padding: 0.5rem;
}
.header-actions {
margin-left: 1rem;
} }
} }
@media (max-width: 768px) { @media (max-width: 768px) {
.mobile-menu-btn {
display: block;
}
.main-nav { .main-nav {
display: none; display: none;
position: absolute; position: absolute;
top: 100%; top: 100%;
left: 0; left: 0;
width: 100%; right: 0;
background: var(--bg-secondary); background: var(--bg-secondary);
padding: 1rem; padding: 1rem;
flex-direction: column;
align-items: flex-start;
gap: 1rem;
border-bottom: 1px solid var(--border-primary); border-bottom: 1px solid var(--border-primary);
z-index: 10;
} }
.main-nav.active { .main-nav.active {
display: flex; display: block;
} }
.logo-tagline { .nav-list {
flex-direction: column;
gap: 0.5rem;
}
.nav-link {
display: block;
padding: 0.75rem 1rem;
}
.nav-link.active::after {
display: none; display: none;
} }
.theme-toggle, .search-button { .mobile-menu-toggle {
padding: 0.4rem; display: block;
}
.search-dropdown {
width: 260px;
} }
} }
</style> </style>
<script> <script>
// Only define one DOMContentLoaded event handler
document.addEventListener('DOMContentLoaded', () => { document.addEventListener('DOMContentLoaded', () => {
const menuBtn = document.getElementById('mobile-menu-btn');
const mainNav = document.querySelector('.main-nav');
const header = document.querySelector('.site-header');
// Mobile menu toggle // Mobile menu toggle
if (menuBtn && mainNav) { const mobileMenuToggle = document.querySelector('.mobile-menu-toggle');
menuBtn.addEventListener('click', () => { const mainNav = document.querySelector('.main-nav');
mainNav.classList.toggle('active');
menuBtn.classList.toggle('mobile-menu-active');
});
}
// Header scroll effect mobileMenuToggle?.addEventListener('click', () => {
window.addEventListener('scroll', () => { mainNav?.classList.toggle('active');
if (window.scrollY > 50) { });
header?.classList.add('scrolled');
} else { // Search dropdown toggle
header?.classList.remove('scrolled'); const searchToggle = document.querySelector('.search-toggle');
const searchDropdown = document.querySelector('.search-dropdown');
const searchInput = document.querySelector('#header-search');
searchToggle?.addEventListener('click', (e) => {
e.stopPropagation();
searchDropdown?.classList.toggle('active');
if (searchDropdown?.classList.contains('active')) {
// Focus the search input when dropdown is shown
setTimeout(() => {
searchInput?.focus();
}, 100);
} }
}); });
// Theme toggle functionality - only define once // Close search dropdown when clicking outside
const themeToggle = document.getElementById('theme-toggle'); document.addEventListener('click', (e) => {
if (!e.target.closest('.search-container')) {
searchDropdown?.classList.remove('active');
}
});
if (themeToggle) { // Search functionality - client-side post filtering
themeToggle.addEventListener('click', () => { const searchResults = document.getElementById('search-results');
document.documentElement.classList.toggle('light-mode');
// Function to perform search
// Store preference in localStorage const performSearch = async (query) => {
const isLightMode = document.documentElement.classList.contains('light-mode'); if (!query || query.length < 2) {
localStorage.setItem('theme', isLightMode ? 'light' : 'dark'); // Clear results if query is too short
}); if (searchResults) {
searchResults.innerHTML = '';
}
return;
}
// Apply saved theme preference try {
const savedTheme = localStorage.getItem('theme'); // This would ideally be a server-side search or a pre-built index
if (savedTheme === 'light') { // For now, we'll just fetch all posts and filter client-side
document.documentElement.classList.add('light-mode'); const response = await fetch('/search-index.json');
if (!response.ok) throw new Error('Failed to fetch search data');
const posts = await response.json();
const results = posts.filter(post => {
const lowerQuery = query.toLowerCase();
return (
post.title.toLowerCase().includes(lowerQuery) ||
post.description?.toLowerCase().includes(lowerQuery) ||
post.tags?.some(tag => tag.toLowerCase().includes(lowerQuery))
);
}).slice(0, 5); // Limit to 5 results
// 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>
</div>
`).join('');
// Add click handlers to results
document.querySelectorAll('.search-result-item').forEach(item => {
item.addEventListener('click', () => {
window.location.href = item.dataset.url;
});
});
} else {
searchResults.innerHTML = '<div class="no-results">No matching posts found</div>';
}
}
} catch (error) {
console.error('Search error:', error);
if (searchResults) {
searchResults.innerHTML = '<div class="no-results">Error performing search</div>';
}
} }
} };
// Add interactive network nodes animation // Search input event handler
const header_el = document.querySelector('.site-header'); let searchTimeout;
searchInput?.addEventListener('input', (e) => {
clearTimeout(searchTimeout);
searchTimeout = setTimeout(() => {
performSearch(e.target.value);
}, 300); // Debounce to avoid too many searches while typing
});
if (header_el) { // Handle search form submission
// Create animated nodes const searchForm = searchInput?.closest('form');
for (let i = 0; i < 5; i++) { searchForm?.addEventListener('submit', (e) => {
const node = document.createElement('div'); e.preventDefault();
node.className = 'nav-node'; performSearch(searchInput.value);
node.style.left = `${Math.random() * 100}%`; });
node.style.animationDelay = `${Math.random() * 5}s`;
node.style.animationDuration = `${5 + Math.random() * 5}s`; // Handle search-submit button click
header_el.appendChild(node); const searchSubmit = document.querySelector('.search-submit');
} searchSubmit?.addEventListener('click', () => {
} performSearch(searchInput?.value || '');
});
}); });
</script> </script>

File diff suppressed because it is too large Load Diff

View File

@ -47,25 +47,25 @@
transition: transform 0.5s ease, opacity 0.5s ease; transition: transform 0.5s ease, opacity 0.5s ease;
} }
html:not(.dark) .sun-icon { :root:not(.light-mode) .sun-icon {
opacity: 1;
transform: rotate(0);
}
html:not(.dark) .moon-icon {
opacity: 0;
transform: rotate(90deg);
}
html.dark .sun-icon {
opacity: 0; opacity: 0;
transform: rotate(-90deg); transform: rotate(-90deg);
} }
html.dark .moon-icon { :root:not(.light-mode) .moon-icon {
opacity: 1; opacity: 1;
transform: rotate(0); transform: rotate(0);
} }
:root.light-mode .sun-icon {
opacity: 1;
transform: rotate(0);
}
:root.light-mode .moon-icon {
opacity: 0;
transform: rotate(90deg);
}
</style> </style>
<script> <script>
@ -73,21 +73,34 @@
document.addEventListener('DOMContentLoaded', () => { document.addEventListener('DOMContentLoaded', () => {
const themeToggle = document.getElementById('theme-toggle'); const themeToggle = document.getElementById('theme-toggle');
// Check for saved theme preference or prefer-color-scheme
const getInitialTheme = () => {
const savedTheme = localStorage.getItem('theme');
if (savedTheme) {
return savedTheme === 'light';
}
// If no saved preference, check system preference
return window.matchMedia('(prefers-color-scheme: light)').matches;
};
// Function to set theme // Function to set theme
const setTheme = (isDark) => { const setTheme = (isLight) => {
if (isDark) { if (isLight) {
document.documentElement.classList.add('dark'); document.documentElement.classList.add('light-mode');
localStorage.setItem('theme', 'dark');
} else {
document.documentElement.classList.remove('dark');
localStorage.setItem('theme', 'light'); localStorage.setItem('theme', 'light');
} else {
document.documentElement.classList.remove('light-mode');
localStorage.setItem('theme', 'dark');
} }
}; };
// Apply initial theme
setTheme(getInitialTheme());
// Theme toggle click handler // Theme toggle click handler
themeToggle?.addEventListener('click', () => { themeToggle?.addEventListener('click', () => {
const isDark = document.documentElement.classList.contains('dark'); const isCurrentlyLight = document.documentElement.classList.contains('light-mode');
setTheme(!isDark); setTheme(!isCurrentlyLight);
}); });
}); });
</script> </script>

View File

@ -1,98 +1,100 @@
// src/content/config.ts
import { defineCollection, z } from 'astro:content'; import { defineCollection, z } from 'astro:content';
// Define custom date validator that handles multiple formats // Define the post collection schema
const customDateParser = (dateString: string | Date | null | undefined) => {
// Handle null/undefined
if (dateString === null || dateString === undefined) {
return new Date();
}
// If date is already a Date object, return it
if (dateString instanceof Date) {
return dateString;
}
// Try to parse the date as is
let date = new Date(dateString);
// For format like "Jul 22 2023"
if (isNaN(date.getTime()) && typeof dateString === 'string') {
try {
// Try various formats
if (dateString.match(/^\d{2}\/\d{2}\/\d{4}$/)) {
const [month, day, year] = dateString.split('/').map(Number);
date = new Date(year, month - 1, day);
}
} catch (e) {
// Default to current date if all parsing fails
date = new Date();
}
}
return date;
};
// Define the base schema for all content
const baseSchema = z.object({
title: z.string(),
description: z.string().optional(),
pubDate: z.union([z.string(), z.date(), z.null()]).optional().default(() => new Date()).transform(customDateParser),
updatedDate: z.union([z.string(), z.date(), z.null()]).optional().transform(val => val ? customDateParser(val) : undefined),
heroImage: z.string().optional().nullable(),
// Add categories array that falls back to the single category field
categories: z.union([
z.array(z.string()),
z.string().transform(val => [val]),
z.null()
]).optional().transform(val => {
if (val === null || val === undefined) return ['Uncategorized'];
return val;
}),
// Keep the original category field for backward compatibility
category: z.string().optional().default('Uncategorized'),
tags: z.union([z.array(z.string()), z.null()]).optional().default([]),
draft: z.boolean().optional().default(false),
readTime: z.union([z.string(), z.number()]).optional(),
image: z.string().optional(),
excerpt: z.string().optional(),
author: z.string().optional(),
github: z.string().optional(),
live: z.string().optional(),
technologies: z.array(z.string()).optional(),
}).passthrough() // Allow any other frontmatter properties
.transform(data => {
// If categories isn't set but category is, use category value to populate categories
if ((!data.categories || data.categories.length === 0) && data.category) {
data.categories = [data.category];
}
return data;
});
// Define collections using the same base schema
const postsCollection = defineCollection({ const postsCollection = defineCollection({
type: 'content', type: 'content',
schema: baseSchema, schema: z.object({
}); title: z.string(),
description: z.string().optional(),
const configurationsCollection = defineCollection({ pubDate: z.coerce.date(),
type: 'content', updatedDate: z.coerce.date().optional(),
schema: baseSchema, heroImage: z.string().optional(),
});
// Support both single category and categories array
const projectsCollection = defineCollection({ category: z.string().optional(),
type: 'content', categories: z.array(z.string()).optional(),
schema: baseSchema,
// Tags as an array
tags: z.array(z.string()).default([]),
// Author and reading time
author: z.string().optional(),
readTime: z.string().optional(),
// Draft status
draft: z.boolean().optional().default(false),
// Related posts by slug
related_posts: z.array(z.string()).optional(),
// Additional metadata
featured: z.boolean().optional().default(false),
technologies: z.array(z.string()).optional(),
complexity: z.enum(['beginner', 'intermediate', 'advanced']).optional(),
}),
}); });
// Define the external posts collection (for external articles)
const externalPostsCollection = defineCollection({ const externalPostsCollection = defineCollection({
type: 'content', type: 'content',
schema: baseSchema, schema: z.object({
title: z.string(),
description: z.string().optional(),
pubDate: z.coerce.date(),
url: z.string().url(),
heroImage: z.string().optional(),
source: z.string().optional(),
category: z.string().optional(),
categories: z.array(z.string()).optional(),
tags: z.array(z.string()).default([]),
}),
});
// Define the configurations collection (for config files)
const configurationsCollection = defineCollection({
type: 'content',
schema: z.object({
title: z.string(),
description: z.string().optional(),
pubDate: z.coerce.date(),
updatedDate: z.coerce.date().optional(),
heroImage: z.string().optional(),
category: z.string().optional(),
categories: z.array(z.string()).optional(),
tags: z.array(z.string()).default([]),
technologies: z.array(z.string()).optional(),
complexity: z.enum(['beginner', 'intermediate', 'advanced']).optional(),
draft: z.boolean().optional().default(false),
version: z.string().optional(),
github: z.string().optional(),
}),
});
// Define the projects collection
const projectsCollection = defineCollection({
type: 'content',
schema: z.object({
title: z.string(),
description: z.string().optional(),
pubDate: z.coerce.date(),
updatedDate: z.coerce.date().optional(),
heroImage: z.string().optional(),
category: z.string().optional(),
categories: z.array(z.string()).optional(),
tags: z.array(z.string()).default([]),
technologies: z.array(z.string()).optional(),
github: z.string().optional(),
website: z.string().optional(),
status: z.enum(['concept', 'in-progress', 'completed', 'maintained']).optional(),
draft: z.boolean().optional().default(false),
}),
}); });
// Export the collections // Export the collections
export const collections = { export const collections = {
'posts': postsCollection, posts: postsCollection,
'configurations': configurationsCollection,
'projects': projectsCollection,
'external-posts': externalPostsCollection, 'external-posts': externalPostsCollection,
configurations: configurationsCollection,
projects: projectsCollection,
}; };

View File

@ -3,7 +3,7 @@ title: "Projects Collection"
description: "A placeholder document for the projects collection" description: "A placeholder document for the projects collection"
heroImage: "/blog/images/placeholders/default.jpg" heroImage: "/blog/images/placeholders/default.jpg"
pubDate: 2025-04-18 pubDate: 2025-04-18
status: "planning" status: "concept" # Changed from 'planning' to match schema
tech: ["astro", "markdown"] tech: ["astro", "markdown"]
--- ---

View File

@ -1,5 +1,5 @@
--- ---
// blog/index.astro - Blog page with knowledge graph and filtering // src/pages/blog/index.astro - Blog page with enhanced knowledge graph and filtering
import { getCollection } from 'astro:content'; import { getCollection } from 'astro:content';
import BaseLayout from '../../layouts/BaseLayout.astro'; import BaseLayout from '../../layouts/BaseLayout.astro';
import KnowledgeGraph from '../../components/KnowledgeGraph.astro'; import KnowledgeGraph from '../../components/KnowledgeGraph.astro';
@ -37,38 +37,64 @@ const postsData = sortedPosts.map(post => ({
isDraft: post.data.draft || false isDraft: post.data.draft || false
})); }));
// Prepare graph data for visualization // Prepare enhanced graph data with both posts and tags
const graphData = { const graphData = {
nodes: sortedPosts nodes: [
.filter(post => !post.data.draft) // Add post nodes
.map(post => ({ ...sortedPosts
id: post.slug, .filter(post => !post.data.draft)
label: post.data.title, .map(post => ({
category: post.data.category || 'Uncategorized', id: post.slug,
tags: post.data.tags || [] label: post.data.title,
})), type: 'post',
category: post.data.category || 'Uncategorized',
tags: post.data.tags || [],
url: `/posts/${post.slug}/`
})),
// Add tag nodes
...allTags.map(tag => ({
id: `tag-${tag}`,
label: tag,
type: 'tag',
url: `/tag/${tag}/`
}))
],
edges: [] edges: []
}; };
// Create edges between posts based on shared tags // Create edges between posts and their tags
for (let i = 0; i < graphData.nodes.length; i++) { sortedPosts
const postA = graphData.nodes[i]; .filter(post => !post.data.draft)
.forEach(post => {
for (let j = i + 1; j < graphData.nodes.length; j++) { const postTags = post.data.tags || [];
const postB = graphData.nodes[j];
// Create edge if posts share at least one tag or same category // Add edges from post to tags
const sharedTags = postA.tags.filter(tag => postB.tags.includes(tag)); postTags.forEach(tag => {
if (sharedTags.length > 0 || postA.category === postB.category) {
graphData.edges.push({ graphData.edges.push({
source: postA.id, source: post.slug,
target: postB.id, target: `tag-${tag}`,
strength: sharedTags.length + (postA.category === postB.category ? 1 : 0) 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
});
}
}); });
} }
} });
}
// Terminal commands for tech effect // Terminal commands for tech effect
const commands = [ const commands = [
@ -79,8 +105,8 @@ const commands = [
}, },
{ {
prompt: "[laforceit@argobox]$ ", prompt: "[laforceit@argobox]$ ",
command: "ls -la ./categories", command: "ls -la ./tags",
output: allCategories.map(cat => `${cat}`) output: allTags.map(tag => `${tag}`)
}, },
{ {
prompt: "[laforceit@argobox]$ ", prompt: "[laforceit@argobox]$ ",
@ -117,58 +143,52 @@ const commands = [
</div> </div>
</section> </section>
<!-- Knowledge Graph Visualization --> <!-- Blog Content Section -->
<section class="graph-section"> <section class="blog-content-section">
<div class="container"> <div class="container">
<div class="section-header"> <!-- Search and Filter Section with integrated Knowledge Graph -->
<h2 class="section-title">Knowledge Graph</h2> <div class="search-filter-container">
<p class="section-description"> <div class="section-header">
Explore connections between articles based on topics and categories <h2 class="section-title">Knowledge Graph & Content Explorer</h2>
</p> <p class="section-description">
</div> Explore connections between articles and topics, or search by keyword
</p>
<div class="graph-container"> </div>
<KnowledgeGraph graphData={graphData} />
<div class="graph-controls"> <!-- Knowledge Graph Visualization -->
<button class="graph-filter active" data-filter="all">All Topics</button> <div class="knowledge-graph-wrapper">
{allCategories.map(category => ( <KnowledgeGraph graphData={graphData} height="500px" />
<button class="graph-filter" data-filter={category}>{category}</button>
))}
</div> </div>
</div>
</div> <div class="search-filter-section">
</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>
<!-- Blog Posts Section --> <input type="search" id="search-input" placeholder="Search posts..." class="search-input" />
<section class="blog-posts-section"> </div>
<div class="container"> <div class="tag-filters">
<div class="section-header"> <span class="filter-label">Filter by Tag:</span>
<h2 class="section-title">All Articles</h2> <button class="tag-filter-btn active" data-tag="all">All</button>
<p class="section-description"> {allTags.map(tag => (
Technical insights, infrastructure guides, and DevOps best practices <button class="tag-filter-btn" data-tag={tag}>{tag}</button>
</p> ))}
</div> </div>
<!-- Search and Filter Section -->
<div class="search-filter-section">
<div class="search-bar">
<input type="search" id="search-input" placeholder="Search posts..." class="search-input" />
</div>
<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> </div>
</div> </div>
<!-- Blog Grid (will be populated by JS) --> <!-- Blog Grid (populated by JS) -->
<div class="blog-grid" id="blog-grid"> <div class="blog-section">
<div class="loading-indicator"> <div class="section-header">
<div class="loading-spinner"></div> <h2 class="section-title">All Articles</h2>
<span>Loading articles...</span> <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>
<span>Loading articles...</span>
</div>
</div> </div>
</div> </div>
</div> </div>
@ -182,81 +202,18 @@ const commands = [
const searchInput = document.getElementById('search-input'); const searchInput = document.getElementById('search-input');
const tagButtons = document.querySelectorAll('.tag-filter-btn'); const tagButtons = document.querySelectorAll('.tag-filter-btn');
const blogGrid = document.getElementById('blog-grid'); const blogGrid = document.getElementById('blog-grid');
const graphFilters = document.querySelectorAll('.graph-filter');
// State variables // State variables
let currentFilterTag = 'all'; let currentFilterTag = 'all';
let currentSearchTerm = ''; let currentSearchTerm = '';
let currentGraphFilter = 'all';
let cy; // Cytoscape instance will be set by KnowledgeGraph component let cy; // Cytoscape instance will be set by KnowledgeGraph component
// Wait for cytoscape instance to be available // Wait for cytoscape instance to be available
document.addEventListener('graphReady', (e) => { document.addEventListener('graphReady', (e) => {
cy = e.detail.cy; cy = e.detail.cy;
setupGraphInteractions(); console.log('Graph ready and connected to filtering system');
}); });
// Setup graph filtering and interactions
function setupGraphInteractions() {
if (!cy) return;
// Graph filtering by category
graphFilters.forEach(button => {
button.addEventListener('click', () => {
// Update active button style
graphFilters.forEach(btn => btn.classList.remove('active'));
button.classList.add('active');
// Update filter
currentGraphFilter = button.dataset.filter;
// Apply filter to graph
if (currentGraphFilter === 'all') {
cy.elements().removeClass('faded').removeClass('highlighted');
} else {
// Fade all nodes/edges
cy.elements().addClass('faded');
// Highlight nodes with matching category and their edges
const matchingNodes = cy.nodes().filter(node =>
node.data('category') === currentGraphFilter
);
matchingNodes.removeClass('faded').addClass('highlighted');
matchingNodes.connectedEdges().removeClass('faded').addClass('highlighted');
}
});
});
// Click node to filter posts
cy.on('tap', 'node', function(evt) {
const node = evt.target;
const slug = node.id();
// Scroll to the post in the blog grid
const post = postsData.find(p => p.slug === slug);
if (post) {
// Reset filters
currentFilterTag = 'all';
searchInput.value = post.title;
currentSearchTerm = post.title;
// Update UI
tagButtons.forEach(btn => btn.classList.remove('active'));
tagButtons[0].classList.add('active');
// Update grid with just this post
updateGrid();
// Scroll to blog section
document.querySelector('.blog-posts-section').scrollIntoView({
behavior: 'smooth',
block: 'start'
});
}
});
}
// Function to create HTML for a single post card // Function to create HTML for a single post card
function createPostCardHTML(post) { function createPostCardHTML(post) {
// Make sure tags is an array before stringifying // Make sure tags is an array before stringifying
@ -264,23 +221,25 @@ const commands = [
// Create tag pills HTML // Create tag pills HTML
const tagPills = post.tags.map(tag => const tagPills = post.tags.map(tag =>
`<span class="post-tag">${tag}</span>` `<span class="post-tag" data-tag="${tag}">${tag}</span>`
).join(''); ).join('');
return ` return `
<article class="post-card" data-tags='${tagsString}' data-slug="${post.slug}"> <article class="post-card" data-tags='${tagsString}' data-slug="${post.slug}">
<div class="post-card-inner"> <div class="post-card-inner">
<div class="post-image-container"> <a href="/posts/${post.slug}/" class="post-image-link">
<img <div class="post-image-container">
width="720" <img
height="360" width="720"
src="${post.heroImage}" height="360"
alt="" src="${post.heroImage}"
class="post-image" alt=""
loading="lazy" class="post-image"
/> loading="lazy"
<div class="post-category-badge">${post.category}</div> />
</div> <div class="post-category-badge">${post.category}</div>
</div>
</a>
<div class="post-content"> <div class="post-content">
<div class="post-meta"> <div class="post-meta">
<time datetime="${post.pubDateISO}">${post.pubDate}</time> <time datetime="${post.pubDateISO}">${post.pubDate}</time>
@ -321,7 +280,7 @@ const commands = [
post.title.toLowerCase().includes(searchTermLower) || post.title.toLowerCase().includes(searchTermLower) ||
post.description.toLowerCase().includes(searchTermLower) || post.description.toLowerCase().includes(searchTermLower) ||
postTags.some(tag => tag.toLowerCase().includes(searchTermLower)); postTags.some(tag => tag.toLowerCase().includes(searchTermLower));
return matchesTag && matchesSearch && !post.isDraft; // Exclude drafts return matchesTag && matchesSearch;
}); });
// Update the grid HTML // Update the grid HTML
@ -329,29 +288,73 @@ const commands = [
if (filteredPosts.length > 0) { if (filteredPosts.length > 0) {
blogGrid.innerHTML = filteredPosts.map(createPostCardHTML).join(''); blogGrid.innerHTML = filteredPosts.map(createPostCardHTML).join('');
// 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 graph is available, highlight matching nodes
if (cy) { if (cy) {
// Get matching slugs for posts
const matchingSlugs = filteredPosts.map(post => post.slug); const matchingSlugs = filteredPosts.map(post => post.slug);
// Reset all nodes
cy.nodes().removeClass('highlighted').removeClass('filtered');
// Highlight matching nodes
matchingSlugs.forEach(slug => {
cy.getElementById(slug).addClass('highlighted');
});
// If filtering by tag, also highlight connected nodes
if (currentFilterTag !== 'all') { if (currentFilterTag !== 'all') {
cy.nodes().forEach(node => { // We're filtering by tag - highlight tag node and connected posts
if (node.data('tags')?.includes(currentFilterTag)) { cy.elements().addClass('faded').removeClass('highlighted filtered');
node.addClass('filtered');
// Highlight the tag node
const tagNode = cy.getElementById(`tag-${currentFilterTag}`);
if (tagNode.length > 0) {
tagNode.removeClass('faded').addClass('highlighted');
// 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 { } 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 { } else {
console.error("Blog grid element not found!"); console.error("Blog grid element not found!");
@ -378,12 +381,53 @@ const commands = [
// Update filter and grid // Update filter and grid
currentFilterTag = button.dataset.tag; currentFilterTag = button.dataset.tag;
updateGrid(); 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 // Initial grid population on client side
document.addEventListener('DOMContentLoaded', () => { document.addEventListener('DOMContentLoaded', () => {
updateGrid(); // Call after DOM is fully loaded 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> </script>
</BaseLayout> </BaseLayout>
@ -481,17 +525,15 @@ const commands = [
max-width: 560px; max-width: 560px;
} }
/* Graph Section */ /* Blog Content Section */
.graph-section { .blog-content-section {
padding: 5rem 0; padding: 2rem 0 5rem;
position: relative;
background: linear-gradient(0deg, var(--bg-primary), var(--bg-secondary), var(--bg-primary));
} }
.section-header { .section-header {
text-align: center; text-align: center;
max-width: 800px; max-width: 800px;
margin: 0 auto 3rem; margin: 0 auto 2rem;
} }
.section-title { .section-title {
@ -519,75 +561,56 @@ const commands = [
margin: 0 auto; margin: 0 auto;
} }
.graph-container { /* Search Filter Container with Knowledge Graph */
position: relative; .search-filter-container {
height: 60vh; margin-bottom: 4rem;
min-height: 500px; background: rgba(15, 23, 42, 0.3);
max-height: 800px;
border-radius: 12px; border-radius: 12px;
overflow: hidden;
border: 1px solid var(--card-border);
background: rgba(15, 23, 42, 0.2);
margin-bottom: 2rem;
}
.graph-controls {
display: flex;
flex-wrap: wrap;
gap: 1rem;
justify-content: center;
margin-top: 1.5rem;
}
.graph-filter {
padding: 0.5rem 1rem;
border-radius: 6px;
border: 1px solid var(--border-primary); border: 1px solid var(--border-primary);
background: var(--bg-secondary); overflow: hidden;
color: var(--text-primary); box-shadow: 0 10px 30px rgba(0, 0, 0, 0.15);
font-family: var(--font-mono);
font-size: var(--font-size-sm);
cursor: pointer;
transition: all 0.3s ease;
} }
.graph-filter:hover { .knowledge-graph-wrapper {
border-color: var(--accent-primary); width: 100%;
box-shadow: 0 0 10px var(--glow-primary); padding: 0;
} margin: 0 0 1.5rem;
.graph-filter.active {
background: linear-gradient(135deg, var(--accent-primary), var(--accent-secondary));
border-color: transparent;
color: var(--bg-primary);
}
/* Blog Posts Section */
.blog-posts-section {
padding: 5rem 0;
} }
.search-filter-section { .search-filter-section {
margin-bottom: 3rem;
padding: 1.5rem; padding: 1.5rem;
background: rgba(13, 21, 41, 0.5); position: relative;
border: 1px solid var(--card-border);
border-radius: 8px;
} }
.search-bar { .search-bar {
margin-bottom: 1.5rem; margin-bottom: 1.5rem;
position: relative;
}
.search-icon {
position: absolute;
left: 1rem;
top: 50%;
transform: translateY(-50%);
color: var(--text-tertiary);
} }
.search-input { .search-input {
width: 100%; width: 100%;
padding: 0.75rem 1rem; padding: 0.75rem 1rem 0.75rem 2.5rem;
background-color: var(--bg-primary); background-color: var(--bg-primary);
border: 1px solid var(--card-border); border: 1px solid var(--card-border);
border-radius: 6px; border-radius: 8px;
color: var(--text-primary); color: var(--text-primary);
font-size: 1rem; font-size: 1rem;
font-family: var(--font-sans); 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 { .search-input::placeholder {
@ -624,6 +647,7 @@ const commands = [
background-color: rgba(226, 232, 240, 0.1); background-color: rgba(226, 232, 240, 0.1);
color: var(--text-primary); color: var(--text-primary);
border-color: rgba(56, 189, 248, 0.4); border-color: rgba(56, 189, 248, 0.4);
transform: translateY(-2px);
} }
.tag-filter-btn.active { .tag-filter-btn.active {
@ -633,8 +657,36 @@ const commands = [
font-weight: 600; font-weight: 600;
} }
.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;
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 { .blog-grid {
margin: 2rem 0 4rem; margin: 2rem 0;
display: grid; display: grid;
grid-template-columns: repeat(auto-fill, minmax(320px, 1fr)); grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
gap: 2rem; gap: 2rem;
@ -666,6 +718,11 @@ const commands = [
border-color: rgba(56, 189, 248, 0.4); border-color: rgba(56, 189, 248, 0.4);
} }
.post-image-link {
display: block;
text-decoration: none;
}
.post-image-container { .post-image-container {
position: relative; position: relative;
} }
@ -674,6 +731,11 @@ const commands = [
width: 100%; width: 100%;
height: 200px; height: 200px;
object-fit: cover; object-fit: cover;
transition: transform 0.5s ease;
}
.post-card:hover .post-image {
transform: scale(1.05);
} }
.post-category-badge { .post-category-badge {
@ -722,6 +784,18 @@ const commands = [
color: var(--accent-primary); 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 { .post-excerpt {
color: var(--text-secondary); color: var(--text-secondary);
font-size: 0.9rem; font-size: 0.9rem;
@ -747,12 +821,19 @@ const commands = [
} }
.post-tag { .post-tag {
background: rgba(226, 232, 240, 0.05); background: rgba(16, 185, 129, 0.1);
color: var(--text-secondary); color: #10B981;
padding: 0.2rem 0.5rem; padding: 0.2rem 0.5rem;
border-radius: 4px; border-radius: 4px;
font-size: 0.7rem; font-size: 0.7rem;
font-family: var(--font-mono); 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 { .read-more {
@ -834,8 +915,10 @@ const commands = [
max-width: 100%; max-width: 100%;
} }
.graph-container { .graph-toggle-btn {
height: 50vh; top: auto;
bottom: 1.5rem;
right: 1.5rem;
} }
} }
@ -855,5 +938,15 @@ const commands = [
.blog-grid { .blog-grid {
grid-template-columns: 1fr; grid-template-columns: 1fr;
} }
.graph-toggle-btn {
padding: 0.5rem;
right: 1rem;
bottom: 1rem;
}
.graph-toggle-btn span {
display: none;
}
} }
</style> </style>

View File

@ -36,38 +36,44 @@ const postsData = sortedPosts.map(post => ({
isDraft: post.data.draft || false isDraft: post.data.draft || false
})); }));
// Prepare graph data for visualization // Prepare graph data (Obsidian-style: Posts and Tags)
const graphData = { const graphNodes = [];
nodes: sortedPosts const graphEdges = [];
.filter(post => !post.data.draft) const tagNodes = new Map(); // To avoid duplicate tag nodes
.map(post => ({
// Add post nodes
sortedPosts.forEach(post => {
if (!post.data.draft) { // Exclude drafts from graph
graphNodes.push({
id: post.slug, id: post.slug,
label: post.data.title, label: post.data.title,
category: post.data.category || 'Uncategorized', type: 'post', // Add type for styling/interaction
tags: post.data.tags || [] url: `/posts/${post.slug}/` // Add URL for linking
})), });
edges: []
};
// Create edges between posts based on shared tags // Add tag nodes and edges
for (let i = 0; i < graphData.nodes.length; i++) { (post.data.tags || []).forEach(tag => {
const postA = graphData.nodes[i]; const tagId = `tag-${tag}`;
// Add tag node only if it doesn't exist
for (let j = i + 1; j < graphData.nodes.length; j++) { if (!tagNodes.has(tagId)) {
const postB = graphData.nodes[j]; graphNodes.push({
id: tagId,
// Create edge if posts share at least one tag or same category label: `#${tag}`, // Prefix with # for clarity
const sharedTags = postA.tags.filter(tag => postB.tags.includes(tag)); type: 'tag' // Add type
});
if (sharedTags.length > 0 || postA.category === postB.category) { tagNodes.set(tagId, true);
graphData.edges.push({ }
source: postA.id, // Add edge connecting post to tag
target: postB.id, graphEdges.push({
strength: sharedTags.length + (postA.category === postB.category ? 1 : 0) source: post.slug,
target: tagId,
type: 'tag-connection' // Add type
}); });
} });
} }
} });
const graphData = { nodes: graphNodes, edges: graphEdges };
// Terminal commands for tech effect // Terminal commands for tech effect
const commands = [ const commands = [
@ -115,29 +121,6 @@ const commands = [
</div> </div>
</section> </section>
{/* Knowledge Graph Visualization */}
<section class="graph-section">
<div class="container">
<div class="section-header">
<h2 class="section-title">Knowledge Graph</h2>
<p class="section-description">
Explore connections between articles based on topics and categories
</p>
</div>
<div class="graph-container">
<KnowledgeGraph graphData={graphData} />
<div class="graph-controls">
<button class="graph-filter active" data-filter="all">All Topics</button>
{allCategories.map(category => (
<button class="graph-filter" data-filter={category}>{category}</button>
))}
</div>
</div>
</div>
</section>
{/* Blog Posts Section */} {/* Blog Posts Section */}
<section class="blog-posts-section"> <section class="blog-posts-section">
<div class="container"> <div class="container">
@ -159,9 +142,15 @@ const commands = [
<button class="tag-filter-btn" data-tag={tag}>{tag}</button> <button class="tag-filter-btn" data-tag={tag}>{tag}</button>
))} ))}
</div> </div>
{/* Integrated Knowledge Graph */}
<div class="integrated-graph-container">
<KnowledgeGraph graphData={graphData} />
{/* We will update graphData generation later */}
</div>
</div> </div>
<!-- Blog Grid (will be populated by JS) --> {/* Blog Grid (will be populated by JS) */}
<div class="blog-grid" id="blog-grid"> <div class="blog-grid" id="blog-grid">
<div class="loading-indicator"> <div class="loading-indicator">
<div class="loading-spinner"></div> <div class="loading-spinner"></div>
@ -179,12 +168,12 @@ const commands = [
const searchInput = document.getElementById('search-input'); const searchInput = document.getElementById('search-input');
const tagButtons = document.querySelectorAll('.tag-filter-btn'); const tagButtons = document.querySelectorAll('.tag-filter-btn');
const blogGrid = document.getElementById('blog-grid'); const blogGrid = document.getElementById('blog-grid');
const graphFilters = document.querySelectorAll('.graph-filter'); // Removed graphFilters as category filtering is removed from graph
// State variables // State variables
let currentFilterTag = 'all'; let currentFilterTag = 'all';
let currentSearchTerm = ''; let currentSearchTerm = '';
let currentGraphFilter = 'all'; // Removed currentGraphFilter
let cy; // Cytoscape instance will be set by KnowledgeGraph component let cy; // Cytoscape instance will be set by KnowledgeGraph component
// Wait for cytoscape instance to be available // Wait for cytoscape instance to be available
@ -193,63 +182,70 @@ const commands = [
setupGraphInteractions(); setupGraphInteractions();
}); });
// Setup graph filtering and interactions // Setup graph interactions (Post and Tag nodes)
function setupGraphInteractions() { function setupGraphInteractions() {
if (!cy) return; if (!cy) {
console.error("Cytoscape instance not ready.");
return;
}
// Graph filtering by category // Remove previous category filter logic if any existed
graphFilters.forEach(button => { // graphFilters.forEach(...) logic removed
button.addEventListener('click', () => {
// Update active button style // Handle clicks on graph nodes
graphFilters.forEach(btn => btn.classList.remove('active'));
button.classList.add('active');
// Update filter
currentGraphFilter = button.dataset.filter;
// Apply filter to graph
if (currentGraphFilter === 'all') {
cy.elements().removeClass('faded').removeClass('highlighted');
} else {
// Fade all nodes/edges
cy.elements().addClass('faded');
// Highlight nodes with matching category and their edges
const matchingNodes = cy.nodes().filter(node =>
node.data('category') === currentGraphFilter
);
matchingNodes.removeClass('faded').addClass('highlighted');
matchingNodes.connectedEdges().removeClass('faded').addClass('highlighted');
}
});
});
// Click node to filter posts
cy.on('tap', 'node', function(evt) { cy.on('tap', 'node', function(evt) {
const node = evt.target; const node = evt.target;
const slug = node.id(); const nodeId = node.id();
const nodeType = node.data('type'); // Get type ('post' or 'tag')
// Scroll to the post in the blog grid
const post = postsData.find(p => p.slug === slug); console.log(`Node clicked: ID=${nodeId}, Type=${nodeType}`); // Debug log
if (post) {
// Reset filters if (nodeType === 'post') {
currentFilterTag = 'all'; // Handle post node click: Find post, update search, filter grid, scroll
searchInput.value = post.title; const post = postsData.find(p => p.slug === nodeId);
currentSearchTerm = post.title; 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}`);
// Update UI const correspondingButton = document.querySelector(`.tag-filter-btn[data-tag="${tagName}"]`);
tagButtons.forEach(btn => btn.classList.remove('active'));
tagButtons[0].classList.add('active');
// Update grid with just this post if (correspondingButton) {
updateGrid(); console.log(`Found corresponding button for tag: ${tagName}`);
// Simulate click on the button
// Scroll to blog section correspondingButton.click();
document.querySelector('.blog-posts-section').scrollIntoView({ // Scroll to blog section smoothly
behavior: 'smooth', const blogSection = document.querySelector('.blog-posts-section');
block: 'start' if (blogSection) {
}); blogSection.scrollIntoView({ behavior: 'smooth', block: 'start' });
}
} else {
console.warn(`Could not find tag filter button for tag: ${tagName}`);
}
} }
}); });
} }
@ -326,26 +322,43 @@ const commands = [
if (filteredPosts.length > 0) { if (filteredPosts.length > 0) {
blogGrid.innerHTML = filteredPosts.map(createPostCardHTML).join(''); blogGrid.innerHTML = filteredPosts.map(createPostCardHTML).join('');
// If graph is available, highlight matching nodes // If graph is available, highlight post nodes shown in the grid
if (cy) { if (cy) {
const matchingSlugs = filteredPosts.map(post => post.slug); const matchingPostSlugs = filteredPosts.map(post => post.slug);
// Reset all nodes // Reset styles on all nodes first
cy.nodes().removeClass('highlighted').removeClass('filtered'); cy.nodes().removeClass('highlighted').removeClass('faded');
// Highlight matching nodes // Highlight post nodes that are currently visible in the grid
matchingSlugs.forEach(slug => { cy.nodes('[type="post"]').forEach(node => {
cy.getElementById(slug).addClass('highlighted'); if (matchingPostSlugs.includes(node.id())) {
node.removeClass('faded').addClass('highlighted');
} else {
node.removeClass('highlighted').addClass('faded'); // Fade non-matching posts
}
}); });
// If filtering by tag, also highlight connected nodes // Highlight tag nodes connected to visible posts OR the currently selected tag
if (currentFilterTag !== 'all') { cy.nodes('[type="tag"]').forEach(tagNode => {
cy.nodes().forEach(node => { const tagName = tagNode.id().replace(/^tag-/, '');
if (node.data('tags')?.includes(currentFilterTag)) { const isSelectedTag = tagName === currentFilterTag;
node.addClass('filtered'); const isConnectedToVisiblePost = tagNode.connectedEdges().sources().some(postNode => matchingPostSlugs.includes(postNode.id()));
if (isSelectedTag || (currentFilterTag === 'all' && isConnectedToVisiblePost)) {
tagNode.removeClass('faded').addClass('highlighted');
} else {
tagNode.removeClass('highlighted').addClass('faded');
}
});
// Adjust edge visibility based on connected highlighted nodes
cy.edges().forEach(edge => {
if (edge.source().hasClass('highlighted') && edge.target().hasClass('highlighted')) {
edge.removeClass('faded').addClass('highlighted');
} else {
edge.removeClass('highlighted').addClass('faded');
} }
}); });
}
} }
} else { } 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.</p>';
@ -603,6 +616,17 @@ const commands = [
border-color: var(--accent-primary); border-color: var(--accent-primary);
font-weight: 600; font-weight: 600;
} }
/* Styles for the integrated graph container */
.integrated-graph-container {
margin-top: 2rem; /* Add space above the graph */
height: 400px; /* Adjust height as needed */
border: 1px solid var(--border-primary);
border-radius: 8px;
background: rgba(15, 23, 42, 0.3); /* Slightly different background */
position: relative; /* Needed for Cytoscape */
overflow: hidden; /* Hide scrollbars if graph overflows */
}
.blog-grid { .blog-grid {
margin: 2rem 0 4rem; margin: 2rem 0 4rem;

View File

@ -1,17 +1,628 @@
--- ---
// src/pages/posts/[slug].astro
import { getCollection } from 'astro:content'; import { getCollection } from 'astro:content';
import BlogPostLayout from '../../layouts/BlogPostLayout.astro'; // Using BlogPostLayout import BaseLayout from '../../layouts/BaseLayout.astro';
// 1. Generate a path for every blog post // Required getStaticPaths function for dynamic routes
export async function getStaticPaths() { export async function getStaticPaths() {
const postEntries = await getCollection('posts'); try {
return postEntries.map(entry => ({ // Get posts from the posts collection
params: { slug: entry.slug }, props: { entry }, const allPosts = await getCollection('posts', ({ data }) => {
})); return import.meta.env.PROD ? !data.draft : true;
});
return allPosts.map(post => ({
params: { slug: post.slug },
props: { post, allPosts },
}));
} catch (error) {
console.error('Error fetching posts:', error);
// Return empty array if collection doesn't exist
return [];
}
} }
// 2. For your template, you can get the entry directly from the prop // Get the post and all posts from props
const { entry } = Astro.props; const { post, allPosts } = Astro.props;
const { Content } = await entry.render();
// Format date helper
const formatDate = (date) => {
if (!date) return '';
const d = new Date(date);
return d.toLocaleDateString('en-US', {
year: 'numeric',
month: 'long',
day: 'numeric'
});
};
// Generate datetime attribute safely
const getISODate = (date) => {
if (!date) return '';
// Handle various date formats
try {
// If already a Date object
if (date instanceof Date) {
return date.toISOString();
}
// If it's a string or number, convert to Date
return new Date(date).toISOString();
} catch (error) {
// Fallback if date is invalid
console.error('Invalid date format:', date);
return '';
}
};
// Find related posts by tags
const getRelatedPosts = (currentPost, allPosts, maxPosts = 3) => {
if (!currentPost || !allPosts) return [];
// Get current post tags
const postTags = currentPost.data.tags || [];
// If no tags, just return recent posts
if (postTags.length === 0) {
return allPosts
.filter(p => p.slug !== currentPost.slug && !p.data.draft)
.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();
})
.slice(0, maxPosts);
}
// Score posts by matching tags
const scoredPosts = allPosts
.filter(p => p.slug !== currentPost.slug && !p.data.draft)
.map(p => {
const pTags = p.data.tags || [];
const matchCount = pTags.filter(tag => postTags.includes(tag)).length;
return { post: p, score: matchCount };
})
.filter(item => item.score > 0)
.sort((a, b) => {
// Sort by score first
if (b.score !== a.score) return b.score - a.score;
// If scores are equal, sort by date
const dateA = a.post.data.pubDate ? new Date(a.post.data.pubDate) : new Date(0);
const dateB = b.post.data.pubDate ? new Date(b.post.data.pubDate) : new Date(0);
return dateB.getTime() - dateA.getTime();
})
.slice(0, maxPosts);
// If we don't have enough related posts by tags, add recent posts
if (scoredPosts.length < maxPosts) {
const recentPosts = allPosts
.filter(p => {
return p.slug !== currentPost.slug &&
!p.data.draft &&
!scoredPosts.some(sp => sp.post.slug === p.slug);
})
.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();
})
.slice(0, maxPosts - scoredPosts.length);
return [...scoredPosts.map(sp => sp.post), ...recentPosts];
}
return scoredPosts.map(sp => sp.post);
};
// Get related posts
const relatedPosts = getRelatedPosts(post, allPosts);
// Check for explicitly related posts in frontmatter
const explicitRelatedPosts = post.data.related_posts
? allPosts.filter(p => post.data.related_posts.includes(p.slug))
: [];
// Combine explicit and tag-based related posts, with explicit ones first
const combinedRelatedPosts = [
...explicitRelatedPosts,
...relatedPosts.filter(p => !explicitRelatedPosts.some(ep => ep.slug === p.slug))
].slice(0, 3);
// Get the Content component for rendering markdown
const { Content } = await post.render();
--- ---
<BlogPostLayout frontmatter={entry.data}> <Content /> </BlogPostLayout>
<BaseLayout title={post.data.title} description={post.data.description || ''}>
<article class="container blog-post">
<header class="post-header">
<h1>{post.data.title}</h1>
<div class="post-meta">
{post.data.pubDate && <time datetime={getISODate(post.data.pubDate)}>{formatDate(post.data.pubDate)}</time>}
{post.data.updatedDate && <div class="updated-date">Updated: {formatDate(post.data.updatedDate)}</div>}
{post.data.readTime && <div class="read-time">{post.data.readTime}</div>}
{post.data.author && <div class="author">By {post.data.author}</div>}
</div>
</header>
{post.data.heroImage && (
<div class="hero-image">
<img src={post.data.heroImage} alt={post.data.title} />
</div>
)}
<div class="post-content">
<div class="post-body">
<Content />
</div>
<aside class="post-sidebar">
{post.data.tags && post.data.tags.length > 0 && (
<div class="tags-section sidebar-block">
<h3>Tags</h3>
<div class="tags">
{post.data.tags.map(tag => (
<a href={`/tag/${tag}`} class="tag">{tag}</a>
))}
</div>
</div>
)}
{post.data.category && (
<div class="category-section sidebar-block">
<h3>Category</h3>
<a href={`/categories/${post.data.category}`} class="category">
{post.data.category}
</a>
</div>
)}
{post.data.categories && post.data.categories.length > 0 && (
<div class="categories-section sidebar-block">
<h3>Categories</h3>
<div class="categories">
{post.data.categories.map(category => (
<a href={`/categories/${category}`} class="category">
{category}
</a>
))}
</div>
</div>
)}
{combinedRelatedPosts.length > 0 && (
<div class="related-posts-section sidebar-block">
<h3>Related Articles</h3>
<ul class="related-posts">
{combinedRelatedPosts.map(relatedPost => (
<li>
<a href={`/posts/${relatedPost.slug}/`} class="related-post">
<div class="related-post-title">{relatedPost.data.title}</div>
<div class="related-post-meta">
<span class="related-post-date">{formatDate(relatedPost.data.pubDate)}</span>
</div>
</a>
</li>
))}
</ul>
</div>
)}
</aside>
</div>
<div class="post-navigation">
<a href="/blog" class="back-to-blog">
<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">
<line x1="19" y1="12" x2="5" y2="12"></line>
<polyline points="12 19 5 12 12 5"></polyline>
</svg>
Back to Blog
</a>
</div>
</article>
</BaseLayout>
<style>
.container {
max-width: 1280px;
margin: 0 auto;
padding: 0 var(--container-padding, 1.5rem);
}
.blog-post {
padding: 2rem 0;
}
.post-header {
margin-bottom: 2rem;
text-align: center;
}
.post-header h1 {
font-size: var(--font-size-4xl, 2.25rem);
margin-bottom: 1rem;
line-height: 1.2;
}
.post-meta {
display: flex;
flex-wrap: wrap;
justify-content: center;
gap: 1rem;
color: var(--text-tertiary);
font-size: var(--font-size-sm, 0.875rem);
font-family: var(--font-mono);
}
.hero-image {
margin-bottom: 2rem;
border-radius: 12px;
overflow: hidden;
border: 1px solid var(--border-primary);
}
.hero-image img {
width: 100%;
height: auto;
display: block;
}
.post-content {
display: grid;
grid-template-columns: 3fr 1fr;
gap: 2rem;
}
.post-body {
background: var(--card-bg);
border-radius: 12px;
padding: 2rem;
border: 1px solid var(--border-primary);
}
.post-sidebar {
display: flex;
flex-direction: column;
gap: 1.5rem;
}
.sidebar-block {
background: var(--card-bg);
border-radius: 12px;
padding: 1.5rem;
border: 1px solid var(--border-primary);
}
.sidebar-block h3 {
font-size: var(--font-size-lg, 1.125rem);
margin-bottom: 1rem;
color: var(--text-primary);
}
.tags {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
}
.tag {
display: inline-block;
padding: 0.25rem 0.75rem;
background: rgba(16, 185, 129, 0.1);
border-radius: 20px;
color: #10B981;
font-size: var(--font-size-xs, 0.75rem);
text-decoration: none;
transition: all 0.2s ease;
}
.tag:hover {
background: rgba(16, 185, 129, 0.2);
transform: translateY(-2px);
}
.category, .categories {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.category {
display: inline-block;
padding: 0.5rem 1rem;
background: linear-gradient(90deg, var(--accent-primary), var(--accent-secondary));
border-radius: 20px;
color: var(--bg-primary);
font-size: var(--font-size-sm, 0.875rem);
text-decoration: none;
font-weight: 500;
transition: all 0.3s ease;
text-align: center;
}
.category:hover {
transform: translateY(-2px);
box-shadow: 0 5px 15px rgba(0, 0, 0, 0.1);
}
/* Related Posts */
.related-posts {
list-style: none;
padding: 0;
margin: 0;
}
.related-posts li {
margin-bottom: 1rem;
}
.related-posts li:last-child {
margin-bottom: 0;
}
.related-post {
display: block;
padding: 0.75rem;
border-radius: 8px;
border: 1px solid var(--border-primary);
text-decoration: none;
transition: all 0.2s ease;
}
.related-post:hover {
background: rgba(6, 182, 212, 0.05);
border-color: var(--accent-primary);
transform: translateY(-2px);
}
.related-post-title {
color: var(--text-primary);
font-size: 0.9rem;
margin-bottom: 0.5rem;
font-weight: 500;
}
.related-post-meta {
font-size: 0.75rem;
color: var(--text-tertiary);
font-family: var(--font-mono);
}
/* Post Navigation */
.post-navigation {
margin-top: 3rem;
display: flex;
justify-content: center;
}
.back-to-blog {
display: flex;
align-items: center;
gap: 0.5rem;
background: var(--card-bg);
border: 1px solid var(--border-primary);
padding: 0.75rem 1.5rem;
border-radius: 30px;
color: var(--text-primary);
text-decoration: none;
font-weight: 500;
transition: all 0.2s ease;
}
.back-to-blog:hover {
border-color: var(--accent-primary);
background: var(--bg-tertiary);
transform: translateY(-2px);
box-shadow: 0 5px 15px rgba(0, 0, 0, 0.05);
}
/* Content Styling */
.post-body {
font-size: 1.05rem;
line-height: 1.7;
color: var(--text-primary);
}
.post-body h2 {
font-size: 1.8rem;
margin: 2rem 0 1rem;
color: var(--text-primary);
}
.post-body h3 {
font-size: 1.4rem;
margin: 1.75rem 0 0.75rem;
color: var(--text-primary);
}
.post-body p {
margin-bottom: 1.25rem;
}
.post-body a {
color: var(--accent-primary);
text-decoration: none;
border-bottom: 1px dashed var(--accent-primary);
transition: all 0.2s ease;
}
.post-body a:hover {
color: var(--accent-secondary);
border-bottom-style: solid;
}
.post-body ul, .post-body ol {
margin: 1rem 0 1.5rem 1.5rem;
}
.post-body li {
margin-bottom: 0.5rem;
}
.post-body blockquote {
margin: 1.5rem 0;
padding: 1rem 1.5rem;
border-left: 4px solid var(--accent-primary);
background: rgba(6, 182, 212, 0.05);
border-radius: 0 8px 8px 0;
color: var(--text-secondary);
font-style: italic;
}
.post-body code {
font-family: var(--font-mono);
background: rgba(15, 23, 42, 0.3);
padding: 0.2rem 0.4rem;
border-radius: 4px;
font-size: 0.9em;
}
.post-body pre {
background: rgba(15, 23, 42, 0.3);
padding: 1rem;
border-radius: 8px;
overflow-x: auto;
margin: 1.5rem 0;
border: 1px solid var(--border-primary);
}
.post-body pre code {
background: transparent;
padding: 0;
font-size: 0.9em;
color: var(--text-primary);
}
.post-body img {
max-width: 100%;
height: auto;
border-radius: 8px;
margin: 1.5rem 0;
}
.post-body table {
width: 100%;
border-collapse: collapse;
margin: 1.5rem 0;
}
.post-body th, .post-body td {
border: 1px solid var(--border-primary);
padding: 0.75rem;
}
.post-body th {
background: rgba(15, 23, 42, 0.3);
font-weight: 600;
text-align: left;
}
/* Responsive Adjustments */
@media (max-width: 1024px) {
.post-header h1 {
font-size: var(--font-size-3xl, 1.875rem);
}
}
@media (max-width: 768px) {
.post-content {
grid-template-columns: 1fr;
}
.post-header h1 {
font-size: var(--font-size-2xl, 1.5rem);
}
.post-body {
padding: 1.5rem;
}
}
</style>
<script>
// Add functionality to handle post sharing and tag clicks
document.addEventListener('DOMContentLoaded', () => {
// Add click functionality to tags
document.querySelectorAll('.tag').forEach(tag => {
tag.addEventListener('click', (e) => {
// Already links, no need for additional JS
});
});
// Add scroll-to-top button when scrolling down
const scrollToTop = document.createElement('button');
scrollToTop.className = 'scroll-to-top';
scrollToTop.innerHTML = `
<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">
<polyline points="18 15 12 9 6 15"></polyline>
</svg>
`;
document.body.appendChild(scrollToTop);
// Show/hide scroll-to-top button
window.addEventListener('scroll', () => {
if (window.scrollY > 500) {
scrollToTop.classList.add('visible');
} else {
scrollToTop.classList.remove('visible');
}
});
// Scroll to top when clicked
scrollToTop.addEventListener('click', () => {
window.scrollTo({
top: 0,
behavior: 'smooth'
});
});
});
</script>
<style is:global>
/* Scroll to top button */
.scroll-to-top {
position: fixed;
bottom: 30px;
right: 30px;
width: 50px;
height: 50px;
border-radius: 50%;
background: linear-gradient(135deg, var(--accent-primary), var(--accent-secondary));
color: white;
border: none;
box-shadow: 0 5px 15px rgba(0, 0, 0, 0.2);
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
opacity: 0;
visibility: hidden;
transition: all 0.3s ease;
transform: translateY(20px);
z-index: 100;
}
.scroll-to-top.visible {
opacity: 1;
visibility: visible;
transform: translateY(0);
}
.scroll-to-top:hover {
transform: translateY(-5px);
box-shadow: 0 8px 20px rgba(0, 0, 0, 0.3);
}
@media (max-width: 768px) {
.scroll-to-top {
width: 40px;
height: 40px;
bottom: 20px;
right: 20px;
}
.scroll-to-top svg {
width: 20px;
height: 20px;
}
}
</style>

View File

@ -0,0 +1,32 @@
// src/pages/search-index.json.js
// Generates a JSON file with all posts for client-side search
import { getCollection } from 'astro:content';
export async function get() {
// Get all posts
const allPosts = await getCollection('posts', ({ data }) => {
// Exclude draft posts in production
return import.meta.env.PROD ? !data.draft : true;
});
// Transform posts into search-friendly format
const searchablePosts = allPosts.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 || 'Uncategorized',
tags: post.data.tags || [],
readTime: post.data.readTime || '5 min read',
}));
// Return JSON
return {
body: JSON.stringify(searchablePosts),
headers: {
'Content-Type': 'application/json',
'Cache-Control': 'max-age=3600'
}
}
}

View File

@ -1,82 +1,132 @@
/* Theme Variables - Dark/Light Mode Support */ /* src/styles/theme.css */
/* Dark theme (default) */ /* Base Variables (Dark Mode Default) */
html { :root {
/* Keep the default dark theme as defined in BaseLayout */ --bg-primary: #0f1219;
--bg-secondary: #161a24;
--bg-tertiary: #1e2330;
--bg-code: #1a1e2a;
--text-primary: #e2e8f0;
--text-secondary: #a0aec0;
--text-tertiary: #718096;
--accent-primary: #06b6d4; /* Cyan */
--accent-secondary: #3b82f6; /* Blue */
--accent-tertiary: #8b5cf6; /* Violet */
--glow-primary: rgba(6, 182, 212, 0.2);
--glow-secondary: rgba(59, 130, 246, 0.2);
--glow-tertiary: rgba(139, 92, 246, 0.2);
--border-primary: rgba(255, 255, 255, 0.1);
--border-secondary: rgba(255, 255, 255, 0.05);
--card-bg: rgba(24, 28, 44, 0.5);
--card-border: rgba(56, 189, 248, 0.2); /* Cyan border */
--ui-element: #1e293b;
--ui-element-hover: #334155;
--container-padding: clamp(1rem, 5vw, 3rem);
--font-size-xs: 0.75rem;
--font-size-sm: 0.875rem;
--font-size-md: 1rem;
--font-size-lg: 1.125rem;
--font-size-xl: 1.25rem;
--font-size-2xl: 1.5rem;
--font-size-3xl: 1.875rem;
--font-size-4xl: 2.25rem;
--font-size-5xl: 3rem;
--font-mono: 'JetBrains Mono', monospace;
--font-sans: 'Inter', -apple-system, BlinkMacSystemFont, sans-serif;
--bg-primary-rgb: 15, 18, 25; /* RGB for gradients */
--bg-secondary-rgb: 22, 26, 36; /* RGB for gradients */
} }
/* Light theme */ /* Light Mode Variables */
html.light-mode { :root.light-mode {
/* Primary Colors */ --bg-primary: #ffffff;
--bg-primary: #f8fafc; --bg-secondary: #f8fafc; /* Lighter secondary */
--bg-secondary: #f1f5f9; --bg-tertiary: #f1f5f9; /* Even lighter tertiary */
--bg-tertiary: #e2e8f0;
--bg-code: #f1f5f9; --bg-code: #f1f5f9;
--text-primary: #0f172a; --text-primary: #1e293b; /* Darker primary text */
--text-secondary: #334155; --text-secondary: #475569; /* Darker secondary text */
--text-tertiary: #64748b; --text-tertiary: #64748b; /* Darker tertiary text */
--accent-primary: #0891b2; /* Slightly darker cyan */
/* Accent Colors remain the same for brand consistency */ --accent-secondary: #2563eb; /* Slightly darker blue */
--accent-tertiary: #7c3aed; /* Slightly darker violet */
/* Glow Effects - lighter for light mode */ --glow-primary: rgba(8, 145, 178, 0.15);
--glow-primary: rgba(6, 182, 212, 0.1); --glow-secondary: rgba(37, 99, 235, 0.15);
--glow-secondary: rgba(59, 130, 246, 0.1); --glow-tertiary: rgba(124, 58, 237, 0.15);
--glow-tertiary: rgba(139, 92, 246, 0.1); --border-primary: rgba(0, 0, 0, 0.1); /* Darker borders */
/* Border Colors */
--border-primary: rgba(0, 0, 0, 0.1);
--border-secondary: rgba(0, 0, 0, 0.05); --border-secondary: rgba(0, 0, 0, 0.05);
--card-bg: rgba(255, 255, 255, 0.8); /* White card with opacity */
/* Card Background */ --card-border: rgba(37, 99, 235, 0.3); /* Blue border */
--card-bg: rgba(255, 255, 255, 0.8); --ui-element: #e2e8f0; /* Lighter UI elements */
--card-border: rgba(56, 189, 248, 0.3); /* Slightly stronger border */
/* UI Element Colors */
--ui-element: #e2e8f0;
--ui-element-hover: #cbd5e1; --ui-element-hover: #cbd5e1;
--bg-primary-rgb: 255, 255, 255; /* RGB for gradients */
--bg-secondary-rgb: 248, 250, 252; /* RGB for gradients */
} }
/* Background adjustments for light mode */ /* Ensure transitions for smooth theme changes */
html.light-mode body { *, *::before, *::after {
background-image: transition: background-color 0.3s ease, color 0.3s ease, border-color 0.3s ease, box-shadow 0.3s ease;
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%);
} }
/* Adding light mode grid overlay */ /* Knowledge Graph specific theme adjustments */
html.light-mode body::before { :root.light-mode .graph-container {
background-image: background: rgba(248, 250, 252, 0.3);
linear-gradient(rgba(15, 23, 42, 0.03) 1px, transparent 1px), border: 1px solid var(--card-border);
linear-gradient(90deg, rgba(15, 23, 42, 0.03) 1px, transparent 1px);
} }
/* Theme transition for smooth switching */ :root.light-mode .node-details {
html, body, * { background: rgba(255, 255, 255, 0.8);
transition: box-shadow: 0 10px 25px rgba(0, 0, 0, 0.1);
background-color 0.3s ease,
color 0.3s ease,
border-color 0.3s ease,
box-shadow 0.3s ease;
} }
/* Knowledge Graph light mode adjustments */ :root.light-mode .graph-filters {
html.light-mode .graph-container { background: rgba(248, 250, 252, 0.7);
background: rgba(248, 250, 252, 0.6);
} }
html.light-mode .graph-loading { :root.light-mode .graph-filter {
background: rgba(241, 245, 249, 0.7); color: var(--text-secondary);
border-color: var(--border-primary);
} }
html.light-mode .graph-filters { :root.light-mode .connections-list a {
background: rgba(241, 245, 249, 0.7); color: var(--accent-secondary);
} }
html.light-mode .graph-legend { :root.light-mode .node-link {
background: rgba(241, 245, 249, 0.7); box-shadow: 0 4px 10px rgba(8, 145, 178, 0.15);
} }
html.light-mode .node-details { /* Fix for code blocks in light mode */
background: rgba(248, 250, 252, 0.9); :root.light-mode pre,
:root.light-mode code {
background-color: var(--bg-code);
color: var(--text-secondary);
}
/* Apply base styles using variables */
body {
background-color: var(--bg-primary);
color: var(--text-primary);
}
a {
color: var(--accent-primary);
}
/* Fix for inputs/selects */
input, select, textarea {
background-color: var(--bg-secondary);
color: var(--text-primary);
border-color: var(--border-primary);
}
/* Ensure header and footer adapt to theme */
.site-header, .site-footer {
background-color: var(--bg-secondary);
border-color: var(--border-primary);
}
/* Fix card styles */
.post-card, .sidebar-block {
background-color: var(--card-bg);
border-color: var(--card-border);
} }