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
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
27 changes: 26 additions & 1 deletion src/app/experiments/[slug]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import { MDXRemote } from 'next-mdx-remote/rsc';
import Image from 'next/image';
import { Section } from '@/components/sections';
import { Button, Badge } from '@/components/ui';
import { ReadingProgressBar } from '@/components/blog';
import { ReadingProgressBar, SeriesNav, SeriesPager } from '@/components/blog';
import { contentService } from '@/lib/services';
import { ROUTES } from '@/lib/constants';
import type { Metadata } from 'next';
Expand Down Expand Up @@ -60,6 +60,16 @@ export default async function ExperimentPage({ params }: BlogPostPageProps) {

const post = postResult.data;

const seriesResult = post.series ? await contentService.getPostsBySeries(post.series) : null;
const seriesPosts = seriesResult?.success ? seriesResult.data : [];

const currentIndex = seriesPosts.findIndex((p) => p.slug === post.slug);
const previousPart = currentIndex > 0 ? seriesPosts[currentIndex - 1] : null;
const nextPart =
currentIndex >= 0 && currentIndex < seriesPosts.length - 1
? seriesPosts[currentIndex + 1]
: null;

return (
<main>
{/* Reading Progress Bar */}
Expand Down Expand Up @@ -125,6 +135,16 @@ export default async function ExperimentPage({ params }: BlogPostPageProps) {
</div>
</header>

{/* Series Navigation */}
{post.series && seriesPosts.length > 1 && (
<SeriesNav
series={post.series}
posts={seriesPosts}
currentSlug={post.slug}
className="mb-8"
/>
)}

{/* Article Content */}
<div className="mt-8">
<MDXRemote
Expand All @@ -140,6 +160,11 @@ export default async function ExperimentPage({ params }: BlogPostPageProps) {
}}
/>
</div>

{/* Series part navigation */}
{post.series && seriesPosts.length > 1 && (
<SeriesPager previous={previousPart} next={nextPart} className="mt-12" />
)}
</article>
</div>
</Section>
Expand Down
79 changes: 79 additions & 0 deletions src/components/blog/SeriesNav/SeriesNav.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
import Link from 'next/link';
import { Layers } from 'lucide-react';
import { cn } from '@/lib/utils';
import type { SeriesNavProps } from './SeriesNav.types';

/**
* SeriesNav Component
* Renders the full table of contents for a multi-part series,
* highlighting the part currently being read.
*/
export function SeriesNav({ series, posts, currentSlug, className }: SeriesNavProps) {
if (posts.length < 2) {
return null;
}

const currentIndex = posts.findIndex((p) => p.slug === currentSlug);

return (
<nav
aria-label={`${series} series`}
className={cn(
'not-prose border-[3px] border-foreground bg-bg-elevated shadow-[6px_6px_0px_0px] shadow-foreground p-5 sm:p-6',
className
)}
>
<div className="mb-4 flex items-center gap-2">
<Layers className="h-4 w-4 text-primary" />
<span className="font-mono text-xs uppercase tracking-wider text-foreground/60">
Series
</span>
</div>

<h2 className="mb-4 font-mono text-lg font-bold uppercase tracking-wide text-foreground">
{series}
</h2>

<ol className="flex flex-col gap-1">
{posts.map((post, index) => {
const isCurrent = post.slug === currentSlug;
const part = post.seriesOrder ?? index + 1;

return (
<li key={post.slug}>
{isCurrent ? (
<div
aria-current="step"
className="flex items-start gap-3 border-l-[3px] border-primary bg-primary/10 px-3 py-2"
>
<span className="font-mono text-sm font-bold text-primary">
{String(part).padStart(2, '0')}
</span>
<span className="text-sm font-bold text-foreground">{post.title}</span>
</div>
) : (
<Link
href={`/experiments/${post.slug}`}
className="group flex items-start gap-3 border-l-[3px] border-transparent px-3 py-2 transition-colors hover:border-foreground hover:bg-foreground/5"
>
<span className="font-mono text-sm text-foreground/50 group-hover:text-foreground">
{String(part).padStart(2, '0')}
</span>
<span className="text-sm text-foreground/70 group-hover:text-foreground">
{post.title}
</span>
</Link>
)}
</li>
);
})}
</ol>

{currentIndex >= 0 && (
<p className="mt-4 font-mono text-xs text-foreground/50">
Part {currentIndex + 1} of {posts.length}
</p>
)}
</nav>
);
}
15 changes: 15 additions & 0 deletions src/components/blog/SeriesNav/SeriesNav.types.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
export interface SeriesNavItem {
slug: string;
title: string;
seriesOrder?: number;
}

export interface SeriesNavProps {
/** Series name (frontmatter `series`) */
series: string;
/** All posts in the series, ordered by `seriesOrder` */
posts: readonly SeriesNavItem[];
/** Slug of the post currently being viewed */
currentSlug: string;
className?: string;
}
2 changes: 2 additions & 0 deletions src/components/blog/SeriesNav/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
export { SeriesNav } from './SeriesNav';
export type { SeriesNavProps, SeriesNavItem } from './SeriesNav.types';
56 changes: 56 additions & 0 deletions src/components/blog/SeriesPager/SeriesPager.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
import Link from 'next/link';
import { ArrowLeft, ArrowRight } from 'lucide-react';
import { ROUTES } from '@/lib/constants';
import { cn } from '@/lib/utils';
import type { SeriesPagerItem, SeriesPagerProps } from './SeriesPager.types';

const partLabel = (item: SeriesPagerItem) =>
item.seriesOrder ? `Part ${item.seriesOrder}` : 'In this series';

/**
* SeriesPager Component
* Previous / Next navigation between parts of a series,
* shown at the end of a series post so the reader can keep going.
*/
export function SeriesPager({ previous, next, className }: SeriesPagerProps) {
if (!previous && !next) {
return null;
}

return (
<nav
aria-label="Series navigation"
className={cn('not-prose grid grid-cols-1 gap-4 sm:grid-cols-2', className)}
>
{/* Previous part */}
{previous ? (
<Link
href={ROUTES.experiments.post(previous.slug)}
className="group flex items-center gap-3 border-[3px] border-foreground bg-bg-elevated p-5 shadow-[4px_4px_0px_0px] shadow-foreground transition-all duration-150 hover:-translate-x-1 hover:shadow-[6px_6px_0px_0px] hover:shadow-primary"
>
<ArrowLeft className="h-5 w-5 shrink-0 text-foreground/50 transition-colors group-hover:text-primary" />
<span className="font-mono text-sm font-bold uppercase tracking-wider text-foreground group-hover:text-primary">
Previous · {partLabel(previous)}
</span>
</Link>
) : (
<div className="hidden sm:block" />
)}

{/* Next part */}
{next ? (
<Link
href={ROUTES.experiments.post(next.slug)}
className="group flex items-center justify-end gap-3 border-[3px] border-foreground bg-bg-elevated p-5 shadow-[4px_4px_0px_0px] shadow-foreground transition-all duration-150 hover:translate-x-1 hover:shadow-[6px_6px_0px_0px] hover:shadow-primary"
>
<span className="font-mono text-sm font-bold uppercase tracking-wider text-foreground group-hover:text-primary">
Next · {partLabel(next)}
</span>
<ArrowRight className="h-5 w-5 shrink-0 text-foreground/50 transition-colors group-hover:text-primary" />
</Link>
) : (
<div className="hidden sm:block" />
)}
</nav>
);
}
13 changes: 13 additions & 0 deletions src/components/blog/SeriesPager/SeriesPager.types.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
export interface SeriesPagerItem {
slug: string;
title: string;
seriesOrder?: number;
}

export interface SeriesPagerProps {
/** Previous part in the series, if any */
previous?: SeriesPagerItem | null;
/** Next part in the series, if any */
next?: SeriesPagerItem | null;
className?: string;
}
2 changes: 2 additions & 0 deletions src/components/blog/SeriesPager/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
export { SeriesPager } from './SeriesPager';
export type { SeriesPagerProps, SeriesPagerItem } from './SeriesPager.types';
2 changes: 2 additions & 0 deletions src/components/blog/index.ts
Original file line number Diff line number Diff line change
@@ -1 +1,3 @@
export { ReadingProgressBar } from './ReadingProgressBar';
export { SeriesNav } from './SeriesNav';
export { SeriesPager } from './SeriesPager';
Loading