Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 6 additions & 9 deletions apps/blog/src/app/(blog)/[slug]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,14 +7,15 @@ import { blog } from "@/lib/source";
import { Badge, InlineTOC, Separator } from "@prisma/eclipse";

import { JsonLd } from "@prisma-docs/ui/components/json-ld";
import { FooterNewsletterForm } from "@prisma-docs/ui/components/newsletter";
import { BlogShare } from "@/components/BlogShare";
import { BlogCTA } from "@/components/BlogCTA";
import { AuthorAvatarGroup } from "@/components/AuthorAvatarGroup";
import { SeriesBanner } from "@/components/SeriesBanner";
import { SeriesMarker } from "@/components/SeriesMarker";
import { SeriesNavigation } from "@/components/SeriesNavigation";
import { KeepReading } from "@/components/KeepReading";
import { getSeriesContext } from "@/lib/series";
import { getRelatedPosts } from "@/lib/related-posts";
import { getBaseUrl, withBlogBasePath, withBlogBasePathForImageSrc } from "@/lib/url";
import Link from "next/link";
import type { Metadata } from "next";
Expand Down Expand Up @@ -163,8 +164,8 @@ export default async function Page(props: { params: Promise<{ slug: string }> })
const MDX = page.data.body;
const blogPostingJsonLd = getBlogPostingJsonLd(page);
const seriesContext = getSeriesContext(page);
const relatedPosts = seriesContext ? [] : getRelatedPosts(page, 2);

const newsletterApiUrl = withBlogBasePath("/api/newsletter");
return (
<div className="w-full px-4 z-1 mx-auto md:grid md:grid-cols-[1fr_180px] mt-4 md:mt-22 gap-12 max-w-257">
{blogPostingJsonLd ? (
Expand Down Expand Up @@ -257,19 +258,15 @@ export default async function Page(props: { params: Promise<{ slug: string }> })
<SeriesBanner series={seriesContext} />
<SeriesNavigation series={seriesContext} />
</>
) : null}
<Separator className="my-12" />
) : (
<KeepReading posts={relatedPosts} />
)}

{/* Conversion CTA */}
<BlogCTA />

{/* Share Container */}
<BlogShare desc={page.data.metaDescription as string} />

{/* Newsletter CTA */}
<div className="w-full px-8 py-12 shadow-box-low newsletter-bg rounded-square border border-background-neutral flex max-sm:flex-col wrap items-start gap-4 sm:items-center justify-between my-12">
<FooterNewsletterForm apiUrl={newsletterApiUrl} />
</div>
</div>
<div className="max-md:hidden toc">
<div className="sticky top-24 max-h-[calc(100vh-6rem)] overflow-y-auto [&_a[data-state=inactive]]:text-foreground-neutral-weak! [&_a[data-state=active]]:text-foreground-neutral!">
Expand Down
9 changes: 8 additions & 1 deletion apps/blog/src/app/(blog)/layout.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
import { Footer } from "@prisma-docs/ui/components/footer";
import { FooterNewsletterForm } from "@prisma-docs/ui/components/newsletter";
import { ThemeProvider } from "@prisma-docs/ui/components/theme-provider";
import { NavigationWrapper } from "@/components/navigation-wrapper";
import { UtmPersistence } from "@/components/utm-persistence";
import { withBlogBasePath } from "@/lib/url";
export function baseOptions() {
return {
nav: {
Expand Down Expand Up @@ -107,7 +109,12 @@ export default function Layout({ children }: { children: React.ReactNode }) {
<UtmPersistence />
<NavigationWrapper links={baseOptions().links} utm={{ source: "website", medium: "blog" }} />
{children}
<Footer basePath="/blog" />
<Footer
basePath="/blog"
newsletterComponent={
<FooterNewsletterForm stacked apiUrl={withBlogBasePath("/api/newsletter")} />
}
/>
</ThemeProvider>
);
}
17 changes: 17 additions & 0 deletions apps/blog/src/components/KeepReading.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import { PostCard } from "./PostCard";
import type { BlogCardItem } from "./BlogGrid";

export function KeepReading({ posts }: { posts: BlogCardItem[] }) {
if (posts.length === 0) return null;

return (
<section aria-label="Keep reading" className="my-12">
<h2 className="type-title-2xl text-center mb-8 text-foreground-neutral">Keep reading</h2>
<div className="grid grid-cols-1 gap-6 sm:grid-cols-2">
{posts.map((post) => (
<PostCard key={post.url} post={post} currentCategory="show-all" vertical />
))}
</div>
</section>
);
}
22 changes: 8 additions & 14 deletions apps/blog/src/components/PostCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -26,25 +26,19 @@ export function PostCard({
post,
currentCategory,
featured = false,
vertical = false,
}: {
post: PostCardItem;
currentCategory: string;
featured?: boolean;
vertical?: boolean;
}) {
const sourceAuthorNames =
post.authors && post.authors.length > 0
? post.authors
: post.author
? [post.author]
: [];
const authorProfiles: AuthorProfile[] = getAuthorProfiles(sourceAuthorNames).map(
(profile) => ({
name: profile.name,
imageSrc: profile.imageSrc
? withBlogBasePathForImageSrc(profile.imageSrc)
: null,
}),
);
post.authors && post.authors.length > 0 ? post.authors : post.author ? [post.author] : [];
const authorProfiles: AuthorProfile[] = getAuthorProfiles(sourceAuthorNames).map((profile) => ({
name: profile.name,
imageSrc: profile.imageSrc ? withBlogBasePathForImageSrc(profile.imageSrc) : null,
}));
const author: AuthorProfile | null = authorProfiles[0] ?? null;

const badge =
Expand All @@ -64,5 +58,5 @@ export function PostCard({
badge,
};

return <SharedPostCard post={sharedPost} featured={featured} />;
return <SharedPostCard post={sharedPost} featured={featured} vertical={vertical} />;
}
32 changes: 11 additions & 21 deletions apps/blog/src/components/SeriesNavigation.tsx
Original file line number Diff line number Diff line change
@@ -1,38 +1,28 @@
import Link from "next/link";
import { PostCard } from "./PostCard";
import type { SeriesContext } from "@/lib/series";

export function SeriesNavigation({ series }: { series: SeriesContext }) {
if (!series.prev && !series.next) return null;

return (
<nav aria-label="Series navigation" className="grid gap-4 sm:grid-cols-2 my-12">
<nav aria-label="Series navigation" className="grid gap-6 sm:grid-cols-2 my-12">
{series.prev ? (
<Link
href={series.prev.url}
className="group block rounded-square border border-stroke-neutral-strong p-5 transition-colors hover:border-stroke-ppg hover:bg-background-ppg/5"
>
<div className="text-xs uppercase tracking-wide text-foreground-neutral-weak mb-1">
<div>
<div className="text-xs uppercase tracking-wide text-foreground-neutral-weak mb-2">
← Previous in series
</div>
<div className="font-semibold text-foreground-neutral group-hover:text-foreground-ppg">
{series.prev.title}
</div>
</Link>
<PostCard post={series.prev} currentCategory="show-all" vertical />
</div>
) : (
<div />
<div className="hidden sm:block" />
)}
{series.next ? (
<Link
href={series.next.url}
className="group block rounded-square border border-stroke-neutral-strong p-5 transition-colors hover:border-stroke-ppg hover:bg-background-ppg/5 sm:text-right"
>
<div className="text-xs uppercase tracking-wide text-foreground-neutral-weak mb-1">
<div>
<div className="text-xs uppercase tracking-wide text-foreground-neutral-weak mb-2 sm:text-right">
Next in series →
</div>
<div className="font-semibold text-foreground-neutral group-hover:text-foreground-ppg">
{series.next.title}
</div>
</Link>
<PostCard post={series.next} currentCategory="show-all" vertical />
</div>
) : null}
</nav>
);
Expand Down
46 changes: 46 additions & 0 deletions apps/blog/src/lib/post-card-item.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
import { blog } from "./source";
import { withBlogBasePath, withBlogBasePathForImageSrc } from "./url";
import type { BlogCardItem } from "@/components/BlogGrid";

export type BlogPage = ReturnType<typeof blog.getPages>[number];

/** Epoch millis for a post's `date`, or 0 when missing/invalid. */
export function getPostTime(page: BlogPage): number {
const date = (page.data as { date?: Date | string }).date;
const time = date instanceof Date ? date.getTime() : new Date(date ?? 0).getTime();
return Number.isNaN(time) ? 0 : time;
}

/**
* Maps a blog page to the `BlogCardItem` shape consumed by `PostCard`.
*
* Shared by the home feed, "Keep reading", and series navigation so card data
* is built one consistent way. `url`/`imageSrc` are base-path prefixed here
* (the prefixing helpers are idempotent, so a second pass in `PostCard` is safe).
*/
export function toBlogCardItem(page: BlogPage): BlogCardItem {
const data = page.data as {
title?: string;
metaDescription?: string;
authors?: unknown;
heroImagePath?: string;
heroImageAlt?: string;
tags?: string[];
};
const authors = Array.isArray(data.authors)
? data.authors.filter((name): name is string => typeof name === "string")
: [];
const time = getPostTime(page);

return {
url: withBlogBasePath(page.url),
title: data.title ?? "",
date: time ? new Date(time).toISOString() : "",
excerpt: data.metaDescription ?? null,
author: authors[0] ?? null,
authors,
imageSrc: withBlogBasePathForImageSrc(data.heroImagePath ?? ""),
imageAlt: data.heroImageAlt ?? data.title ?? "",
tags: data.tags,
};
}
37 changes: 37 additions & 0 deletions apps/blog/src/lib/related-posts.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import { blog } from "./source";
import { getPostTime, toBlogCardItem, type BlogPage } from "./post-card-item";
import type { BlogCardItem } from "@/components/BlogGrid";

function getSlug(page: BlogPage): string {
return page.slugs[0] ?? "";
}

function getTags(page: BlogPage): string[] {
const tags = (page.data as { tags?: unknown }).tags;
return Array.isArray(tags) ? tags.filter((t): t is string => typeof t === "string") : [];
}

/**
* Returns up to `limit` posts to recommend after the given post.
*
* Ranking: most shared tags with the current post first, then most recent.
* The current post is excluded; any other post is eligible (including posts
* that belong to other series). Returns an empty array when no other posts
* exist.
*/
export function getRelatedPosts(current: BlogPage, limit = 2): BlogCardItem[] {
const currentSlug = getSlug(current);
const currentTags = new Set(getTags(current));

return blog
.getPages()
.filter((page) => getSlug(page) !== currentSlug)
.map((page) => ({
page,
sharedTags: getTags(page).filter((tag) => currentTags.has(tag)).length,
time: getPostTime(page),
}))
.sort((a, b) => b.sharedTags - a.sharedTags || b.time - a.time)
.slice(0, limit)
.map((candidate) => toBlogCardItem(candidate.page));
}
10 changes: 6 additions & 4 deletions apps/blog/src/lib/series.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
import { blog } from "./source";
import { getSeriesMetadata } from "./series-registry";
import { toBlogCardItem } from "./post-card-item";
import type { BlogCardItem } from "@/components/BlogGrid";

type BlogPage = ReturnType<typeof blog.getPages>[number];

Expand All @@ -17,8 +19,8 @@ export type SeriesContext = {
posts: SeriesPostRef[];
index: number;
total: number;
prev: SeriesPostRef | null;
next: SeriesPostRef | null;
prev: BlogCardItem | null;
next: BlogCardItem | null;
};

function getSlug(page: BlogPage): string {
Expand Down Expand Up @@ -108,7 +110,7 @@ export function getSeriesContext(page: BlogPage): SeriesContext | null {
posts: posts.map(toRef),
index: currentIndex >= 0 ? currentIndex + 1 : 0,
total: posts.length,
prev: prevPage ? toRef(prevPage) : null,
next: nextPage ? toRef(nextPage) : null,
prev: prevPage ? toBlogCardItem(prevPage) : null,
next: nextPage ? toBlogCardItem(nextPage) : null,
};
}
8 changes: 7 additions & 1 deletion apps/site/src/components/navigation-wrapper.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

import { WebNavigation } from "@prisma-docs/ui/components/web-navigation";
import { Footer } from "@prisma-docs/ui/components/footer";
import { FooterNewsletterForm } from "@prisma-docs/ui/components/newsletter";
import { useEffect, useState } from "react";
import { usePathname } from "next/navigation";
import { getUtmParams, hasUtmParams, type UtmParams } from "@prisma-docs/ui/lib/utm";
Expand Down Expand Up @@ -98,5 +99,10 @@ export function FooterWrapper() {
return "ppg"; // default variant
};

return <Footer color={getButtonVariant()} />;
return (
<Footer
color={getButtonVariant()}
newsletterComponent={<FooterNewsletterForm stacked apiUrl="/api/newsletter" />}
/>
);
}
Loading
Loading