diff --git a/src/components/Header.astro b/src/components/Header.astro index cd066a7..65080b0 100644 --- a/src/components/Header.astro +++ b/src/components/Header.astro @@ -1,439 +1,505 @@ --- -// Header.astro -// Primary navigation component with premium design elements +// src/components/Header.astro +import ThemeToggler from './ThemeToggler.astro'; -// Get current path to highlight active nav item -const pathname = new URL(Astro.request.url).pathname; -const currentPath = pathname.split('/')[1]; // Get the first path segment - -// Define navigation items +// Define navigation items with proper URLs const navItems = [ - { name: 'Home', path: '/' }, - { name: 'Blog', path: '/blog/' }, - { name: 'Projects', path: '/projects/' }, - { name: 'Home Lab', path: '/homelab/' }, - { name: 'Resources', path: '/resources/' }, - { name: 'About', path: '/about/' }, - { name: 'Contact', path: '/contact/' } + { name: 'Home', url: '/' }, + { name: 'Blog', url: '/blog' }, + { name: 'Projects', url: '/projects' }, + { name: 'Home Lab', url: 'https://argobox.com' }, + { name: 'Resources', url: '/resources' }, + { name: 'About', url: 'https://laforceit.com' }, + { name: 'Contact', url: 'https://laforceit.com/index.html#contact' } ]; + +// Get current URL path for active nav item highlighting +const currentPath = Astro.url.pathname; --- \ No newline at end of file diff --git a/src/components/KnowledgeGraph.astro b/src/components/KnowledgeGraph.astro index e795ffc..854e098 100644 --- a/src/components/KnowledgeGraph.astro +++ b/src/components/KnowledgeGraph.astro @@ -1,18 +1,20 @@ --- // src/components/KnowledgeGraph.astro -// Interactive visualization of content connections using Cytoscape.js +// Enhanced interactive visualization of content connections using Cytoscape.js export interface GraphNode { id: string; label: string; + type: 'post' | 'tag' | 'category'; // Node types to distinguish posts from tags category?: string; tags?: string[]; - url?: string; // Added URL for linking + url?: string; // URL for linking } export interface GraphEdge { source: string; target: string; + type: 'post-tag' | 'post-category' | 'post-post'; // Edge types strength?: number; } @@ -23,14 +25,20 @@ export interface GraphData { interface Props { graphData: GraphData; - height?: string; // e.g., '600px' + height?: string; // e.g., '500px' + initialFilter?: string; // Optional initial filter } -const { graphData, height = "60vh" } = Astro.props; +const { graphData, height = "50vh", initialFilter = "all" } = Astro.props; -// Generate colors based on categories for nodes -const uniqueCategories = [...new Set(graphData.nodes.map(node => node.category || 'Uncategorized'))]; -const categoryColors = {}; +// Generate colors based on node types +const nodeTypeColors = { + 'post': '#3B82F6', // Blue for posts + 'tag': '#10B981', // Green for tags + 'category': '#8B5CF6' // Purple for categories +}; + +// Generate predefined colors for categories const predefinedColors = { 'Kubernetes': '#326CE5', 'Docker': '#2496ED', 'DevOps': '#FF6F61', 'Homelab': '#06B6D4', 'Networking': '#9333EA', 'Infrastructure': '#10B981', @@ -38,18 +46,10 @@ const predefinedColors = { 'Storage': '#8B5CF6', 'Obsidian': '#7C3AED', 'Tutorial': '#3B82F6', 'Uncategorized': '#A0AEC0' }; -uniqueCategories.forEach((category, index) => { - if (predefinedColors[category]) { - categoryColors[category] = predefinedColors[category]; - } else { - const hue = (index * 137.5) % 360; - categoryColors[category] = `hsl(${hue}, 70%, 60%)`; - } -}); // Calculate node sizes const nodeSizes = {}; -const minSize = 20; const maxSize = 40; +const minSize = 15; const maxSize = 35; const degreeMap = new Map(); graphData.nodes.forEach(node => degreeMap.set(node.id, 0)); graphData.edges.forEach(edge => { @@ -59,15 +59,47 @@ graphData.edges.forEach(edge => { const maxDegree = Math.max(...Array.from(degreeMap.values()), 1); graphData.nodes.forEach(node => { const degree = degreeMap.get(node.id) || 0; + // Make tags slightly smaller than posts by default + const baseSize = node.type === 'post' ? minSize : minSize * 0.8; const normalizedSize = maxDegree === 0 ? 0.5 : degree / maxDegree; - nodeSizes[node.id] = minSize + normalizedSize * (maxSize - minSize); + nodeSizes[node.id] = baseSize + normalizedSize * (maxSize - minSize); }); + +// Count node types for legend +const nodeTypeCounts = { + post: graphData.nodes.filter(node => node.type === 'post').length, + tag: graphData.nodes.filter(node => node.type === 'tag').length, + category: graphData.nodes.filter(node => node.type === 'category').length +}; --- -
+
+ +
+
+ How to use the Knowledge Graph +
+

This Knowledge Graph visualizes connections between blog content:

+
    +
  • Posts - Blog articles (circle nodes)
  • +
  • Tags - Content topics (diamond nodes)
  • +
+

Interactions:

+
    +
  • Click a node to see its connections and details
  • +
  • Click a tag node to filter posts by that tag
  • +
  • Click a post node to highlight that specific post
  • +
  • Use the filter buttons below to focus on specific topics
  • +
  • Use mouse wheel to zoom in/out and drag to pan
  • +
  • Click an empty area to reset the view
  • +
+
+
+
+
@@ -89,6 +121,9 @@ graphData.nodes.forEach(node => {
+
+ Type +
Category: Category Name @@ -105,16 +140,15 @@ graphData.nodes.forEach(node => {
- Read Article + View Content
- - {uniqueCategories.map(category => ( - - ))} + + +
- +
- - -
- Legend -
- {uniqueCategories.map(category => ( -
- - {category} -
- ))} -
-
- \ No newline at end of file diff --git a/src/components/DigitalGardenGraph.astro b/src/components/deleteDigitalGardenGraph.astro similarity index 100% rename from src/components/DigitalGardenGraph.astro rename to src/components/deleteDigitalGardenGraph.astro diff --git a/src/content/config.ts b/src/content/config.ts index 7dd588e..6531b14 100644 --- a/src/content/config.ts +++ b/src/content/config.ts @@ -1,98 +1,100 @@ +// src/content/config.ts import { defineCollection, z } from 'astro:content'; -// Define custom date validator that handles multiple formats -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 +// Define the post collection schema const postsCollection = defineCollection({ type: 'content', - schema: baseSchema, -}); - -const configurationsCollection = defineCollection({ - type: 'content', - schema: baseSchema, -}); - -const projectsCollection = defineCollection({ - type: 'content', - schema: baseSchema, + schema: z.object({ + title: z.string(), + description: z.string().optional(), + pubDate: z.coerce.date(), + updatedDate: z.coerce.date().optional(), + heroImage: z.string().optional(), + + // Support both single category and categories array + category: z.string().optional(), + categories: z.array(z.string()).optional(), + + // 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({ 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 const collections = { - 'posts': postsCollection, - 'configurations': configurationsCollection, - 'projects': projectsCollection, + posts: postsCollection, 'external-posts': externalPostsCollection, + configurations: configurationsCollection, + projects: projectsCollection, }; \ No newline at end of file diff --git a/src/content/projects/placeholder.md b/src/content/projects/placeholder.md index 8793264..8d3629d 100644 --- a/src/content/projects/placeholder.md +++ b/src/content/projects/placeholder.md @@ -3,7 +3,7 @@ title: "Projects Collection" description: "A placeholder document for the projects collection" heroImage: "/blog/images/placeholders/default.jpg" pubDate: 2025-04-18 -status: "planning" +status: "concept" # Changed from 'planning' to match schema tech: ["astro", "markdown"] --- diff --git a/src/pages/blog/index.astro b/src/pages/blog/index.astro index cbf6ce4..4a7e418 100644 --- a/src/pages/blog/index.astro +++ b/src/pages/blog/index.astro @@ -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 BaseLayout from '../../layouts/BaseLayout.astro'; import KnowledgeGraph from '../../components/KnowledgeGraph.astro'; @@ -37,38 +37,64 @@ const postsData = sortedPosts.map(post => ({ isDraft: post.data.draft || false })); -// Prepare graph data for visualization +// Prepare enhanced graph data with both posts and tags const graphData = { - nodes: sortedPosts - .filter(post => !post.data.draft) - .map(post => ({ - id: post.slug, - label: post.data.title, - category: post.data.category || 'Uncategorized', - tags: post.data.tags || [] - })), + nodes: [ + // Add post nodes + ...sortedPosts + .filter(post => !post.data.draft) + .map(post => ({ + id: post.slug, + label: post.data.title, + type: 'post', + category: post.data.category || 'Uncategorized', + tags: post.data.tags || [], + url: `/posts/${post.slug}/` + })), + + // Add tag nodes + ...allTags.map(tag => ({ + id: `tag-${tag}`, + label: tag, + type: 'tag', + url: `/tag/${tag}/` + })) + ], edges: [] }; -// Create edges between posts based on shared tags -for (let i = 0; i < graphData.nodes.length; i++) { - const postA = graphData.nodes[i]; - - for (let j = i + 1; j < graphData.nodes.length; j++) { - const postB = graphData.nodes[j]; +// Create edges between posts and their tags +sortedPosts + .filter(post => !post.data.draft) + .forEach(post => { + const postTags = post.data.tags || []; - // Create edge if posts share at least one tag or same category - const sharedTags = postA.tags.filter(tag => postB.tags.includes(tag)); - - if (sharedTags.length > 0 || postA.category === postB.category) { + // Add edges from post to tags + postTags.forEach(tag => { graphData.edges.push({ - source: postA.id, - target: postB.id, - strength: sharedTags.length + (postA.category === postB.category ? 1 : 0) + source: post.slug, + target: `tag-${tag}`, + type: 'post-tag', + strength: 1 + }); + }); + + // Check if post references other posts (optional) + // This requires a related_posts field in frontmatter + if (post.data.related_posts && Array.isArray(post.data.related_posts)) { + post.data.related_posts.forEach(relatedSlug => { + // Make sure related post exists + if (sortedPosts.some(p => p.slug === relatedSlug)) { + graphData.edges.push({ + source: post.slug, + target: relatedSlug, + type: 'post-post', + strength: 2 + }); + } }); } - } -} + }); // Terminal commands for tech effect const commands = [ @@ -79,8 +105,8 @@ const commands = [ }, { prompt: "[laforceit@argobox]$ ", - command: "ls -la ./categories", - output: allCategories.map(cat => `${cat}`) + command: "ls -la ./tags", + output: allTags.map(tag => `${tag}`) }, { prompt: "[laforceit@argobox]$ ", @@ -117,58 +143,52 @@ const commands = [
- -
+ +
-
-

Knowledge Graph

-

- Explore connections between articles based on topics and categories -

-
- -
- + +
+
+

Knowledge Graph & Content Explorer

+

+ Explore connections between articles and topics, or search by keyword +

+
-
- - {allCategories.map(category => ( - - ))} + +
+
-
-
-
- - -
-
-
-

All Articles

-

- Technical insights, infrastructure guides, and DevOps best practices -

-
- - -
- -
- Filter by Tag: - - {allTags.map(tag => ( - - ))} + +
+ +
+ Filter by Tag: + + {allTags.map(tag => ( + + ))} +
- -
-
-
- Loading articles... + +
+
+

All Articles

+

+ Technical insights, infrastructure guides, and DevOps best practices +

+
+ +
+
+
+ Loading articles... +
@@ -182,81 +202,18 @@ const commands = [ const searchInput = document.getElementById('search-input'); const tagButtons = document.querySelectorAll('.tag-filter-btn'); const blogGrid = document.getElementById('blog-grid'); - const graphFilters = document.querySelectorAll('.graph-filter'); // State variables let currentFilterTag = 'all'; let currentSearchTerm = ''; - let currentGraphFilter = 'all'; let cy; // Cytoscape instance will be set by KnowledgeGraph component // Wait for cytoscape instance to be available document.addEventListener('graphReady', (e) => { cy = e.detail.cy; - setupGraphInteractions(); + console.log('Graph ready and connected to filtering system'); }); - // Setup graph 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 createPostCardHTML(post) { // Make sure tags is an array before stringifying @@ -264,23 +221,25 @@ const commands = [ // Create tag pills HTML const tagPills = post.tags.map(tag => - `` + `` ).join(''); return `
-
- - -
+ +
+ + +
+
- {/* Knowledge Graph Visualization */} -
-
-
-

Knowledge Graph

-

- Explore connections between articles based on topics and categories -

-
- -
- - -
- - {allCategories.map(category => ( - - ))} -
-
-
-
- {/* Blog Posts Section */}
@@ -159,9 +142,15 @@ const commands = [ ))}
+ + {/* Integrated Knowledge Graph */} +
+ + {/* We will update graphData generation later */} +
- + {/* Blog Grid (will be populated by JS) */}
@@ -179,12 +168,12 @@ const commands = [ const searchInput = document.getElementById('search-input'); const tagButtons = document.querySelectorAll('.tag-filter-btn'); const blogGrid = document.getElementById('blog-grid'); - const graphFilters = document.querySelectorAll('.graph-filter'); + // Removed graphFilters as category filtering is removed from graph // State variables let currentFilterTag = 'all'; let currentSearchTerm = ''; - let currentGraphFilter = 'all'; + // Removed currentGraphFilter let cy; // Cytoscape instance will be set by KnowledgeGraph component // Wait for cytoscape instance to be available @@ -193,63 +182,70 @@ const commands = [ setupGraphInteractions(); }); - // Setup graph filtering and interactions + // Setup graph interactions (Post and Tag nodes) function setupGraphInteractions() { - if (!cy) return; + if (!cy) { + console.error("Cytoscape instance not ready."); + 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 + // Remove previous category filter logic if any existed + // graphFilters.forEach(...) logic removed + + // Handle clicks on graph nodes cy.on('tap', 'node', function(evt) { const node = evt.target; - const 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; + const nodeId = node.id(); + const nodeType = node.data('type'); // Get type ('post' or 'tag') + + console.log(`Node clicked: ID=${nodeId}, Type=${nodeType}`); // Debug log + + if (nodeType === 'post') { + // Handle post node click: Find post, update search, filter grid, scroll + const post = postsData.find(p => p.slug === nodeId); + if (post) { + console.log(`Post node clicked: ${post.title}`); + // Reset tag filter to 'all' when a specific post is selected via graph + currentFilterTag = 'all'; + tagButtons.forEach(btn => btn.classList.remove('active')); + const allButton = document.querySelector('.tag-filter-btn[data-tag="all"]'); + if (allButton) allButton.classList.add('active'); + + // Update search bar and term + searchInput.value = post.title; // Show post title in search + currentSearchTerm = post.title; // Filter grid by title + + // Update grid to show only this post (or matching search term) + updateGrid(); + + // Scroll to the blog section smoothly + const blogSection = document.querySelector('.blog-posts-section'); + if (blogSection) { + blogSection.scrollIntoView({ behavior: 'smooth', block: 'start' }); + } + } else { + console.warn(`Post data not found for slug: ${nodeId}`); + } + + } else if (nodeType === 'tag') { + // Handle tag node click: Simulate click on corresponding tag filter button + const tagName = nodeId.replace(/^tag-/, ''); // Extract tag name (remove 'tag-' prefix) + console.log(`Tag node clicked: ${tagName}`); - // Update UI - tagButtons.forEach(btn => btn.classList.remove('active')); - tagButtons[0].classList.add('active'); + const correspondingButton = document.querySelector(`.tag-filter-btn[data-tag="${tagName}"]`); - // Update grid with just this post - updateGrid(); - - // Scroll to blog section - document.querySelector('.blog-posts-section').scrollIntoView({ - behavior: 'smooth', - block: 'start' - }); + if (correspondingButton) { + console.log(`Found corresponding button for tag: ${tagName}`); + // Simulate click on the button + correspondingButton.click(); + // Scroll to blog section smoothly + const blogSection = document.querySelector('.blog-posts-section'); + if (blogSection) { + blogSection.scrollIntoView({ behavior: 'smooth', block: 'start' }); + } + } else { + console.warn(`Could not find tag filter button for tag: ${tagName}`); + } } }); } @@ -326,26 +322,43 @@ const commands = [ if (filteredPosts.length > 0) { 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) { - const matchingSlugs = filteredPosts.map(post => post.slug); + const matchingPostSlugs = filteredPosts.map(post => post.slug); - // Reset all nodes - cy.nodes().removeClass('highlighted').removeClass('filtered'); + // Reset styles on all nodes first + cy.nodes().removeClass('highlighted').removeClass('faded'); - // Highlight matching nodes - matchingSlugs.forEach(slug => { - cy.getElementById(slug).addClass('highlighted'); + // Highlight post nodes that are currently visible in the grid + cy.nodes('[type="post"]').forEach(node => { + if (matchingPostSlugs.includes(node.id())) { + node.removeClass('faded').addClass('highlighted'); + } else { + node.removeClass('highlighted').addClass('faded'); // Fade non-matching posts + } }); - - // If filtering by tag, also highlight connected nodes - if (currentFilterTag !== 'all') { - cy.nodes().forEach(node => { - if (node.data('tags')?.includes(currentFilterTag)) { - node.addClass('filtered'); + + // Highlight tag nodes connected to visible posts OR the currently selected tag + cy.nodes('[type="tag"]').forEach(tagNode => { + const tagName = tagNode.id().replace(/^tag-/, ''); + const isSelectedTag = tagName === currentFilterTag; + const isConnectedToVisiblePost = tagNode.connectedEdges().sources().some(postNode => matchingPostSlugs.includes(postNode.id())); + + if (isSelectedTag || (currentFilterTag === 'all' && isConnectedToVisiblePost)) { + tagNode.removeClass('faded').addClass('highlighted'); + } else { + tagNode.removeClass('highlighted').addClass('faded'); + } + }); + + // Adjust edge visibility based on connected highlighted nodes + cy.edges().forEach(edge => { + if (edge.source().hasClass('highlighted') && edge.target().hasClass('highlighted')) { + edge.removeClass('faded').addClass('highlighted'); + } else { + edge.removeClass('highlighted').addClass('faded'); } - }); - } + }); } } else { blogGrid.innerHTML = '

No posts found matching your criteria.

'; @@ -603,6 +616,17 @@ const commands = [ border-color: var(--accent-primary); font-weight: 600; } + + /* Styles for the integrated graph container */ + .integrated-graph-container { + margin-top: 2rem; /* Add space above the graph */ + height: 400px; /* Adjust height as needed */ + border: 1px solid var(--border-primary); + border-radius: 8px; + background: rgba(15, 23, 42, 0.3); /* Slightly different background */ + position: relative; /* Needed for Cytoscape */ + overflow: hidden; /* Hide scrollbars if graph overflows */ + } .blog-grid { margin: 2rem 0 4rem; diff --git a/src/pages/posts/[slug].astro b/src/pages/posts/[slug].astro index 31e86eb..005ee2f 100644 --- a/src/pages/posts/[slug].astro +++ b/src/pages/posts/[slug].astro @@ -1,17 +1,628 @@ --- +// src/pages/posts/[slug].astro 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() { - const postEntries = await getCollection('posts'); - return postEntries.map(entry => ({ - params: { slug: entry.slug }, props: { entry }, - })); + try { + // Get posts from the posts collection + 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 -const { entry } = Astro.props; -const { Content } = await entry.render(); +// Get the post and all posts from props +const { post, allPosts } = Astro.props; + +// 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(); --- - \ No newline at end of file + + +
+
+

{post.data.title}

+ +
+ + {post.data.heroImage && ( +
+ {post.data.title} +
+ )} + +
+
+ +
+ + +
+ +
+ + + + + + Back to Blog + +
+
+
+ + + + + + \ No newline at end of file diff --git a/src/pages/search-index.json.js b/src/pages/search-index.json.js new file mode 100644 index 0000000..8a2ff28 --- /dev/null +++ b/src/pages/search-index.json.js @@ -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' + } + } +} \ No newline at end of file diff --git a/src/styles/theme.css b/src/styles/theme.css index c20ca55..0dc5e50 100644 --- a/src/styles/theme.css +++ b/src/styles/theme.css @@ -1,82 +1,132 @@ -/* Theme Variables - Dark/Light Mode Support */ +/* src/styles/theme.css */ -/* Dark theme (default) */ -html { - /* Keep the default dark theme as defined in BaseLayout */ +/* Base Variables (Dark Mode Default) */ +:root { + --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 */ -html.light-mode { - /* Primary Colors */ - --bg-primary: #f8fafc; - --bg-secondary: #f1f5f9; - --bg-tertiary: #e2e8f0; +/* Light Mode Variables */ +:root.light-mode { + --bg-primary: #ffffff; + --bg-secondary: #f8fafc; /* Lighter secondary */ + --bg-tertiary: #f1f5f9; /* Even lighter tertiary */ --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); + --text-primary: #1e293b; /* Darker primary text */ + --text-secondary: #475569; /* Darker secondary text */ + --text-tertiary: #64748b; /* Darker tertiary text */ + --accent-primary: #0891b2; /* Slightly darker cyan */ + --accent-secondary: #2563eb; /* Slightly darker blue */ + --accent-tertiary: #7c3aed; /* Slightly darker violet */ + --glow-primary: rgba(8, 145, 178, 0.15); + --glow-secondary: rgba(37, 99, 235, 0.15); + --glow-tertiary: rgba(124, 58, 237, 0.15); + --border-primary: rgba(0, 0, 0, 0.1); /* Darker borders */ --border-secondary: rgba(0, 0, 0, 0.05); - - /* Card 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; + --card-bg: rgba(255, 255, 255, 0.8); /* White card with opacity */ + --card-border: rgba(37, 99, 235, 0.3); /* Blue border */ + --ui-element: #e2e8f0; /* Lighter UI elements */ --ui-element-hover: #cbd5e1; + --bg-primary-rgb: 255, 255, 255; /* RGB for gradients */ + --bg-secondary-rgb: 248, 250, 252; /* RGB for gradients */ } -/* 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%); +/* Ensure transitions for smooth theme changes */ +*, *::before, *::after { + transition: background-color 0.3s ease, color 0.3s ease, border-color 0.3s ease, box-shadow 0.3s ease; } -/* 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); +/* Knowledge Graph specific theme adjustments */ +:root.light-mode .graph-container { + background: rgba(248, 250, 252, 0.3); + border: 1px solid var(--card-border); } -/* 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; +:root.light-mode .node-details { + background: rgba(255, 255, 255, 0.8); + box-shadow: 0 10px 25px rgba(0, 0, 0, 0.1); } -/* Knowledge Graph light mode adjustments */ -html.light-mode .graph-container { - background: rgba(248, 250, 252, 0.6); +:root.light-mode .graph-filters { + background: rgba(248, 250, 252, 0.7); } -html.light-mode .graph-loading { - background: rgba(241, 245, 249, 0.7); +:root.light-mode .graph-filter { + color: var(--text-secondary); + border-color: var(--border-primary); } -html.light-mode .graph-filters { - background: rgba(241, 245, 249, 0.7); +:root.light-mode .connections-list a { + color: var(--accent-secondary); } -html.light-mode .graph-legend { - background: rgba(241, 245, 249, 0.7); +:root.light-mode .node-link { + box-shadow: 0 4px 10px rgba(8, 145, 178, 0.15); } -html.light-mode .node-details { - background: rgba(248, 250, 252, 0.9); +/* Fix for code blocks in light mode */ +: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); } \ No newline at end of file