feat: Implement theme toggler and update related components

This commit is contained in:
Daniel LaForce 2025-04-23 13:55:23 -06:00
parent b392c30aba
commit e66e605479
6 changed files with 470 additions and 221 deletions

View File

@ -7,7 +7,7 @@ import tailwind from '@astrojs/tailwind';
// https://astro.build/config
export default defineConfig({
site: 'https://laforceit.blog',
site: 'https://laforceit-blog.pages.dev', // Your current Cloudflare site
output: 'static',
// adapter: cloudflare(), // Commented out for local development
integrations: [
@ -17,10 +17,14 @@ export default defineConfig({
],
markdown: {
shikiConfig: {
theme: 'dracula',
theme: 'one-dark-pro',
wrap: true
},
remarkPlugins: [],
rehypePlugins: []
},
compressHTML: false, // Disable HTML compression to avoid parsing errors
build: {
format: 'file', // Use 'file' instead of 'directory' format
}
});

View File

@ -379,6 +379,70 @@ const navItems = [
</style>
<script>
// Handle mobile menu toggle
document.addEventListener('DOMContentLoaded', () => {
const menuBtn = document.getElementById('mobile-menu-btn');
const mainNav = document.querySelector('.main-nav');
const header = document.querySelector('.site-header');
if (menuBtn && mainNav) {
menuBtn.addEventListener('click', () => {
mainNav.classList.toggle('active');
menuBtn.classList.toggle('mobile-menu-active');
});
}
// Header scroll effect
window.addEventListener('scroll', () => {
if (window.scrollY > 50) {
header?.classList.add('scrolled');
} else {
header?.classList.remove('scrolled');
}
});
// Theme toggle functionality
const themeToggle = document.getElementById('theme-toggle');
if (themeToggle) {
themeToggle.addEventListener('click', () => {
document.documentElement.classList.toggle('light-mode');
// Store preference in localStorage
const isLightMode = document.documentElement.classList.contains('light-mode');
localStorage.setItem('theme', isLightMode ? 'light' : 'dark');
});
}
// Add interactive network nodes animation
const header_el = document.querySelector('.site-header');
if (header_el) {
// Create animated nodes
for (let i = 0; i < 5; i++) {
const node = document.createElement('div');
node.className = 'nav-node';
node.style.left = `${Math.random() * 100}%`;
node.style.animationDelay = `${Math.random() * 5}s`;
node.style.animationDuration = `${5 + Math.random() * 5}s`;
header_el.appendChild(node);
}
}
// Theme toggle functionality
const themeToggle = document.getElementById('theme-toggle');
if (themeToggle) {
themeToggle.addEventListener('click', () => {
document.documentElement.classList.toggle('light-mode');
// Store preference in localStorage
const isLightMode = document.documentElement.classList.contains('light-mode');
localStorage.setItem('theme', isLightMode ? 'light' : 'dark');
});
}
// Handle mobile menu toggle
document.addEventListener('DOMContentLoaded', () => {
const menuBtn = document.getElementById('mobile-menu-btn');

View File

@ -0,0 +1,93 @@
---
// ThemeToggler.astro
// A component to toggle between light and dark themes
---
<button id="theme-toggle" aria-label="Toggle dark mode" class="theme-toggle">
<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" class="sun-icon">
<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 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" class="moon-icon">
<path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"></path>
</svg>
</button>
<style>
.theme-toggle {
background: none;
border: none;
padding: 0.25rem;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
color: var(--text-secondary);
cursor: pointer;
transition: color 0.3s ease, background-color 0.3s ease;
position: relative;
width: 34px;
height: 34px;
}
.theme-toggle:hover {
color: var(--text-primary);
background: rgba(255, 255, 255, 0.1);
}
.sun-icon, .moon-icon {
position: absolute;
transition: transform 0.5s ease, opacity 0.5s ease;
}
html:not(.dark) .sun-icon {
opacity: 1;
transform: rotate(0);
}
html:not(.dark) .moon-icon {
opacity: 0;
transform: rotate(90deg);
}
html.dark .sun-icon {
opacity: 0;
transform: rotate(-90deg);
}
html.dark .moon-icon {
opacity: 1;
transform: rotate(0);
}
</style>
<script>
// Theme toggling logic
document.addEventListener('DOMContentLoaded', () => {
const themeToggle = document.getElementById('theme-toggle');
// Function to set theme
const setTheme = (isDark) => {
if (isDark) {
document.documentElement.classList.add('dark');
localStorage.setItem('theme', 'dark');
} else {
document.documentElement.classList.remove('dark');
localStorage.setItem('theme', 'light');
}
};
// Theme toggle click handler
themeToggle?.addEventListener('click', () => {
const isDark = document.documentElement.classList.contains('dark');
setTheme(!isDark);
});
});
</script>

View File

@ -23,6 +23,18 @@ const {
<title>{title}</title>
<meta name="description" content={description} />
<!-- Theme initialization - Must be inline -->
<script is:inline>
// Initialize theme before page loads to prevent flash
const savedTheme = localStorage.getItem('theme');
const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
if (savedTheme === 'light' || (!savedTheme && !prefersDark)) {
document.documentElement.classList.add('light-mode');
} else {
document.documentElement.classList.remove('light-mode');
}
</script>
<!-- OpenGraph/Social Media Meta Tags -->
<meta property="og:title" content={title} />
<meta property="og:description" content={description} />
@ -44,6 +56,9 @@ const {
<!-- Favicon -->
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<!-- Theme CSS -->
<link rel="stylesheet" href="/styles/theme.css" />
<!-- Cytoscape Library for Knowledge Graph -->
<script src="https://unpkg.com/cytoscape@3.25.0/dist/cytoscape.min.js" is:inline></script>

View File

@ -1,255 +1,246 @@
import { getCollection } from 'astro:content';
---
// src/pages/tag/[tag].astro
// Dynamic route for tag pages
import BaseLayout from '../../layouts/BaseLayout.astro';
import { getCollection } from 'astro:content';
export async function getStaticPaths() {
const allPosts = await getCollection('posts');
// Get all unique tags from all posts
const uniqueTags = [...new Set(allPosts.flatMap(post => post.data.tags || []))];
// Create a page for each tag
return uniqueTags.map(tag => {
// Filter posts that have this tag
const filteredPosts = allPosts.filter(post =>
post.data.tags && post.data.tags.includes(tag)
);
const allPosts = await getCollection('blog');
const uniqueTags = [...new Set(allPosts.map((post) => post.data.tags).flat())];
return uniqueTags.map((tag) => {
const filteredPosts = allPosts.filter((post) => post.data.tags.includes(tag));
return {
params: { tag },
props: { posts: filteredPosts, tag },
props: { posts: filteredPosts },
};
});
}
const { posts, tag } = Astro.props;
const { tag } = Astro.params;
const { posts } = Astro.props;
// Sort posts by date
// Format date
const formatDate = (dateStr) => {
const date = new Date(dateStr);
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 = a.data.pubDate ? new Date(a.data.pubDate) : new Date(0);
const dateB = b.data.pubDate ? new Date(b.data.pubDate) : new Date(0);
const dateA = new Date(a.data.pubDate);
const dateB = new Date(b.data.pubDate);
return dateB.getTime() - dateA.getTime();
});
---
<BaseLayout title={`Posts tagged with "${tag}" | LaForce IT Blog`} description={`Articles and guides related to ${tag}`}>
<main class="container">
<section class="tag-header">
<h1 class="tag-title">Posts tagged with <span>#{tag}</span></h1>
<p class="tag-description">
Browse all {sortedPosts.length} articles related to this topic
</p>
<a href="/tags" class="tag-link">View all tags</a>
</section>
<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>
<div class="blog-grid">
<div class="posts-grid">
{sortedPosts.map((post) => (
<article class="post-card">
{/* Temporarily removed conditional image rendering for debugging */}
<!-- Simplified image rendering that works reliably -->
<img
width={720}
height={360}
src="/images/placeholders/default.jpg"
src={post.data.heroImage || "/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">
<time datetime={post.data.pubDate}>{formatDate(post.data.pubDate)}</time>
<h2 class="post-title">
<a href={`/posts/${post.slug}/`}>{post.data.title}</a>
{post.data.draft && <span class="draft-badge">Draft</span>}
</h3>
</h2>
<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={`/posts/${post.slug}/`} class="read-more">Read More</a>
<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>
))}
</div>
</main>
<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">
<line x1="19" y1="12" x2="5" y2="12"></line>
<polyline points="12 19 5 12 12 5"></polyline>
</svg>
View all tags
</a>
</div>
</BaseLayout>
<style>
.tag-header {
margin: 3rem 0;
text-align: center;
}
.tag-title {
font-size: clamp(1.8rem, 4vw, 2.5rem);
margin-bottom: 1rem;
}
.tag-title span {
background: linear-gradient(90deg, var(--accent-primary), var(--accent-tertiary));
-webkit-background-clip: text;
background-clip: text;
color: transparent;
font-family: 'JetBrains Mono', monospace;
}
.tag-description {
color: var(--text-secondary);
font-size: 1.1rem;
margin-bottom: 1rem;
}
.tag-link {
display: inline-block;
margin-top: 1rem;
color: var(--accent-primary);
text-decoration: none;
font-family: 'JetBrains Mono', monospace;
font-size: 0.9rem;
border-bottom: 1px dashed var(--accent-primary);
transition: all 0.3s ease;
}
.tag-link:hover {
border-bottom: 1px solid var(--accent-primary);
}
.draft-badge {
display: inline-block;
margin-left: 0.5rem;
padding: 0.25rem 0.5rem;
background-color: rgba(226, 232, 240, 0.2);
color: #94a3b8;
font-size: 0.75rem;
border-radius: 0.25rem;
vertical-align: middle;
}
/* Include styles from blog index if needed, like .blog-grid, .post-card etc. */
.blog-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
gap: 2rem;
margin: 2rem 0 4rem;
}
.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: '→';
}
</style>
.tag-page {
padding-top: 2rem;
padding-bottom: 4rem;
}
.tag-hero {
text-align: center;
margin-bottom: 3rem;
animation: fadeIn 0.5s ease-out;
}
@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;
display: flex;
flex-direction: column;
}
.post-card:hover {
transform: translateY(-5px);
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.2);
border-color: var(--accent-primary);
}
.post-image {
width: 100%;
height: 200px;
object-fit: cover;
border-bottom: 1px solid var(--border-primary);
}
.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;
}
.reading-time {
color: var(--text-tertiary);
font-size: var(--font-size-sm);
font-family: var(--font-mono);
}
.post-tags {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
list-style: none;
padding: 0;
}
.post-tags li a {
display: block;
padding: 0.25rem 0.75rem;
background: rgba(56, 189, 248, 0.1);
border-radius: 20px;
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);
}
.post-tags li a.current-tag {
background: var(--accent-primary);
color: var(--bg-primary);
}
.all-tags-link {
display: flex;
align-items: center;
gap: 0.5rem;
margin: 0 auto;
padding: 0.75rem 1.5rem;
background: var(--bg-secondary);
border: 1px solid var(--border-primary);
border-radius: 30px;

82
src/styles/theme.css Normal file
View File

@ -0,0 +1,82 @@
/* Theme Variables - Dark/Light Mode Support */
/* Dark theme (default) */
html {
/* Keep the default dark theme as defined in BaseLayout */
}
/* Light theme */
html.light-mode {
/* Primary Colors */
--bg-primary: #f8fafc;
--bg-secondary: #f1f5f9;
--bg-tertiary: #e2e8f0;
--bg-code: #f1f5f9;
--text-primary: #0f172a;
--text-secondary: #334155;
--text-tertiary: #64748b;
/* Accent Colors remain the same for brand consistency */
/* Glow Effects - lighter for light mode */
--glow-primary: rgba(6, 182, 212, 0.1);
--glow-secondary: rgba(59, 130, 246, 0.1);
--glow-tertiary: rgba(139, 92, 246, 0.1);
/* Border Colors */
--border-primary: rgba(0, 0, 0, 0.1);
--border-secondary: rgba(0, 0, 0, 0.05);
/* Card Background */
--card-bg: rgba(255, 255, 255, 0.8);
--card-border: rgba(56, 189, 248, 0.3); /* Slightly stronger border */
/* UI Element Colors */
--ui-element: #e2e8f0;
--ui-element-hover: #cbd5e1;
}
/* Background adjustments for light mode */
html.light-mode body {
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%);
}
/* Adding light mode grid overlay */
html.light-mode body::before {
background-image:
linear-gradient(rgba(15, 23, 42, 0.03) 1px, transparent 1px),
linear-gradient(90deg, rgba(15, 23, 42, 0.03) 1px, transparent 1px);
}
/* Theme transition for smooth switching */
html, body, * {
transition:
background-color 0.3s ease,
color 0.3s ease,
border-color 0.3s ease,
box-shadow 0.3s ease;
}
/* Knowledge Graph light mode adjustments */
html.light-mode .graph-container {
background: rgba(248, 250, 252, 0.6);
}
html.light-mode .graph-loading {
background: rgba(241, 245, 249, 0.7);
}
html.light-mode .graph-filters {
background: rgba(241, 245, 249, 0.7);
}
html.light-mode .graph-legend {
background: rgba(241, 245, 249, 0.7);
}
html.light-mode .node-details {
background: rgba(248, 250, 252, 0.9);
}