diff --git a/app/blogs/[slug]/page.tsx b/app/blogs/[slug]/page.tsx index d5c79633..3759a6cf 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 */} @@ -785,7 +779,7 @@ export default async function BlogPostPage({ params }: BlogPostPageProps) { {/* Related Posts */} {relatedPosts.length > 0 && ( -
+
diff --git a/components/blog/BlogAuthorBio.tsx b/components/blog/BlogAuthorBio.tsx index 577766bc..06de1b03 100644 --- a/components/blog/BlogAuthorBio.tsx +++ b/components/blog/BlogAuthorBio.tsx @@ -1,28 +1,53 @@ -"use client"; - -import { m } from "framer-motion"; +import Image from "next/image"; import { Author } from "@/lib/blog-types"; 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 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} +
+ ); + } + + 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 */}
@@ -80,6 +105,6 @@ export function BlogAuthorBio({ author }: BlogAuthorBioProps) {
- + ); } 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..112fd445 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"; @@ -18,21 +15,15 @@ export function BlogRelatedPosts({ } return ( -
- +
+

{title}

More insights from the Procedure engineering team

- +
{posts.map((post, idx) => ( 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/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 (
= { 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..1fffa030 100644 --- a/lib/notion-image-cache.ts +++ b/lib/notion-image-cache.ts @@ -2,11 +2,13 @@ import { createHash } from "crypto"; import { existsSync, mkdirSync, writeFileSync } from "fs"; import { dirname, join } from "path"; import { fileURLToPath } from "url"; +import sharp from "sharp"; // Cache directory paths 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 @@ -25,23 +27,43 @@ function ensureCacheDir(dir: string): void { } } +// Browser-supported image formats +const BROWSER_FORMATS = /\.(jpg|jpeg|png|gif|webp|avif)$/i; +// Formats that need conversion to JPEG (HEIC from iPhones, TIFF, BMP, etc.) +const NEEDS_CONVERSION = /\.(heic|heif|tiff|tif|bmp)$/i; + +// Extract extension from URL, converting non-browser formats to jpg +function getOutputExtension(url: string): string { + const urlPath = new URL(url).pathname; + const browserMatch = urlPath.match(BROWSER_FORMATS); + if (browserMatch) return browserMatch[1].toLowerCase(); + // Non-browser format (heic, tiff, etc.) → will be converted to jpg + return "jpg"; +} + +// Check if source URL needs format conversion +function needsConversion(url: string): boolean { + const urlPath = new URL(url).pathname; + return NEEDS_CONVERSION.test(urlPath); +} + // Generate a unique filename from URL 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 const hash = createHash("md5").update(url).digest("hex").substring(0, 8); - - // Extract file extension from URL or default to jpg - const urlPath = new URL(url).pathname; - const ext = urlPath.match(/\.(jpg|jpeg|png|gif|webp|avif)$/i)?.[1] || "jpg"; + const ext = getOutputExtension(url); if (type === "cover") { return `${slug}-cover-${hash}.${ext}`; } + if (type === "author") { + return `${slug}-${hash}.${ext}`; + } return `${slug}-${index ?? 0}-${hash}.${ext}`; } @@ -54,7 +76,7 @@ function isNotionUrl(url: string): boolean { ); } -// Download and cache a single image +// Download and cache a single image, converting non-browser formats to JPEG async function downloadImage(url: string, localPath: string): Promise { try { const response = await fetch(url); @@ -66,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); @@ -200,6 +228,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