From c161c518a92c4c570793ce5460cdf370c89814b3 Mon Sep 17 00:00:00 2001 From: vibemarketerpromax Date: Wed, 25 Feb 2026 16:58:02 +0530 Subject: [PATCH 1/5] feat: Display author photos and bios from Notion on blog posts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add cacheAuthorPhoto() to download and cache author images from Notion (same pattern as blog cover caching — handles temporary S3 URLs) - Extract "Author image" file property from Notion database - Create reusable AuthorAvatar component with next/image and gradient initial fallback when no photo exists - Hero section: show author photo + name (no title/bio) - Bottom section: show full author bio with photo, name, title, and social links via BlogAuthorBio component --- app/blogs/[slug]/page.tsx | 16 ++++--------- components/blog/BlogAuthorBio.tsx | 40 +++++++++++++++++++++++++++---- components/blog/index.ts | 2 +- lib/notion-blog.ts | 18 ++++++++++---- lib/notion-image-cache.ts | 34 +++++++++++++++++++++++++- 5 files changed, 88 insertions(+), 22 deletions(-) diff --git a/app/blogs/[slug]/page.tsx b/app/blogs/[slug]/page.tsx index d5c79633..1cdda132 100644 --- a/app/blogs/[slug]/page.tsx +++ b/app/blogs/[slug]/page.tsx @@ -11,6 +11,7 @@ import type { BlogContent } from "@/lib/notion-blog"; import { formatDate } from "@/lib/blog-utils"; import { getImageMetadata } from "@/lib/image-utils"; import { + AuthorAvatar, BlogAuthorBio, BlogRelatedPosts, BlogShareButtons, @@ -592,17 +593,10 @@ export default async function BlogPostPage({ params }: BlogPostPageProps) {
{/* Author */}
-
- - {post.author.name.charAt(0)} - -
-
-

- {post.author.name} -

-

{post.author.role}

-
+ +

+ {post.author.name} +

{/* Separator */} diff --git a/components/blog/BlogAuthorBio.tsx b/components/blog/BlogAuthorBio.tsx index 577766bc..3c8da8e6 100644 --- a/components/blog/BlogAuthorBio.tsx +++ b/components/blog/BlogAuthorBio.tsx @@ -1,5 +1,6 @@ "use client"; +import Image from "next/image"; import { m } from "framer-motion"; import { Author } from "@/lib/blog-types"; @@ -7,6 +8,39 @@ interface BlogAuthorBioProps { author: Author; } +const DEFAULT_AVATAR = "/team/default.jpg"; + +function AuthorAvatar({ author, size }: { author: Author; size: "sm" | "lg" }) { + const hasPhoto = author.avatar && author.avatar !== DEFAULT_AVATAR; + const px = size === "lg" ? 80 : 40; + const className = size === "lg" + ? "w-20 h-20 rounded-full shrink-0" + : "w-10 h-10 rounded-full shrink-0"; + + if (hasPhoto) { + return ( + {author.name} + ); + } + + const textSize = size === "lg" ? "text-2xl" : "text-sm"; + return ( +
+ + {author.name.charAt(0)} + +
+ ); +} + +export { AuthorAvatar }; + export function BlogAuthorBio({ author }: BlogAuthorBioProps) { return (
{/* Avatar */} -
- - {author.name.charAt(0)} - -
+ {/* Content */}
diff --git a/components/blog/index.ts b/components/blog/index.ts index 6ceb57f5..b55f9231 100644 --- a/components/blog/index.ts +++ b/components/blog/index.ts @@ -8,7 +8,7 @@ export { BlogGrid } from "./BlogGrid"; export { BlogCTA } from "./BlogCTA"; export { BlogPostContent } from "./BlogPostContent"; export { BlogTableOfContents } from "./BlogTableOfContents"; -export { BlogAuthorBio } from "./BlogAuthorBio"; +export { BlogAuthorBio, AuthorAvatar } from "./BlogAuthorBio"; export { BlogRelatedPosts } from "./BlogRelatedPosts"; export { BlogShareButtons } from "./BlogShareButtons"; export type { TOCHeading } from "./BlogTableOfContents"; diff --git a/lib/notion-blog.ts b/lib/notion-blog.ts index 450ad678..8a0ea8ee 100644 --- a/lib/notion-blog.ts +++ b/lib/notion-blog.ts @@ -7,7 +7,7 @@ import type { BlockObjectResponse, ListBlockChildrenResponse, } from "@notionhq/client/build/src/api-endpoints"; -import { cacheBlogCover, cacheContentImages } from "./notion-image-cache"; +import { cacheBlogCover, cacheAuthorPhoto, cacheContentImages } from "./notion-image-cache"; // ============================================================================= // Extended Types for Blog Detail Pages @@ -238,7 +238,8 @@ const AUTHOR_MAP: Record = { function mapAuthor( authorName: string | null, authorBio?: string, - authorTitle?: string + authorTitle?: string, + authorAvatar?: string | null ): Author { if (!authorName) { return AUTHOR_MAP["Procedure Team"]; @@ -250,12 +251,13 @@ function mapAuthor( // Override with Notion values if provided bio: authorBio || existingAuthor.bio, role: authorTitle || existingAuthor.role, + avatar: authorAvatar || existingAuthor.avatar, }; } return { id: authorName.toLowerCase().replace(/\s+/g, "-"), name: authorName, - avatar: "/team/default.jpg", + avatar: authorAvatar || "/team/default.jpg", role: authorTitle || "Engineer", bio: authorBio || "", }; @@ -346,6 +348,12 @@ async function transformNotionPageToBlogPost( const readTime = getNumber(props["Read Time"]) || 5; // Slug property (renamed from URL) - use rich text Slug as primary const customSlug = getRichText(props["Slug"]) || getUrl(props["URL"]); + // Author photo from Notion files property + const authorImageUrl = + getFiles(props["Author image"]) || + getFiles(props["Author Image"]) || + getFiles(props["Author Photo"]); + // Cover image from Notion files property (primary) with fallbacks const featuredImage = getFiles(props["Cover"]) || @@ -369,7 +377,9 @@ async function transformNotionPageToBlogPost( // Map category and author const category = mapCategory(categoryName); - const author = mapAuthor(authorName, authorBio, authorTitle); + const authorId = authorName?.toLowerCase().replace(/\s+/g, "-") || "procedure-team"; + const cachedAuthorPhoto = await cacheAuthorPhoto(authorImageUrl, authorId); + const author = mapAuthor(authorName, authorBio, authorTitle, cachedAuthorPhoto); // Cache cover image to public folder (downloads from Notion and saves locally) const cachedFeaturedImage = await cacheBlogCover(featuredImage, slug); diff --git a/lib/notion-image-cache.ts b/lib/notion-image-cache.ts index 1b5948b8..2ad2c8e9 100644 --- a/lib/notion-image-cache.ts +++ b/lib/notion-image-cache.ts @@ -7,6 +7,7 @@ import { fileURLToPath } from "url"; const CACHE_BASE_DIR = "public/content/cache"; const CASE_STUDIES_CACHE_DIR = `${CACHE_BASE_DIR}/case-studies`; const BLOG_CACHE_DIR = `${CACHE_BASE_DIR}/blog`; +const AUTHORS_CACHE_DIR = `${CACHE_BASE_DIR}/authors`; // Get project root from this file's location (lib/ -> project root) // Using import.meta.url for Turbopack compatibility @@ -29,7 +30,7 @@ function ensureCacheDir(dir: string): void { function generateFilename( url: string, slug: string, - type: "cover" | "content", + type: "cover" | "content" | "author", index?: number ): string { // Create a short hash from the URL to ensure uniqueness @@ -42,6 +43,9 @@ function generateFilename( if (type === "cover") { return `${slug}-cover-${hash}.${ext}`; } + if (type === "author") { + return `${slug}-${hash}.${ext}`; + } return `${slug}-${index ?? 0}-${hash}.${ext}`; } @@ -200,6 +204,34 @@ export async function cacheBlogContentImage( return success ? publicPath : url; } +/** + * Cache a blog author photo + * Returns the public path to use in components + */ +export async function cacheAuthorPhoto( + url: string | null, + authorId: string +): Promise { + if (!url) return null; + + if (!isNotionUrl(url)) { + return url; + } + + ensureCacheDir(AUTHORS_CACHE_DIR); + + const filename = generateFilename(url, authorId, "author"); + const localPath = getAbsolutePath(join(AUTHORS_CACHE_DIR, filename)); + const publicPath = `/content/cache/authors/${filename}`; + + if (existsSync(localPath)) { + return publicPath; + } + + const success = await downloadImage(url, localPath); + return success ? publicPath : null; +} + /** * Process all images in content blocks and cache them * Mutates the content array in place for efficiency From d98259be7df0ecfdfb14b9f845c00d531a595a27 Mon Sep 17 00:00:00 2001 From: vibemarketerpromax Date: Wed, 25 Feb 2026 17:15:19 +0530 Subject: [PATCH 2/5] fix: Remove Framer Motion whileInView from BlogAuthorBio The m.div with whileInView animation started at opacity: 0 but the intersection observer never fired inside the TracingBeam container, making the entire author bio section invisible. Replaced with a plain div. Also removes the unnecessary "use client" directive since the component no longer uses any client-side APIs. --- components/blog/BlogAuthorBio.tsx | 11 ++--------- 1 file changed, 2 insertions(+), 9 deletions(-) diff --git a/components/blog/BlogAuthorBio.tsx b/components/blog/BlogAuthorBio.tsx index 3c8da8e6..adf4d34a 100644 --- a/components/blog/BlogAuthorBio.tsx +++ b/components/blog/BlogAuthorBio.tsx @@ -1,7 +1,4 @@ -"use client"; - import Image from "next/image"; -import { m } from "framer-motion"; import { Author } from "@/lib/blog-types"; interface BlogAuthorBioProps { @@ -43,11 +40,7 @@ export { AuthorAvatar }; export function BlogAuthorBio({ author }: BlogAuthorBioProps) { return ( -
@@ -110,6 +103,6 @@ export function BlogAuthorBio({ author }: BlogAuthorBioProps) {
-
+
); } From 0e5bd399a78b9012dccaf3d56ea378d00f532c7f Mon Sep 17 00:00:00 2001 From: vibemarketerpromax Date: Wed, 25 Feb 2026 17:33:06 +0530 Subject: [PATCH 3/5] fix: Circular avatar, HEIC conversion, TracingBeam empty space - AuthorAvatar: wrap Image in div with overflow-hidden for reliable circular clipping in both hero and bio sections - notion-image-cache: detect HEIC/HEIF/TIFF files and convert to JPEG via sharp during build (fixes Shravani's .heic author photo) - tracing-beam: remove h-full from outer container which caused it to stretch beyond content height in the grid layout, creating blank space between article end and "Continue Reading" section --- components/blog/BlogAuthorBio.tsx | 24 +++++++++++---------- components/ui/tracing-beam.tsx | 2 +- lib/notion-image-cache.ts | 36 +++++++++++++++++++++++++------ 3 files changed, 44 insertions(+), 18 deletions(-) diff --git a/components/blog/BlogAuthorBio.tsx b/components/blog/BlogAuthorBio.tsx index adf4d34a..06de1b03 100644 --- a/components/blog/BlogAuthorBio.tsx +++ b/components/blog/BlogAuthorBio.tsx @@ -10,25 +10,27 @@ const DEFAULT_AVATAR = "/team/default.jpg"; function AuthorAvatar({ author, size }: { author: Author; size: "sm" | "lg" }) { const hasPhoto = author.avatar && author.avatar !== DEFAULT_AVATAR; const px = size === "lg" ? 80 : 40; - const className = size === "lg" - ? "w-20 h-20 rounded-full shrink-0" - : "w-10 h-10 rounded-full shrink-0"; + const wrapperClass = size === "lg" + ? "w-20 h-20 rounded-full shrink-0 overflow-hidden" + : "w-10 h-10 rounded-full shrink-0 overflow-hidden"; if (hasPhoto) { return ( - {author.name} +
+ {author.name} +
); } const textSize = size === "lg" ? "text-2xl" : "text-sm"; return ( -
+
{author.name.charAt(0)} diff --git a/components/ui/tracing-beam.tsx b/components/ui/tracing-beam.tsx index b90ee774..eaa77f8d 100644 --- a/components/ui/tracing-beam.tsx +++ b/components/ui/tracing-beam.tsx @@ -65,7 +65,7 @@ export const TracingBeam = ({ return (
{ try { const response = await fetch(url); @@ -70,7 +88,13 @@ async function downloadImage(url: string, localPath: string): Promise { const arrayBuffer = await response.arrayBuffer(); const buffer = Buffer.from(arrayBuffer); - writeFileSync(localPath, buffer); + if (needsConversion(url)) { + const converted = await sharp(buffer).jpeg({ quality: 85 }).toBuffer(); + writeFileSync(localPath, converted); + console.log(`Converted ${new URL(url).pathname.split("/").pop()} → JPEG`); + } else { + writeFileSync(localPath, buffer); + } return true; } catch (error) { console.warn(`Error downloading image ${url}:`, error); From ceff60fa82c377ad8b87127d59c4746b2f335bc3 Mon Sep 17 00:00:00 2001 From: vibemarketerpromax Date: Wed, 25 Feb 2026 20:44:23 +0530 Subject: [PATCH 4/5] fix: Resolve invisible Continue Reading and CTA sections on blog posts BlogRelatedPosts, BlogCTA, and BlogPostCard used framer-motion's lazy `m` component which requires a `LazyMotion` provider ancestor. The blog post page is a server component with no such provider, so `whileInView` never fired and elements stayed at initial opacity: 0. - BlogRelatedPosts: removed framer-motion animation entirely - BlogCTA: removed framer-motion animation entirely - BlogPostCard: switched from `m` to `motion` (works without LazyMotion) --- components/blog/BlogCTA.tsx | 12 ++---------- components/blog/BlogPostCard.tsx | 6 +++--- components/blog/BlogRelatedPosts.tsx | 13 ++----------- 3 files changed, 7 insertions(+), 24 deletions(-) diff --git a/components/blog/BlogCTA.tsx b/components/blog/BlogCTA.tsx index e89b46d9..39ea189d 100644 --- a/components/blog/BlogCTA.tsx +++ b/components/blog/BlogCTA.tsx @@ -1,7 +1,4 @@ -"use client"; - import Link from "next/link"; -import { m } from "framer-motion"; interface CTAContent { headingLine1: string; @@ -81,12 +78,7 @@ export function BlogCTA({ categorySlug }: BlogCTAProps) { return (
- +

{cta.headingLine1}
@@ -172,7 +164,7 @@ export function BlogCTA({ categorySlug }: BlogCTAProps) { Talk with engineers, not sales

- +
); diff --git a/components/blog/BlogPostCard.tsx b/components/blog/BlogPostCard.tsx index bf33896f..39508293 100644 --- a/components/blog/BlogPostCard.tsx +++ b/components/blog/BlogPostCard.tsx @@ -2,7 +2,7 @@ import Link from "next/link"; import Image from "next/image"; -import { m } from "framer-motion"; +import { motion } from "framer-motion"; import { BlogPost } from "@/lib/blog-types"; import { formatDateShort, @@ -19,7 +19,7 @@ export function BlogPostCard({ post, index = 0 }: BlogPostCardProps) { const categoryColors = getCategoryColor(post.category.color); return ( -
- + ); } diff --git a/components/blog/BlogRelatedPosts.tsx b/components/blog/BlogRelatedPosts.tsx index 46f358fa..30933a51 100644 --- a/components/blog/BlogRelatedPosts.tsx +++ b/components/blog/BlogRelatedPosts.tsx @@ -1,6 +1,3 @@ -"use client"; - -import { m } from "framer-motion"; import { BlogPost } from "@/lib/blog-types"; import { BlogPostCard } from "./BlogPostCard"; @@ -19,20 +16,14 @@ export function BlogRelatedPosts({ return (
- +

{title}

More insights from the Procedure engineering team

- +
{posts.map((post, idx) => ( From 161491766460afbe6d15d1f0d9625f69bc0dc512 Mon Sep 17 00:00:00 2001 From: vibemarketerpromax Date: Wed, 25 Feb 2026 20:58:37 +0530 Subject: [PATCH 5/5] fix: Standardize section spacing on blog post pages Use consistent py-16 sm:py-24 for Related Posts and CTA sections, matching the standard pattern used across all other site sections. Remove redundant internal spacing from BlogRelatedPosts. --- app/blogs/[slug]/page.tsx | 2 +- components/blog/BlogRelatedPosts.tsx | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/app/blogs/[slug]/page.tsx b/app/blogs/[slug]/page.tsx index 1cdda132..3759a6cf 100644 --- a/app/blogs/[slug]/page.tsx +++ b/app/blogs/[slug]/page.tsx @@ -779,7 +779,7 @@ export default async function BlogPostPage({ params }: BlogPostPageProps) { {/* Related Posts */} {relatedPosts.length > 0 && ( -
+
diff --git a/components/blog/BlogRelatedPosts.tsx b/components/blog/BlogRelatedPosts.tsx index 30933a51..112fd445 100644 --- a/components/blog/BlogRelatedPosts.tsx +++ b/components/blog/BlogRelatedPosts.tsx @@ -15,7 +15,7 @@ export function BlogRelatedPosts({ } return ( -
+

{title}