From f1f5ac4b3336baca0a32dbf514843d18a4cb6740 Mon Sep 17 00:00:00 2001 From: Daniel LaForce Date: Sun, 27 Apr 2025 00:12:03 -0600 Subject: [PATCH] feat(layout): Add Header and Footer to post pages for consistency --- casestudy.html | 135 ++++++++++ src/components/MiniGraph.astro | 439 ++++++++++++++++++++++++++++++--- src/pages/posts/[slug].astro | 35 ++- 3 files changed, 575 insertions(+), 34 deletions(-) create mode 100644 casestudy.html diff --git a/casestudy.html b/casestudy.html new file mode 100644 index 0000000..2e626d3 --- /dev/null +++ b/casestudy.html @@ -0,0 +1,135 @@ + + + + + + Case Study: SharePoint & M365 Migrations | Daniel LaForce + + + + + + +
+
+
+ + + + +
+
+

Case Study: SharePoint & M365 Migrations

+

Modernizing enterprise collaboration and storage through Microsoft 365

+
+
+ +
+
+
+
+

Client Overview

+

+ 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. +

+ +

Objectives

+
    +
  • Consolidate file and email services under Microsoft 365
  • +
  • Minimize business disruption during migration
  • +
  • Strengthen compliance, backup, and access control
  • +
  • Enable centralized collaboration with Microsoft Teams
  • +
+
+

Solution Strategy

+
    +
  • Provisioned a temporary VM to act as a synchronized landing zone for Egnyte and local file server data
  • +
  • Used Egnyte’s API and command-line tools to batch-export permissions and files to the sync VM
  • +
  • Ran Microsoft’s SharePoint Migration Tool (SPMT) with JSON-based mapping to import content into SharePoint document libraries
  • +
  • Automated pre/post migration checks and permissions auditing via PowerShell and Graph API
  • +
  • Staged migration of mail and calendar data using MigrationWiz with coexistence enabled between Google Workspace and Exchange Online
  • +
  • Provided Teams onboarding with channel templates and cross-platform training
  • +
+
+

Results

+
    +
  • 12.4 TB of content migrated across four platforms in under 90 days
  • +
  • Zero data loss or permission mismatches confirmed via script-based audits
  • +
  • Helpdesk load cut by 40% within the first month post-migration
  • +
  • Fully adopted Microsoft Teams structure with defined department-based channels and integrated file repositories
  • +
  • End-user training boosted post-migration satisfaction to 97%
  • +
+ +
+ SharePoint Online + OneDrive + Teams + Exchange Online + PowerShell + Migration Tools + Egnyte API + SPMT +
+ + +
+
+
+
+ + + + + diff --git a/src/components/MiniGraph.astro b/src/components/MiniGraph.astro index 618d0a4..04abf1e 100644 --- a/src/components/MiniGraph.astro +++ b/src/components/MiniGraph.astro @@ -1,6 +1,6 @@ --- // 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 interface Props { @@ -8,8 +8,10 @@ interface Props { title: string; // Current post title tags?: string[]; // Current post tags 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 + width?: string; // Optional width parameter } // Extract props with defaults @@ -18,8 +20,10 @@ const { title, tags = [], category = "Uncategorized", + content = "", // Add content property for current post relatedPosts = [], - allPosts = [] + allPosts = [], + width = "100%" // Default width of the component } = Astro.props; // Generate unique ID for the graph container @@ -75,6 +79,7 @@ const nodes = [ level: 0, category: category, tags: tags, + content: content, // Add content for the current post (as HTML string) url: `/posts/${slug}/` }, // Level 1: Tag nodes @@ -86,15 +91,37 @@ const nodes = [ url: `/tag/${tag}/` })), // Level 1: Related post nodes - ...relatedPosts.map(post => ({ - id: post.slug, - label: post.data.title, - type: "post", - level: 1, - category: post.data.category || "Uncategorized", - tags: post.data.tags || [], - url: `/posts/${post.slug}/` - })), + ...relatedPosts.map(post => { + // Extract content from post object - this will vary based on your data structure + // Try multiple properties where content might be stored + 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: 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 => ({ id: `tag-${tag}`, @@ -104,15 +131,36 @@ const nodes = [ url: `/tag/${tag}/` })), // Level 2: Posts related to tags (Posts connected to Level 1 tags) - ...level2Posts.map(post => ({ - id: post.slug, - label: post.data.title, - type: "post", - level: 2, - category: post.data.category || "Uncategorized", - tags: post.data.tags || [], - url: `/posts/${post.slug}/` - })), + ...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 => ({ @@ -170,8 +218,8 @@ const predefinedColors = { }; --- - -
+ +

Post Connections

@@ -192,7 +240,7 @@ const predefinedColors = {
- +
Knowledge Graph
@@ -208,7 +256,7 @@ const predefinedColors = {
- +

Node Info

@@ -236,6 +284,23 @@ const predefinedColors = {
+ +
+ Content Preview: +
+
+ + + + + + + +

Select a post to view content preview

+
+
+
+
Connections:
    @@ -249,17 +314,18 @@ const predefinedColors = { @@ -639,6 +911,7 @@ const predefinedColors = { level: node.level, category: node.category, tags: node.tags || [], + content: node.content || '', // Include content for post nodes color: nodeColor, opacity: levelOpacity, size: nodeSize, @@ -1058,7 +1331,7 @@ const predefinedColors = { 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) { if (!infoPanel) return; @@ -1069,6 +1342,8 @@ const predefinedColors = { 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`); @@ -1118,6 +1393,104 @@ const predefinedColors = { } } + // Show or hide content preview based on node type + if (contentPreviewContainerEl && contentPreviewEl) { + // First, clear any previous content + contentPreviewEl.innerHTML = ''; + + if (nodeData.type === 'post') { + // Always show content container for post nodes, even if content is missing + contentPreviewContainerEl.style.display = 'block'; + + if (nodeData.content && nodeData.content.trim() !== '') { + // We have content to display + contentPreviewEl.innerHTML = ` +
    + ${nodeData.content} +
    + `; + } else { + // No content available, show placeholder + contentPreviewEl.innerHTML = ` +
    + + + + + + + +

    No content preview available

    +

    Click the link below to read the full post

    +
    + `; + } + } 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 = ` + + `; + + // 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 = ` +
    +

    No posts connected to tag "${nodeData.label}" in the current view.

    +
    + `; + } + } else { + // Hide for other node types + contentPreviewContainerEl.style.display = 'none'; + } + } + // Set connections - now with interactive links if (connectionsEl) { connectionsEl.innerHTML = ''; diff --git a/src/pages/posts/[slug].astro b/src/pages/posts/[slug].astro index 23c796f..528a663 100644 --- a/src/pages/posts/[slug].astro +++ b/src/pages/posts/[slug].astro @@ -3,6 +3,8 @@ import { getCollection } from 'astro:content'; import BaseLayout from '../../layouts/BaseLayout.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 export async function getStaticPaths() { @@ -131,9 +133,37 @@ const combinedRelatedPosts = [ // Get the Content component for rendering markdown 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 + } + }) +); --- +
    {/* Added Header */}

    {post.data.title}

    @@ -176,7 +206,9 @@ const { Content } = await post.render(); tags={post.data.tags || []} category={post.data.category || "Uncategorized"} 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 */ />
    @@ -243,6 +275,7 @@ const { Content } = await post.render();
    +