Compare commits

...

2 Commits

3 changed files with 847 additions and 255 deletions

135
casestudy.html Normal file
View File

@ -0,0 +1,135 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Case Study: SharePoint & M365 Migrations | Daniel LaForce</title>
<meta name="description" content="Enterprise migration case study: Seamless transition from legacy systems to Microsoft 365 with automation, SharePoint, OneDrive, and Teams integration.">
<link rel="stylesheet" href="styles.css">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
<style>
.portfolio-header {
padding: 2rem 0 1rem;
}
.portfolio-section {
padding-top: 1rem;
padding-bottom: 2rem;
}
.project-card {
margin-top: 0;
padding: 1.5rem;
}
</style>
</head>
<body>
<br>
<br>
<br>
<!-- Header/Nav -->
<nav class="navbar">
<div class="container">
<div class="logo">
<a href="index.html">
<span class="logo-text-glow">Daniel LaForce</span>
</a>
</div>
<div class="nav-menu">
<a href="index.html#home" class="nav-link">Home</a>
<a href="index.html#portfolio" class="nav-link active">Portfolio</a>
<a href="index.html#services" class="nav-link">Services</a>
<a href="index.html#lab" class="nav-link">Live Lab</a>
<a href="resume.html" class="nav-link">Resume</a>
</div>
</div>
</nav>
<!-- Case Study Content -->
<section class="portfolio-header">
<div class="container">
<h1 class="portfolio-title">Case Study: SharePoint & M365 Migrations</h1>
<p class="portfolio-subtitle">Modernizing enterprise collaboration and storage through Microsoft 365</p>
</div>
</section>
<section class="portfolio-section">
<div class="container">
<div class="project-card">
<div class="project-content">
<h3 class="project-title">Client Overview</h3>
<p class="project-description">
A mid-sized professional services firm relied on Dropbox, Egnyte, Google Workspace, and on-prem file servers to manage daily operations. The IT environment was fragmented, increasing operational complexity and impeding secure collaboration. They needed a scalable solution to unify infrastructure, reduce support load, and enhance productivity.
</p>
<h3 class="project-title">Objectives</h3>
<ul>
<li>Consolidate file and email services under Microsoft 365</li>
<li>Minimize business disruption during migration</li>
<li>Strengthen compliance, backup, and access control</li>
<li>Enable centralized collaboration with Microsoft Teams</li>
</ul>
<br>
<h3 class="project-title">Solution Strategy</h3>
<ul>
<li>Provisioned a temporary VM to act as a synchronized landing zone for Egnyte and local file server data</li>
<li>Used Egnytes API and command-line tools to batch-export permissions and files to the sync VM</li>
<li>Ran Microsofts SharePoint Migration Tool (SPMT) with JSON-based mapping to import content into SharePoint document libraries</li>
<li>Automated pre/post migration checks and permissions auditing via PowerShell and Graph API</li>
<li>Staged migration of mail and calendar data using MigrationWiz with coexistence enabled between Google Workspace and Exchange Online</li>
<li>Provided Teams onboarding with channel templates and cross-platform training</li>
</ul>
<br>
<h3 class="project-title">Results</h3>
<ul>
<li>12.4 TB of content migrated across four platforms in under 90 days</li>
<li>Zero data loss or permission mismatches confirmed via script-based audits</li>
<li>Helpdesk load cut by 40% within the first month post-migration</li>
<li>Fully adopted Microsoft Teams structure with defined department-based channels and integrated file repositories</li>
<li>End-user training boosted post-migration satisfaction to 97%</li>
</ul>
<div class="project-skills">
<span class="skill-tag">SharePoint Online</span>
<span class="skill-tag">OneDrive</span>
<span class="skill-tag">Teams</span>
<span class="skill-tag">Exchange Online</span>
<span class="skill-tag">PowerShell</span>
<span class="skill-tag">Migration Tools</span>
<span class="skill-tag">Egnyte API</span>
<span class="skill-tag">SPMT</span>
</div>
<div class="project-links">
<a href="/index.html#portfolio" class="project-link"><i class="fas fa-arrow-left"></i> Back to Portfolio</a>
</div>
</div>
</div>
</div>
</section>
<!-- Footer -->
<footer class="footer">
<div class="container">
<div class="footer-content">
<div class="footer-logo">
<span class="logo-text-glow">Daniel LaForce</span>
</div>
<div class="footer-links">
<a href="/index.html#home">Home</a>
<a href="/index.html#portfolio">Portfolio</a>
<a href="/index.html#services">Services</a>
<a href="/index.html#lab">Live Lab</a>
<a href="/resume.html">Resume</a>
</div>
</div>
<div class="footer-bottom">
<p>© All rights reserved. LaForceIT.com</p>
<p class="disclaimer">Custom-built with HTML, CSS, and JavaScript.</p>
</div>
</div>
</footer>
</body>
</html>

View File

@ -1,6 +1,6 @@
--- ---
// MiniGraph.astro - A standalone mini knowledge graph component with fullscreen capability // MiniGraph.astro - A standalone mini knowledge graph component with fullscreen capability
// This component is designed to work independently from the blog structure // This component is designed to work independently from the blog structure and now includes content previews
// Define props interface // Define props interface
interface Props { interface Props {
@ -8,8 +8,10 @@ interface Props {
title: string; // Current post title title: string; // Current post title
tags?: string[]; // Current post tags tags?: string[]; // Current post tags
category?: string; // Current post category category?: string; // Current post category
relatedPosts?: any[]; // Related posts data content?: string; // Current post content HTML
relatedPosts?: any[]; // Related posts data with their content
allPosts?: any[]; // All posts for second level relationships allPosts?: any[]; // All posts for second level relationships
width?: string; // Optional width parameter
} }
// Extract props with defaults // Extract props with defaults
@ -18,8 +20,10 @@ const {
title, title,
tags = [], tags = [],
category = "Uncategorized", category = "Uncategorized",
content = "", // Add content property for current post
relatedPosts = [], relatedPosts = [],
allPosts = [] allPosts = [],
width = "100%" // Default width of the component
} = Astro.props; } = Astro.props;
// Generate unique ID for the graph container // Generate unique ID for the graph container
@ -30,6 +34,9 @@ const relatedPostsTags = relatedPosts
.flatMap(post => post.data.tags || []) .flatMap(post => post.data.tags || [])
.filter(tag => !tags.includes(tag)); // Exclude current post tags to avoid duplicates .filter(tag => !tags.includes(tag)); // Exclude current post tags to avoid duplicates
// Create a set of all Level 1 nodes' tags for filtering Level 2 tags
const level1TagsSet = new Set([...tags, ...relatedPostsTags]);
// Get Level 2 posts: posts related to Level 1 tags (excluding current post and Level 1 posts) // Get Level 2 posts: posts related to Level 1 tags (excluding current post and Level 1 posts)
const level2PostIds = new Set(); const level2PostIds = new Set();
const level2Posts = []; const level2Posts = [];
@ -38,21 +45,22 @@ const level2Posts = [];
tags.forEach(tag => { tags.forEach(tag => {
allPosts.forEach(post => { allPosts.forEach(post => {
// Skip if post is current post or already in related posts // Skip if post is current post or already in related posts
if (post.slug === slug || relatedPosts.some(rp => rp.slug === post.slug)) { if (post.slug === slug || relatedPosts.some(rp => rp.slug === post.slug) || level2PostIds.has(post.slug)) {
return; return;
} }
// If post has the tag and isn't already added // If post has the tag, add it to Level 2
if (post.data.tags?.includes(tag) && !level2PostIds.has(post.slug)) { if (post.data.tags?.includes(tag)) {
level2PostIds.add(post.slug); level2PostIds.add(post.slug);
level2Posts.push(post); level2Posts.push(post);
} }
}); });
}); });
// Get Level 2 tags from Level 2 posts // Only collect Level 2 tags that are directly linked to Level 1 posts
// This fixes the issue where unrelated Level 2 tags were being included
const level2Tags = new Set(); const level2Tags = new Set();
level2Posts.forEach(post => { relatedPosts.forEach(post => {
(post.data.tags || []).forEach(tag => { (post.data.tags || []).forEach(tag => {
// Only add if not already in Level 0 or Level 1 tags // Only add if not already in Level 0 or Level 1 tags
if (!tags.includes(tag) && !relatedPostsTags.includes(tag)) { if (!tags.includes(tag) && !relatedPostsTags.includes(tag)) {
@ -70,46 +78,97 @@ const nodes = [
type: "post", type: "post",
level: 0, level: 0,
category: category, category: category,
tags: tags tags: tags,
content: content, // Add content for the current post (as HTML string)
url: `/posts/${slug}/`
}, },
// Level 1: Tag nodes // Level 1: Tag nodes
...tags.map(tag => ({ ...tags.map(tag => ({
id: `tag-${tag}`, id: `tag-${tag}`,
label: tag, label: tag,
type: "tag", type: "tag",
level: 1 level: 1,
url: `/tag/${tag}/`
})), })),
// Level 1: Related post nodes // Level 1: Related post nodes
...relatedPosts.map(post => ({ ...relatedPosts.map(post => {
id: post.slug, // Extract content from post object - this will vary based on your data structure
label: post.data.title, // Try multiple properties where content might be stored
type: "post", let postContent = '';
level: 1, if (post.data.content) {
category: post.data.category || "Uncategorized", postContent = post.data.content;
tags: post.data.tags || [] } else if (post.data.body) {
})), postContent = post.data.body;
// Level 2: Related tags nodes } else if (post.data.html) {
postContent = post.data.html;
} else if (post.content) {
postContent = post.content;
} else if (post.body) {
postContent = post.body;
} else if (post.html) {
postContent = post.html;
} else if (post.data.excerpt) {
postContent = post.data.excerpt;
}
return {
id: post.slug,
label: post.data.title,
type: "post",
level: 1,
category: post.data.category || "Uncategorized",
tags: post.data.tags || [],
content: postContent, // Add content as HTML string
url: `/posts/${post.slug}/`
};
}),
// Level 2: Related tags nodes (Tags from Level 1 posts)
...relatedPostsTags.map(tag => ({ ...relatedPostsTags.map(tag => ({
id: `tag-${tag}`, id: `tag-${tag}`,
label: tag, label: tag,
type: "tag", type: "tag",
level: 2
})),
// Level 2: Posts related to tags
...level2Posts.map(post => ({
id: post.slug,
label: post.data.title,
type: "post",
level: 2, level: 2,
category: post.data.category || "Uncategorized", url: `/tag/${tag}/`
tags: post.data.tags || []
})), })),
// Level 2: Tags from Level 2 posts // Level 2: Posts related to tags (Posts connected to Level 1 tags)
...level2Posts.map(post => {
// Extract content from level 2 posts
let postContent = '';
if (post.data.content) {
postContent = post.data.content;
} else if (post.data.body) {
postContent = post.data.body;
} else if (post.data.html) {
postContent = post.data.html;
} else if (post.content) {
postContent = post.content;
} else if (post.body) {
postContent = post.body;
} else if (post.html) {
postContent = post.html;
} else if (post.data.excerpt) {
postContent = post.data.excerpt;
}
return {
id: post.slug,
label: post.data.title,
type: "post",
level: 2,
category: post.data.category || "Uncategorized",
tags: post.data.tags || [],
content: postContent, // Add content as HTML string
url: `/posts/${post.slug}/`
};
}),
// Level 2: Tags from Level 1 posts (only tags directly connected to Level 1 posts)
// This was the corrected logic for level2Tags Set
...[...level2Tags].map(tag => ({ ...[...level2Tags].map(tag => ({
id: `tag-${tag}`, id: `tag-${tag}`,
label: tag.toString(), label: tag.toString(),
type: "tag", type: "tag",
level: 2 level: 2,
url: `/tag/${tag.toString()}/`
})) }))
]; ];
@ -132,7 +191,7 @@ const edges = [
(post.data.tags || []).map(tag => ({ (post.data.tags || []).map(tag => ({
source: post.slug, source: post.slug,
target: `tag-${tag}`, target: `tag-${tag}`,
type: "post-tag" type: "post-tag" // Re-using post-tag type for simplicity
})) }))
), ),
// Level 1 to Level 2: Tags to related posts // Level 1 to Level 2: Tags to related posts
@ -140,11 +199,12 @@ const edges = [
tags.filter(tag => post.data.tags?.includes(tag)).map(tag => ({ tags.filter(tag => post.data.tags?.includes(tag)).map(tag => ({
source: `tag-${tag}`, source: `tag-${tag}`,
target: post.slug, target: post.slug,
type: "tag-post" type: "tag-post" // New type for tag -> post connection
})) }))
) )
]; ];
// Prepare graph data object // Prepare graph data object
const graphData = { nodes, edges }; const graphData = { nodes, edges };
@ -158,8 +218,8 @@ const predefinedColors = {
}; };
--- ---
<!-- Super simple HTML structure --> <!-- Enhanced HTML structure with specified width -->
<div class="knowledge-graph-wrapper"> <div class="knowledge-graph-wrapper" style={`width: ${width};`}>
<h4 class="graph-title">Post Connections</h4> <h4 class="graph-title">Post Connections</h4>
<div id={graphId} class="mini-graph-container"></div> <div id={graphId} class="mini-graph-container"></div>
@ -180,7 +240,7 @@ const predefinedColors = {
</button> </button>
</div> </div>
<!-- Fullscreen container (initially hidden) --> <!-- Fullscreen container (initially hidden) with enhanced info panel -->
<div id={`${graphId}-fullscreen`} class="fullscreen-container"> <div id={`${graphId}-fullscreen`} class="fullscreen-container">
<div class="fullscreen-header"> <div class="fullscreen-header">
<div class="fullscreen-title">Knowledge Graph</div> <div class="fullscreen-title">Knowledge Graph</div>
@ -196,7 +256,7 @@ const predefinedColors = {
<!-- Fullscreen graph container --> <!-- Fullscreen graph container -->
<div id={`${graphId}-fullscreen-graph`} class="fullscreen-graph"></div> <div id={`${graphId}-fullscreen-graph`} class="fullscreen-graph"></div>
<!-- Info panel --> <!-- Enhanced Info panel with content preview capability -->
<div id={`${graphId}-info-panel`} class="info-panel"> <div id={`${graphId}-info-panel`} class="info-panel">
<div class="info-panel-header"> <div class="info-panel-header">
<h3 id={`${graphId}-info-title`} class="info-title">Node Info</h3> <h3 id={`${graphId}-info-title`} class="info-title">Node Info</h3>
@ -224,12 +284,29 @@ const predefinedColors = {
<div id={`${graphId}-info-tags`} class="tags-container"></div> <div id={`${graphId}-info-tags`} class="tags-container"></div>
</div> </div>
<!-- Enhanced content preview section -->
<div id={`${graphId}-content-preview-container`} class="content-preview-container">
<span class="info-label">Content Preview:</span>
<div id={`${graphId}-content-preview`} class="content-preview">
<div class="content-placeholder">
<svg xmlns="http://www.w3.org/2000/svg" width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1" stroke-linecap="round" stroke-linejoin="round">
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"></path>
<polyline points="14 2 14 8 20 8"></polyline>
<line x1="16" y1="13" x2="8" y2="13"></line>
<line x1="16" y1="17" x2="8" y2="17"></line>
<polyline points="10 9 9 9 8 9"></polyline>
</svg>
<p>Select a post to view content preview</p>
</div>
</div>
</div>
<div class="info-connections"> <div class="info-connections">
<span class="info-label">Connections:</span> <span class="info-label">Connections:</span>
<ul id={`${graphId}-info-connections`} class="connections-list"></ul> <ul id={`${graphId}-info-connections`} class="connections-list"></ul>
</div> </div>
<a href="#" id={`${graphId}-info-link`} class="info-link" target="_self">View Content</a> <a href="#" id={`${graphId}-info-link`} class="info-link" target="_blank" rel="noopener noreferrer">View Content</a>
</div> </div>
</div> </div>
</div> </div>
@ -237,17 +314,18 @@ const predefinedColors = {
<!-- Minimal CSS --> <!-- Minimal CSS -->
<style> <style>
/* Base styles for mini graph */ /* Base styles for mini graph - 30% larger */
.knowledge-graph-wrapper { .knowledge-graph-wrapper {
width: 100%; width: 100%; /* Width will be set by style attribute */
position: relative; position: relative;
margin-bottom: 1rem; margin-bottom: 1.5rem;
} }
.graph-title { .graph-title {
font-size: 1rem; font-size: 1.1rem;
margin-bottom: 0.5rem; margin-bottom: 0.75rem;
color: var(--text-primary, #e2e8f0); color: var(--text-primary, #e2e8f0);
font-weight: 600;
} }
.mini-graph-container { .mini-graph-container {
@ -257,6 +335,7 @@ const predefinedColors = {
overflow: hidden; overflow: hidden;
border: 1px solid var(--card-border, rgba(56, 189, 248, 0.2)); border: 1px solid var(--card-border, rgba(56, 189, 248, 0.2));
background: rgba(15, 23, 42, 0.2); background: rgba(15, 23, 42, 0.2);
min-height: 250px; /* Ensure minimum height */
} }
/* Fullscreen toggle button */ /* Fullscreen toggle button */
@ -277,9 +356,15 @@ const predefinedColors = {
z-index: 2; z-index: 2;
transition: all 0.2s ease; transition: all 0.2s ease;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.2); box-shadow: 0 2px 8px rgba(0, 0, 0, 0.2);
opacity: 0; /* Hidden by default, shown on hover */
}
.knowledge-graph-wrapper:hover .fullscreen-toggle {
opacity: 0.7; /* Show on hover */
} }
.fullscreen-toggle:hover { .fullscreen-toggle:hover {
opacity: 1;
background: rgba(30, 41, 59, 0.9); background: rgba(30, 41, 59, 0.9);
transform: translateY(-2px); transform: translateY(-2px);
} }
@ -359,7 +444,7 @@ const predefinedColors = {
/* Info panel */ /* Info panel */
.info-panel { .info-panel {
width: 0; width: 0; /* Hidden by default */
height: 100%; height: 100%;
background: var(--bg-secondary, #1e293b); background: var(--bg-secondary, #1e293b);
transition: width 0.3s ease; transition: width 0.3s ease;
@ -369,7 +454,9 @@ const predefinedColors = {
} }
.info-panel.active { .info-panel.active {
width: 350px; width: 45%; /* Percentage-based width for better responsiveness */
min-width: 400px; /* Minimum width to ensure content is readable */
max-width: 500px; /* Maximum width to prevent overflow on large screens */
} }
.info-panel-header { .info-panel-header {
@ -411,7 +498,7 @@ const predefinedColors = {
flex: 1; flex: 1;
} }
.info-type, .info-category, .info-tags, .info-connections { .info-type, .info-category, .info-tags, .info-connections, .content-preview-container {
margin-bottom: 1.25rem; margin-bottom: 1.25rem;
} }
@ -486,6 +573,7 @@ const predefinedColors = {
color: var(--accent-primary, #38bdf8); color: var(--accent-primary, #38bdf8);
text-decoration: none; text-decoration: none;
transition: color 0.2s ease; transition: color 0.2s ease;
cursor: pointer;
} }
.connections-list a:hover { .connections-list a:hover {
@ -493,6 +581,205 @@ const predefinedColors = {
text-decoration: underline; text-decoration: underline;
} }
/* Content Preview Styles - Enhanced */
.content-preview-container {
border-top: 1px solid var(--border-primary, rgba(56, 189, 248, 0.1));
padding-top: 1.25rem;
margin-top: 1.25rem;
}
.content-preview {
max-height: 350px; /* Taller content area */
overflow-y: auto;
background: rgba(15, 23, 42, 0.3);
border-radius: 6px;
border: 1px solid var(--border-primary, rgba(56, 189, 248, 0.1));
padding: 1rem;
font-size: 0.9rem;
line-height: 1.5;
color: var(--text-primary, #e2e8f0);
}
.content-preview::-webkit-scrollbar {
width: 6px;
}
.content-preview::-webkit-scrollbar-track {
background: rgba(15, 23, 42, 0.2);
border-radius: 3px;
}
.content-preview::-webkit-scrollbar-thumb {
background: rgba(56, 189, 248, 0.2);
border-radius: 3px;
}
.content-preview::-webkit-scrollbar-thumb:hover {
background: rgba(56, 189, 248, 0.3);
}
.content-placeholder {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 2rem 1rem;
color: var(--text-tertiary, #64748b);
text-align: center;
}
.content-placeholder svg {
margin-bottom: 1rem;
opacity: 0.5;
}
.content-placeholder p {
margin: 0;
font-size: 0.9rem;
}
.placeholder-note {
margin-top: 0.5rem;
font-size: 0.8rem;
opacity: 0.8;
}
/* Styling for post content preview */
.post-content-preview {
font-family: var(--font-sans, system-ui, -apple-system, BlinkMacSystemFont, sans-serif);
}
.post-content-preview h1,
.post-content-preview h2,
.post-content-preview h3,
.post-content-preview h4,
.post-content-preview h5,
.post-content-preview h6 {
color: var(--text-primary, #e2e8f0);
margin-top: 1rem;
margin-bottom: 0.5rem;
font-weight: 600;
line-height: 1.3;
}
.post-content-preview h1 { font-size: 1.4rem; }
.post-content-preview h2 { font-size: 1.25rem; }
.post-content-preview h3 { font-size: 1.1rem; }
.post-content-preview h4,
.post-content-preview h5,
.post-content-preview h6 { font-size: 1rem; }
.post-content-preview p {
margin-bottom: 1rem;
}
.post-content-preview a {
color: var(--accent-primary, #38bdf8);
text-decoration: none;
border-bottom: 1px dotted var(--accent-primary, #38bdf8);
transition: all 0.2s ease;
}
.post-content-preview a:hover {
color: var(--accent-secondary, #06b6d4);
border-bottom-style: solid;
}
.post-content-preview img {
max-width: 100%;
height: auto;
border-radius: 4px;
margin: 1rem 0;
}
.post-content-preview code {
font-family: var(--font-mono, monospace);
background: rgba(15, 23, 42, 0.5);
padding: 0.2em 0.4em;
border-radius: 3px;
font-size: 0.85em;
}
.post-content-preview pre {
background: rgba(15, 23, 42, 0.5);
padding: 1rem;
border-radius: 6px;
overflow-x: auto;
margin: 1rem 0;
border: 1px solid var(--border-primary, rgba(56, 189, 248, 0.1));
}
.post-content-preview pre code {
background: transparent;
padding: 0;
font-size: 0.85em;
color: var(--text-primary, #e2e8f0);
}
.post-content-preview blockquote {
border-left: 3px solid var(--accent-primary, #38bdf8);
padding-left: 1rem;
margin-left: 0;
color: var(--text-secondary, #94a3b8);
font-style: italic;
}
.post-content-preview ul,
.post-content-preview ol {
padding-left: 1.5rem;
margin-bottom: 1rem;
}
.post-content-preview li {
margin-bottom: 0.25rem;
}
/* Tag Posts Styling */
.tag-posts-title {
margin-top: 0;
margin-bottom: 1rem;
font-size: 1rem;
color: var(--text-primary, #e2e8f0);
}
/* Tag List Styles for tag nodes */
.related-posts-list {
list-style: none;
padding: 0;
margin: 0.5rem 0 0 0;
}
.related-posts-list li {
margin-bottom: 0.75rem;
padding: 0.5rem;
border-radius: 4px;
background: rgba(15, 23, 42, 0.3);
transition: all 0.2s ease;
}
.related-posts-list li:hover {
background: rgba(15, 23, 42, 0.5);
}
.related-posts-list a {
color: var(--accent-primary, #38bdf8);
text-decoration: none;
display: block;
font-size: 0.85rem;
}
.related-posts-list a:hover {
text-decoration: underline;
}
.related-posts-category {
display: inline-block;
font-size: 0.7rem;
padding: 0.15rem 0.5rem;
border-radius: 10px;
margin-top: 0.25rem;
}
.info-link { .info-link {
display: block; display: block;
background: linear-gradient(90deg, var(--accent-primary, #38bdf8), var(--accent-secondary, #06b6d4)); background: linear-gradient(90deg, var(--accent-primary, #38bdf8), var(--accent-secondary, #06b6d4));
@ -526,12 +813,16 @@ const predefinedColors = {
.info-panel { .info-panel {
width: 100%; width: 100%;
height: 0; height: 0; /* Start hidden */
} }
.info-panel.active { .info-panel.active {
width: 100%; width: 100%;
height: 40%; height: 40%; /* Take remaining space */
}
.content-preview {
max-height: 150px; /* Smaller height on mobile */
} }
} }
</style> </style>
@ -584,6 +875,7 @@ const predefinedColors = {
let cy = null; // Mini graph instance let cy = null; // Mini graph instance
let cyFullscreen = null; // Fullscreen graph instance let cyFullscreen = null; // Fullscreen graph instance
let originalStyles = {}; // To restore page layout after exiting fullscreen let originalStyles = {}; // To restore page layout after exiting fullscreen
let selectedNode = null; // Currently selected node for fullscreen interactions
try { try {
// Check if we have any nodes // Check if we have any nodes
@ -619,9 +911,11 @@ const predefinedColors = {
level: node.level, level: node.level,
category: node.category, category: node.category,
tags: node.tags || [], tags: node.tags || [],
content: node.content || '', // Include content for post nodes
color: nodeColor, color: nodeColor,
opacity: levelOpacity, opacity: levelOpacity,
size: nodeSize size: nodeSize,
url: node.url || '#'
} }
}; };
}); });
@ -791,6 +1085,11 @@ const predefinedColors = {
cy.on('tap', 'node', function(evt) { cy.on('tap', 'node', function(evt) {
const node = evt.target; const node = evt.target;
highlightNode(node, cy); highlightNode(node, cy);
// Navigate on click in mini-graph
const url = node.data('url');
if (url && url !== '#') {
window.location.href = url;
}
}); });
// Add fullscreen toggle functionality if the button exists // Add fullscreen toggle functionality if the button exists
@ -840,6 +1139,13 @@ const predefinedColors = {
// Initialize fullscreen graph if not already done // Initialize fullscreen graph if not already done
if (!cyFullscreen && fullscreenGraph) { if (!cyFullscreen && fullscreenGraph) {
initFullscreenGraph(); initFullscreenGraph();
} else if (cyFullscreen) {
// If already initialized, just resize and fit
setTimeout(() => {
cyFullscreen.resize();
cyFullscreen.fit(undefined, 30);
cyFullscreen.center();
}, 50); // Small delay for transition
} }
// Prevent body scroll // Prevent body scroll
@ -869,6 +1175,14 @@ const predefinedColors = {
// Allow body scroll again // Allow body scroll again
document.body.style.overflow = ''; document.body.style.overflow = '';
// Resize mini graph after exit
setTimeout(() => {
if (cy) {
cy.resize();
cy.fit(undefined, 20);
}
}, 50);
} }
// Save original styles of page elements before entering fullscreen // Save original styles of page elements before entering fullscreen
@ -879,8 +1193,8 @@ const predefinedColors = {
}; };
// Check for sidebar, content containers, etc. and save their styles // Check for sidebar, content containers, etc. and save their styles
const sidebar = document.querySelector('.sidebar, aside, nav'); const sidebar = document.querySelector('.post-sidebar'); // Use specific class
const mainContent = document.querySelector('main, .content, article'); const mainContent = document.querySelector('.post-main-column'); // Use specific class
if (sidebar) { if (sidebar) {
originalStyles.sidebar = { originalStyles.sidebar = {
@ -888,7 +1202,10 @@ const predefinedColors = {
display: sidebar.style.display, display: sidebar.style.display,
visibility: sidebar.style.visibility, visibility: sidebar.style.visibility,
position: sidebar.style.position, position: sidebar.style.position,
zIndex: sidebar.style.zIndex zIndex: sidebar.style.zIndex,
width: sidebar.style.width, // Save width too
flex: sidebar.style.flex,
gridColumn: sidebar.style.gridColumn
}; };
} }
@ -909,19 +1226,26 @@ const predefinedColors = {
// Restore sidebar if it exists // Restore sidebar if it exists
if (originalStyles.sidebar && originalStyles.sidebar.element) { if (originalStyles.sidebar && originalStyles.sidebar.element) {
const sidebar = originalStyles.sidebar.element; const sidebar = originalStyles.sidebar.element;
sidebar.style.display = originalStyles.sidebar.display; Object.assign(sidebar.style, originalStyles.sidebar); // Restore all saved styles
sidebar.style.visibility = originalStyles.sidebar.visibility; sidebar.style.removeProperty('position'); // Ensure position is reset if it was static
sidebar.style.position = originalStyles.sidebar.position;
sidebar.style.zIndex = originalStyles.sidebar.zIndex;
} }
// Restore main content if it exists // Restore main content if it exists
if (originalStyles.mainContent && originalStyles.mainContent.element) { if (originalStyles.mainContent && originalStyles.mainContent.element) {
const mainContent = originalStyles.mainContent.element; const mainContent = originalStyles.mainContent.element;
mainContent.style.marginLeft = originalStyles.mainContent.marginLeft; Object.assign(mainContent.style, originalStyles.mainContent);
mainContent.style.width = originalStyles.mainContent.width;
mainContent.style.maxWidth = originalStyles.mainContent.maxWidth;
} }
// Force reflow to ensure styles apply correctly
setTimeout(() => {
const contentArea = document.querySelector('.post-content');
if (contentArea) {
const display = contentArea.style.display;
contentArea.style.display = 'none';
void contentArea.offsetHeight; // Trigger reflow
contentArea.style.display = display;
}
}, 0);
} }
// Initialize the fullscreen graph // Initialize the fullscreen graph
@ -969,6 +1293,7 @@ const predefinedColors = {
// Click on node to show info // Click on node to show info
cyFullscreen.on('tap', 'node', function(evt) { cyFullscreen.on('tap', 'node', function(evt) {
const node = evt.target; const node = evt.target;
selectedNode = node; // Store the selected node
const nodeData = node.data(); const nodeData = node.data();
// Highlight the selected node // Highlight the selected node
@ -983,6 +1308,7 @@ const predefinedColors = {
if (evt.target === cyFullscreen) { if (evt.target === cyFullscreen) {
// Remove highlights // Remove highlights
cyFullscreen.elements().removeClass('highlighted faded'); cyFullscreen.elements().removeClass('highlighted faded');
selectedNode = null;
// Hide info panel // Hide info panel
if (infoPanel) { if (infoPanel) {
@ -1005,115 +1331,213 @@ const predefinedColors = {
cyInstance.elements().difference(node.neighborhood().union(node)).addClass('faded'); cyInstance.elements().difference(node.neighborhood().union(node)).addClass('faded');
} }
// Show node info in the panel // Enhanced showNodeInfo function to properly display content previews
function showNodeInfo(nodeData) { function showNodeInfo(nodeData) {
if (!infoPanel) return; if (!infoPanel) return;
// Get elements inside the panel
const infoTitleEl = document.getElementById(`${graphId}-info-title`);
const infoTypeEl = document.getElementById(`${graphId}-info-type`);
const categoryContainerEl = document.getElementById(`${graphId}-info-category-container`);
const categoryEl = document.getElementById(`${graphId}-info-category`);
const tagsContainerEl = document.getElementById(`${graphId}-info-tags-container`);
const tagsEl = document.getElementById(`${graphId}-info-tags`);
const contentPreviewContainerEl = document.getElementById(`${graphId}-content-preview-container`);
const contentPreviewEl = document.getElementById(`${graphId}-content-preview`);
const connectionsEl = document.getElementById(`${graphId}-info-connections`);
const linkEl = document.getElementById(`${graphId}-info-link`);
// Set node title // Set node title
document.getElementById(`${graphId}-info-title`).textContent = nodeData.label; if (infoTitleEl) infoTitleEl.textContent = nodeData.label;
// Set node type // Set node type
const typeEl = document.getElementById(`${graphId}-info-type`); if (infoTypeEl) {
typeEl.textContent = nodeData.type.charAt(0).toUpperCase() + nodeData.type.slice(1); infoTypeEl.textContent = nodeData.type.charAt(0).toUpperCase() + nodeData.type.slice(1);
typeEl.className = `info-value type-value ${nodeData.type}-type`; infoTypeEl.className = `info-value type-value ${nodeData.type}-type`;
}
// Set category if it's a post // Set category if it's a post
const categoryContainer = document.getElementById(`${graphId}-info-category-container`); if (categoryContainerEl && categoryEl) {
const categoryEl = document.getElementById(`${graphId}-info-category`); if (nodeData.type === 'post' && nodeData.category) {
categoryContainerEl.style.display = 'block';
if (nodeData.type === 'post' && nodeData.category) { categoryEl.textContent = nodeData.category;
categoryContainer.style.display = 'block'; const catColor = predefinedColors[nodeData.category] || '#A0AEC0';
categoryEl.textContent = nodeData.category; categoryEl.style.backgroundColor = `${catColor}33`;
categoryEl.style.color = catColor;
// Use category colors } else {
const catColor = predefinedColors[nodeData.category] || '#A0AEC0'; categoryContainerEl.style.display = 'none';
categoryEl.style.backgroundColor = `${catColor}33`; // Add alpha }
categoryEl.style.color = catColor;
} else {
categoryContainer.style.display = 'none';
} }
// Set tags // Set tags
const tagsContainer = document.getElementById(`${graphId}-info-tags-container`); if (tagsContainerEl && tagsEl) {
const tagsEl = document.getElementById(`${graphId}-info-tags`); if (nodeData.tags && nodeData.tags.length > 0) {
tagsContainerEl.style.display = 'block';
if (nodeData.type === 'post' && nodeData.tags && nodeData.tags.length > 0) { tagsEl.innerHTML = '';
tagsContainer.style.display = 'block'; nodeData.tags.forEach(tag => {
tagsEl.innerHTML = ''; const tagEl = document.createElement('span');
tagEl.className = 'tag';
nodeData.tags.forEach(tag => { tagEl.textContent = tag;
const tagEl = document.createElement('span'); tagEl.addEventListener('click', () => {
tagEl.className = 'tag'; const tagNode = cyFullscreen.getElementById(`tag-${tag}`);
tagEl.textContent = tag; if (tagNode.length > 0) {
tagEl.addEventListener('click', () => { selectedNode = tagNode; // Update selected node
// Try to find and highlight the tag node highlightNode(tagNode, cyFullscreen);
const tagNode = cyFullscreen.getElementById(`tag-${tag}`); showNodeInfo(tagNode.data());
if (tagNode.length > 0) { }
highlightNode(tagNode, cyFullscreen); });
showNodeInfo(tagNode.data()); tagsEl.appendChild(tagEl);
} });
}); } else {
tagsEl.appendChild(tagEl); tagsContainerEl.style.display = 'none';
}); }
} else {
tagsContainer.style.display = 'none';
} }
// Set connections // Show or hide content preview based on node type
const connectionsEl = document.getElementById(`${graphId}-info-connections`); if (contentPreviewContainerEl && contentPreviewEl) {
connectionsEl.innerHTML = ''; // First, clear any previous content
contentPreviewEl.innerHTML = '';
const neighbors = cyFullscreen.getElementById(nodeData.id).neighborhood('node'); if (nodeData.type === 'post') {
if (neighbors.length > 0) { // Always show content container for post nodes, even if content is missing
neighbors.forEach(neighbor => { contentPreviewContainerEl.style.display = 'block';
const neighborData = neighbor.data();
const li = document.createElement('li'); if (nodeData.content && nodeData.content.trim() !== '') {
const a = document.createElement('a'); // We have content to display
a.href = '#'; contentPreviewEl.innerHTML = `
a.textContent = neighborData.label; <div class="post-content-preview">
a.addEventListener('click', (e) => { ${nodeData.content}
e.preventDefault(); </div>
highlightNode(neighbor, cyFullscreen); `;
showNodeInfo(neighborData); } else {
}); // No content available, show placeholder
li.appendChild(a); contentPreviewEl.innerHTML = `
connectionsEl.appendChild(li); <div class="content-placeholder">
}); <svg xmlns="http://www.w3.org/2000/svg" width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1" stroke-linecap="round" stroke-linejoin="round">
} else { <path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"></path>
const li = document.createElement('li'); <polyline points="14 2 14 8 20 8"></polyline>
li.textContent = 'No connections'; <line x1="16" y1="13" x2="8" y2="13"></line>
connectionsEl.appendChild(li); <line x1="16" y1="17" x2="8" y2="17"></line>
<polyline points="10 9 9 9 8 9"></polyline>
</svg>
<p>No content preview available</p>
<p class="placeholder-note">Click the link below to read the full post</p>
</div>
`;
}
} else if (nodeData.type === 'tag') {
// For tag nodes, show related posts instead
contentPreviewContainerEl.style.display = 'block';
// Find posts that are connected to this tag
const tagId = nodeData.id;
const currentNode = cyFullscreen.getElementById(tagId);
const connectedPosts = currentNode.neighborhood('node[type="post"]');
if (connectedPosts.length > 0) {
contentPreviewEl.innerHTML = `
<div class="tag-related-posts">
<h4 class="tag-posts-title">Posts with tag "${nodeData.label}":</h4>
<ul class="related-posts-list">
${Array.from(connectedPosts).map(post => {
const postData = post.data();
const categoryColor = postData.category && predefinedColors[postData.category]
? predefinedColors[postData.category]
: '#A0AEC0';
return `
<li>
<a href="#" class="related-post-link" data-node-id="${postData.id}">
${postData.label}
</a>
${postData.category ? `
<span class="related-posts-category" style="background-color:${categoryColor}33;color:${categoryColor}">
${postData.category}
</span>
` : ''}
</li>
`;
}).join('')}
</ul>
</div>
`;
// Add click handlers for related posts
setTimeout(() => {
const relatedPostLinks = contentPreviewEl.querySelectorAll('.related-post-link');
relatedPostLinks.forEach(link => {
link.addEventListener('click', (e) => {
e.preventDefault();
const nodeId = link.getAttribute('data-node-id');
const postNode = cyFullscreen.getElementById(nodeId);
if (postNode.length > 0) {
selectedNode = postNode;
highlightNode(postNode, cyFullscreen);
showNodeInfo(postNode.data());
}
});
});
}, 10); // Small delay to ensure elements are rendered
} else {
contentPreviewEl.innerHTML = `
<div class="content-placeholder">
<p>No posts connected to tag "${nodeData.label}" in the current view.</p>
</div>
`;
}
} else {
// Hide for other node types
contentPreviewContainerEl.style.display = 'none';
}
} }
// Set link based on node type // Set connections - now with interactive links
const linkEl = document.getElementById(`${graphId}-info-link`); if (connectionsEl) {
if (nodeData.type === 'post') { connectionsEl.innerHTML = '';
linkEl.textContent = 'Read Post'; const currentNode = cyFullscreen.getElementById(nodeData.id); // Get current node from fullscreen instance
linkEl.href = `/posts/${nodeData.id}/`; const neighbors = currentNode.neighborhood('node');
} else if (nodeData.type === 'tag') {
linkEl.textContent = 'View Tag'; if (neighbors.length > 0) {
linkEl.href = `/tag/${nodeData.label}/`; neighbors.forEach(neighbor => {
} else { const neighborData = neighbor.data();
linkEl.textContent = 'View Content'; const li = document.createElement('li');
linkEl.href = '#'; const a = document.createElement('a');
a.href = '#';
a.textContent = neighborData.label;
a.addEventListener('click', (e) => {
e.preventDefault();
selectedNode = neighbor; // Update selected node
highlightNode(neighbor, cyFullscreen);
showNodeInfo(neighborData);
});
li.appendChild(a);
connectionsEl.appendChild(li);
});
} else {
const li = document.createElement('li');
li.textContent = 'No connections';
connectionsEl.appendChild(li);
}
}
// Set link based on node type - dynamic text and URL
if (linkEl) {
if (nodeData.type === 'post') {
linkEl.textContent = 'Read Post';
linkEl.href = nodeData.url || `/posts/${nodeData.id}/`;
} else if (nodeData.type === 'tag') {
linkEl.textContent = 'View Tag';
linkEl.href = nodeData.url || `/tag/${nodeData.label}/`;
} else {
linkEl.textContent = 'View Content';
linkEl.href = nodeData.url || '#';
}
} }
// Show the panel // Show the panel
infoPanel.classList.add('active'); infoPanel.classList.add('active');
} }
// Make nodes clickable in mini graph
cy.on('tap', 'node[type="tag"]', function(evt) {
const node = evt.target;
const tagName = node.data('label');
window.location.href = `/tag/${tagName}`;
});
cy.on('tap', 'node[type="post"][level!=0]', function(evt) {
const node = evt.target;
const postSlug = node.id();
window.location.href = `/posts/${postSlug}/`;
});
} catch (error) { } catch (error) {
console.error('[MiniGraph] Error initializing graph:', error); console.error('[MiniGraph] Error initializing graph:', error);
container.innerHTML = '<div style="padding:10px;color:#a0aec0;text-align:center;">Error loading graph</div>'; container.innerHTML = '<div style="padding:10px;color:#a0aec0;text-align:center;">Error loading graph</div>';

View File

@ -3,6 +3,8 @@
import { getCollection } from 'astro:content'; import { getCollection } from 'astro:content';
import BaseLayout from '../../layouts/BaseLayout.astro'; import BaseLayout from '../../layouts/BaseLayout.astro';
import MiniGraph from '../../components/MiniGraph.astro'; import MiniGraph from '../../components/MiniGraph.astro';
import Header from '../../components/Header.astro'; // Added import
import Footer from '../../components/Footer.astro'; // Added import
// Required getStaticPaths function for dynamic routes // Required getStaticPaths function for dynamic routes
export async function getStaticPaths() { export async function getStaticPaths() {
@ -131,9 +133,37 @@ const combinedRelatedPosts = [
// Get the Content component for rendering markdown // Get the Content component for rendering markdown
const { Content } = await post.render(); const { Content } = await post.render();
// Capture rendered content in a string for MiniGraph
// Note: This is a simplified approach. A more robust method might be needed
// if Content.toString() doesn't reliably produce HTML.
let renderedContent = '';
try {
const contentRender = await post.render();
renderedContent = contentRender.Content ? contentRender.Content.toString() : '';
} catch (e) {
console.error(`Error rendering content for post ${post.slug}:`, e);
}
// Prepare related posts with content (best effort)
const relatedPostsWithContent = await Promise.all(
combinedRelatedPosts.map(async (relatedPost) => {
try {
const contentRender = await relatedPost.render();
return {
...relatedPost,
content: contentRender.Content ? contentRender.Content.toString() : ''
};
} catch (e) {
console.error(`Error rendering content for related post ${relatedPost.slug}:`, e);
return { ...relatedPost, content: '' }; // Fallback
}
})
);
--- ---
<BaseLayout title={post.data.title} description={post.data.description || ''}> <BaseLayout title={post.data.title} description={post.data.description || ''}>
<Header slot="header" /> {/* Added Header */}
<article class="container blog-post"> <article class="container blog-post">
<header class="post-header"> <header class="post-header">
<h1>{post.data.title}</h1> <h1>{post.data.title}</h1>
@ -176,7 +206,9 @@ const { Content } = await post.render();
tags={post.data.tags || []} tags={post.data.tags || []}
category={post.data.category || "Uncategorized"} category={post.data.category || "Uncategorized"}
allPosts={allPosts} allPosts={allPosts}
content={""} {/* Pass empty string for content */} content={renderedContent} /* Pass rendered HTML */
relatedPosts={relatedPostsWithContent} /* Pass related posts with content */
width="130%" /* Pass width prop */
/> />
</div> </div>
@ -243,6 +275,7 @@ const { Content } = await post.render();
</a> </a>
</div> </div>
</article> </article>
<Footer slot="footer" /> {/* Added Footer */}
</BaseLayout> </BaseLayout>
<style> <style>