diff --git a/src/components/Header.astro b/src/components/Header.astro index 35eaa50..65080b0 100644 --- a/src/components/Header.astro +++ b/src/components/Header.astro @@ -1,458 +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 56079d0..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,20 +25,31 @@ 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; -// Define fixed colors for node types +// Generate colors based on node types const nodeTypeColors = { - post: 'var(--accent-secondary)', // Blue for posts - tag: 'var(--accent-primary)' // Cyan for tags + 'post': '#3B82F6', // Blue for posts + 'tag': '#10B981', // Green for tags + 'category': '#8B5CF6' // Purple for categories }; -// Calculate node sizes (Keep this logic) +// Generate predefined colors for categories +const predefinedColors = { + 'Kubernetes': '#326CE5', 'Docker': '#2496ED', 'DevOps': '#FF6F61', + 'Homelab': '#06B6D4', 'Networking': '#9333EA', 'Infrastructure': '#10B981', + 'Automation': '#F59E0B', 'Security': '#EF4444', 'Monitoring': '#6366F1', + 'Storage': '#8B5CF6', 'Obsidian': '#7C3AED', 'Tutorial': '#3B82F6', + 'Uncategorized': '#A0AEC0' +}; + +// 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 => { @@ -46,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
  • +
+
+
+
+
@@ -76,6 +121,9 @@ graphData.nodes.forEach(node => {
+
+ Type +
Category: Category Name @@ -92,12 +140,16 @@ graphData.nodes.forEach(node => {
- Read Article + View Content
- +
- {/* Removed category filter buttons */} +
+ + + +
- +
- - -
- Legend -
-
- - Post -
-
- - Tag -
-
-
- \ 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 `
-
- - -
+ +
+ + +
+