feat(nx-dev): add metrics and related blogs section

This commit is contained in:
Juri 2025-02-07 17:33:41 +01:00 committed by Juri Strumpflohner
parent 13b9c23e3b
commit 541acf536b
12 changed files with 289 additions and 81 deletions

View File

@ -75,6 +75,7 @@ export class BlogApi {
podcastAppleUrl: frontmatter.podcastAppleUrl, podcastAppleUrl: frontmatter.podcastAppleUrl,
podcastAmazonUrl: frontmatter.podcastAmazonUrl, podcastAmazonUrl: frontmatter.podcastAmazonUrl,
published: frontmatter.published ?? true, published: frontmatter.published ?? true,
metrics: frontmatter.metrics,
}; };
const isDevelopment = process.env.NODE_ENV === 'development'; const isDevelopment = process.env.NODE_ENV === 'development';
const shouldIncludePost = !frontmatter.draft || isDevelopment; const shouldIncludePost = !frontmatter.draft || isDevelopment;

View File

@ -18,6 +18,9 @@ export type BlogPostDataEntry = {
podcastAppleUrl?: string; podcastAppleUrl?: string;
podcastIHeartUrl?: string; podcastIHeartUrl?: string;
published?: boolean; published?: boolean;
ogImage?: string;
ogImageType?: string;
metrics?: Array<{ value: string; label: string }>;
}; };
export type BlogAuthor = { export type BlogAuthor = {

View File

@ -47,12 +47,14 @@ export default async function BlogPostDetail({
}: BlogPostDetailProps) { }: BlogPostDetailProps) {
const ctaHeaderConfig = [tryNxCloudForFree]; const ctaHeaderConfig = [tryNxCloudForFree];
const blog = await blogApi.getBlogPostBySlug(slug); const blog = await blogApi.getBlogPostBySlug(slug);
const allPosts = await blogApi.getBlogs((p) => !!p.published);
return blog ? ( return blog ? (
<> <>
{/* This empty div is necessary as app router does not automatically scroll on route changes */} {/* This empty div is necessary as app router does not automatically scroll on route changes */}
<div></div> <div></div>
<DefaultLayout headerCTAConfig={ctaHeaderConfig}> <DefaultLayout headerCTAConfig={ctaHeaderConfig}>
<BlogDetails post={blog} /> <BlogDetails post={blog} allPosts={allPosts} />
</DefaultLayout> </DefaultLayout>
</> </>
) : null; ) : null;

View File

@ -5,6 +5,7 @@ import { FeaturedBlogs } from './featured-blogs';
import { useEffect, useMemo, useState } from 'react'; import { useEffect, useMemo, useState } from 'react';
import { Filters } from './filters'; import { Filters } from './filters';
import { useSearchParams } from 'next/navigation'; import { useSearchParams } from 'next/navigation';
import { ALL_TOPICS } from './topics';
import { import {
ComputerDesktopIcon, ComputerDesktopIcon,
BookOpenIcon, BookOpenIcon,
@ -21,57 +22,6 @@ export interface BlogContainerProps {
tags: string[]; tags: string[];
} }
let ALL_TOPICS = [
{
label: 'All',
icon: ListBulletIcon,
value: 'All',
heading: 'All Blogs',
},
{
label: 'Stories',
icon: BookOpenIcon,
value: 'customer story',
heading: 'Customer Stories',
},
{
label: 'Webinars',
icon: ComputerDesktopIcon,
value: 'webinar',
heading: 'Webinars',
},
{
label: 'Podcasts',
icon: MicrophoneIcon,
value: 'podcast',
heading: 'Podcasts',
},
{
label: 'Releases',
icon: CubeIcon,
value: 'release',
heading: 'Release Blogs',
},
{
label: 'Talks',
icon: ChatBubbleOvalLeftEllipsisIcon,
value: 'talk',
heading: 'Talks',
},
{
label: 'Tutorials',
icon: AcademicCapIcon,
value: 'tutorial',
heading: 'Tutorials',
},
{
label: 'Livestreams',
icon: VideoCameraIcon,
value: 'livestream',
heading: 'Livestreams',
},
];
// first five blog posts contain potentially pinned plus the last published ones. They // first five blog posts contain potentially pinned plus the last published ones. They
// should be sorted by date (not just all pinned first) // should be sorted by date (not just all pinned first)
export function sortFirstFivePosts( export function sortFirstFivePosts(
@ -142,7 +92,14 @@ export function BlogContainer({ blogPosts, tags }: BlogContainerProps) {
</div> </div>
</div> </div>
<FeaturedBlogs blogs={firstFiveBlogs} /> <FeaturedBlogs blogs={firstFiveBlogs} />
{!!remainingBlogs.length && <MoreBlogs blogs={remainingBlogs} />} {!!remainingBlogs.length && (
<>
<div className="mx-auto mb-8 mt-20 border-b-2 border-slate-300 pb-3 text-sm dark:border-slate-700">
<h2 className="font-semibold">More blogs</h2>
</div>
<MoreBlogs blogs={remainingBlogs} />
</>
)}
</div> </div>
</main> </main>
); );

View File

@ -2,16 +2,21 @@ import Link from 'next/link';
import { BlogPostDataEntry } from '@nx/nx-dev/data-access-documents/node-only'; import { BlogPostDataEntry } from '@nx/nx-dev/data-access-documents/node-only';
import Image from 'next/image'; import Image from 'next/image';
import { BlogAuthors } from './authors'; import { BlogAuthors } from './authors';
import { ChevronLeftIcon } from '@heroicons/react/24/outline'; import { ChevronLeftIcon, ListBulletIcon } from '@heroicons/react/24/outline';
import { renderMarkdown } from '@nx/nx-dev/ui-markdoc'; import { renderMarkdown } from '@nx/nx-dev/ui-markdoc';
import { EpisodePlayer } from './episode-player'; import { EpisodePlayer } from './episode-player';
import { YouTube } from '@nx/nx-dev/ui-common'; import { YouTube } from '@nx/nx-dev/ui-common';
import { FeaturedBlogs } from './featured-blogs';
import { MoreBlogs } from './more-blogs';
import { ALL_TOPICS, type Topic } from './topics';
import { Metrics } from '@nx/nx-dev/ui-markdoc';
export interface BlogDetailsProps { export interface BlogDetailsProps {
post: BlogPostDataEntry; post: BlogPostDataEntry;
allPosts: BlogPostDataEntry[];
} }
export function BlogDetails({ post }: BlogDetailsProps) { export function BlogDetails({ post, allPosts }: BlogDetailsProps) {
const { node } = renderMarkdown(post.content, { const { node } = renderMarkdown(post.content, {
filePath: post.filePath ?? '', filePath: post.filePath ?? '',
headingClass: 'scroll-mt-20', headingClass: 'scroll-mt-20',
@ -23,30 +28,48 @@ export function BlogDetails({ post }: BlogDetailsProps) {
year: 'numeric', year: 'numeric',
}); });
// Find the primary topic of the current post
const primaryTopic = ALL_TOPICS.find((topic: Topic) =>
post.tags.includes(topic.value.toLowerCase())
);
const relatedPosts = allPosts
.filter(
(p) =>
p.slug !== post.slug && // Exclude current post
p.tags.some((tag) => post.tags.includes(tag)) // Include posts with matching tags
)
.slice(0, 5);
return ( return (
<main id="main" role="main" className="w-full py-8"> <main id="main" role="main" className="w-full py-8">
<div className="mx-auto flex max-w-3xl justify-between px-4 lg:px-0"> <div className="mx-auto max-w-screen-md">
<Link {/* Top navigation and author info */}
href="/blog" <div className="mx-auto flex justify-between px-4">
className="flex w-20 shrink-0 items-center gap-2 text-slate-400 hover:text-slate-800 dark:text-slate-600 dark:hover:text-slate-200" <Link
prefetch={false} href="/blog"
> className="flex w-20 shrink-0 items-center gap-2 text-slate-400 hover:text-slate-800 dark:text-slate-600 dark:hover:text-slate-200"
<ChevronLeftIcon className="h-3 w-3" /> prefetch={false}
Blog >
</Link> <ChevronLeftIcon className="h-3 w-3" />
<div className="flex max-w-sm flex-1 grow items-center justify-end gap-2"> Blog
<BlogAuthors authors={post.authors} /> </Link>
<span className="text-sm text-slate-400 dark:text-slate-600"> <div className="flex max-w-sm flex-1 grow items-center justify-end gap-2">
{formattedDate} <BlogAuthors authors={post.authors} />
</span> <span className="text-sm text-slate-400 dark:text-slate-600">
{formattedDate}
</span>
</div>
</div> </div>
</div>
<div id="content-wrapper"> {/* Title */}
<header className="mx-auto mb-16 mt-8 max-w-3xl px-4 lg:px-0"> <header className="mx-auto mb-16 mt-8 px-4">
<h1 className="text-center text-4xl font-semibold text-slate-900 dark:text-white"> <h1 className="text-center text-4xl font-semibold text-slate-900 dark:text-white">
{post.title} {post.title}
</h1> </h1>
</header> </header>
{/* Media content (podcast, youtube, or image) */}
{post.podcastYoutubeId && post.podcastSpotifyId ? ( {post.podcastYoutubeId && post.podcastSpotifyId ? (
<div className="mx-auto mb-16 w-full max-w-screen-md"> <div className="mx-auto mb-16 w-full max-w-screen-md">
<EpisodePlayer <EpisodePlayer
@ -74,17 +97,73 @@ export function BlogDetails({ post }: BlogDetailsProps) {
</div> </div>
) )
)} )}
<div className="mx-auto min-w-0 max-w-3xl flex-auto px-4 pb-24 lg:px-0 lg:pb-16"> </div>
<div className="relative">
{/* Main grid layout */}
<div className="mx-auto max-w-7xl px-4 lg:px-8">
<div className="relative isolate grid grid-cols-1 gap-8 xl:grid-cols-[200px_minmax(0,1fr)_200px]">
<div className="hidden min-h-full xl:block">
{post.metrics && (
<div className="sticky top-28 pr-4 pt-8">
<Metrics metrics={post.metrics} variant="vertical" />
</div>
)}
</div>
{/* Middle column - main content */}
<div className="w-full min-w-0 md:mx-auto md:max-w-screen-md">
{post.metrics && (
<div className="mb-8 xl:hidden">
<Metrics metrics={post.metrics} variant="horizontal" />
</div>
)}
<div <div
data-document="main" data-document="main"
className="prose prose-lg prose-slate dark:prose-invert w-full max-w-none 2xl:max-w-4xl" className="prose prose-lg prose-slate dark:prose-invert w-full max-w-none"
> >
{node} {node}
</div> </div>
</div> </div>
{/* Right column - for future sticky content */}
<div className="hidden xl:block">
<div className="sticky top-24">
{/* Right sidebar content can go here */}
</div>
</div>
</div> </div>
</div> </div>
{/* Related Posts Section */}
{post.tags.length > 0 && relatedPosts.length > 0 && (
<section className="mt-24 border-b border-t border-slate-200 bg-slate-50 py-24 sm:py-32 dark:border-slate-800 dark:bg-slate-900">
<div className="mx-auto max-w-7xl px-4 sm:px-6 lg:px-8">
<div className="mx-auto max-w-2xl lg:mx-0 lg:max-w-none">
<h2 className="mb-8 flex items-center gap-3 text-2xl font-semibold text-slate-900 dark:text-white">
{primaryTopic ? (
<>
<primaryTopic.icon className="h-7 w-7" />
More {primaryTopic.label}
</>
) : (
<>
<ListBulletIcon className="h-7 w-7" />
More Articles
</>
)}
</h2>
{/* Show list view on small screens */}
<div className="md:hidden">
<MoreBlogs blogs={relatedPosts} />
</div>
{/* Show grid view on larger screens */}
<div className="hidden md:block">
<FeaturedBlogs blogs={relatedPosts} />
</div>
</div>
</div>
</section>
)}
</main> </main>
); );
} }

View File

@ -9,9 +9,6 @@ export interface MoreBlogsProps {
export function MoreBlogs({ blogs }: MoreBlogsProps) { export function MoreBlogs({ blogs }: MoreBlogsProps) {
return ( return (
<> <>
<div className="mx-auto mb-8 mt-20 border-b-2 border-slate-300 pb-3 text-sm dark:border-slate-700">
<h2 className="font-semibold">More blogs</h2>
</div>
<div className="mx-auto"> <div className="mx-auto">
{blogs?.map((post) => { {blogs?.map((post) => {
const formattedDate = new Date(post.date).toLocaleDateString( const formattedDate = new Date(post.date).toLocaleDateString(

View File

@ -0,0 +1,69 @@
import type { FC, SVGProps } from 'react';
import {
ComputerDesktopIcon,
BookOpenIcon,
MicrophoneIcon,
CubeIcon,
AcademicCapIcon,
ChatBubbleOvalLeftEllipsisIcon,
ListBulletIcon,
VideoCameraIcon,
} from '@heroicons/react/24/outline';
export interface Topic {
label: string;
icon: FC<SVGProps<SVGSVGElement>>;
value: string;
heading: string;
}
export const ALL_TOPICS: Topic[] = [
{
label: 'All',
icon: ListBulletIcon,
value: 'All',
heading: 'All Blogs',
},
{
label: 'Stories',
icon: BookOpenIcon,
value: 'customer story',
heading: 'Customer Stories',
},
{
label: 'Webinars',
icon: ComputerDesktopIcon,
value: 'webinar',
heading: 'Webinars',
},
{
label: 'Podcasts',
icon: MicrophoneIcon,
value: 'podcast',
heading: 'Podcasts',
},
{
label: 'Releases',
icon: CubeIcon,
value: 'release',
heading: 'Release Blogs',
},
{
label: 'Talks',
icon: ChatBubbleOvalLeftEllipsisIcon,
value: 'talk',
heading: 'Talks',
},
{
label: 'Tutorials',
icon: AcademicCapIcon,
value: 'tutorial',
heading: 'Tutorials',
},
{
label: 'Livestreams',
icon: VideoCameraIcon,
value: 'livestream',
heading: 'Livestreams',
},
];

View File

@ -16,7 +16,7 @@ export function DefaultLayout({
headerCTAConfig?: ButtonLinkProps[]; headerCTAConfig?: ButtonLinkProps[];
} & PropsWithChildren): JSX.Element { } & PropsWithChildren): JSX.Element {
return ( return (
<div className="w-full overflow-hidden dark:bg-slate-950"> <div className="w-full dark:bg-slate-950">
{!hideHeader && <Header ctaButtons={headerCTAConfig} />} {!hideHeader && <Header ctaButtons={headerCTAConfig} />}
<div className="relative isolate"> <div className="relative isolate">
<div <div

View File

@ -56,8 +56,8 @@ import { TableOfContents } from './lib/tags/table-of-contents.component';
import { tableOfContents } from './lib/tags/table-of-contents.schema'; import { tableOfContents } from './lib/tags/table-of-contents.schema';
import { Quote } from './lib/tags/quote.component'; import { Quote } from './lib/tags/quote.component';
import { quote } from './lib/tags/quote.schema'; import { quote } from './lib/tags/quote.schema';
// TODO fix this export import { metrics } from './lib/tags/metrics.schema';
export { GithubRepository } from './lib/tags/github-repository.component'; import { Metrics } from './lib/tags/metrics.component';
export const getMarkdocCustomConfig = ( export const getMarkdocCustomConfig = (
documentFilePath: string, documentFilePath: string,
@ -96,6 +96,7 @@ export const getMarkdocCustomConfig = (
tweet, tweet,
youtube, youtube,
'video-link': videoLink, 'video-link': videoLink,
metrics,
// 'svg-animation': svgAnimation, // 'svg-animation': svgAnimation,
}, },
}, },
@ -128,6 +129,7 @@ export const getMarkdocCustomConfig = (
YouTube, YouTube,
VideoLink, VideoLink,
VideoPlayer, VideoPlayer,
Metrics,
// SvgAnimation, // SvgAnimation,
}, },
}); });
@ -177,3 +179,6 @@ export const renderMarkdown: (
treeNode, treeNode,
}; };
}; };
export { GithubRepository } from './lib/tags/github-repository.component';
export { Metrics };

View File

@ -0,0 +1,27 @@
'use client';
/* this is a separate component s.t. it can be client-side only to avoid hydration errors*/
import { ButtonLink } from '@nx/nx-dev/ui-common';
import { sendCustomEvent } from '@nx/nx-dev/feature-analytics';
export function MetricsCTA() {
return (
<div className="not-prose flex flex-col space-y-3">
<h3 className="text-lg font-semibold text-slate-900 dark:text-white">
Ready to get started?
</h3>
<ButtonLink
href="/enterprise/trial"
title="Reach out"
variant="primary"
size="default"
onClick={() =>
sendCustomEvent('request-trial-click', 'metrics-cta', 'blog')
}
>
Reach out
</ButtonLink>
</div>
);
}

View File

@ -0,0 +1,52 @@
'use client';
import { MetricsCTA } from './metrics-cta';
import { Metric } from './metrics.schema';
interface MetricsProps {
metrics: Metric[];
variant?: 'horizontal' | 'vertical';
}
export function Metrics({
metrics,
variant = 'vertical',
}: MetricsProps): JSX.Element {
if (variant === 'horizontal') {
return (
<div className="mx-auto w-full max-w-none">
<div className="grid grid-cols-2 gap-4 md:grid-cols-4">
{metrics.map((metric, index) => (
<div
key={index}
className="flex flex-col items-center space-y-1 text-center"
>
<div className="text-2xl font-bold text-slate-700 dark:text-slate-200">
{metric.value}
</div>
<div className="text-sm leading-snug text-slate-500 dark:text-slate-400">
{metric.label}
</div>
</div>
))}
</div>
</div>
);
}
return (
<div className="space-y-8">
{metrics.map((metric, index) => (
<div key={index} className="flex flex-col space-y-2">
<div className="text-4xl font-bold text-slate-700 dark:text-slate-200">
{metric.value}
</div>
<div className="text-sm leading-snug text-slate-500 dark:text-slate-400">
{metric.label}
</div>
</div>
))}
<MetricsCTA />
</div>
);
}

View File

@ -0,0 +1,16 @@
import { Schema } from '@markdoc/markdoc';
export interface Metric {
value: string;
label: string;
}
export const metrics: Schema = {
render: 'Metrics',
attributes: {
metrics: {
type: 'Array',
required: true,
},
},
};