@xexr/blog-platform 1.1.3 → 1.1.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/app/blog/[slug]/page.tsx +11 -4
- package/dist/app/blog/authors/[slug]/page.tsx +1 -1
- package/dist/app/blog/page.test.ts +20 -0
- package/dist/app/blog/page.tsx +14 -13
- package/dist/app/blog/series/[slug]/page.d.ts +28 -0
- package/dist/app/blog/series/[slug]/page.tsx +39 -0
- package/dist/app/blog/series/page.d.ts +24 -0
- package/dist/app/blog/series/page.tsx +28 -0
- package/dist/components/SeriesArchivePages.tsx +212 -0
- package/dist/components/SeriesNavigation.tsx +117 -0
- package/dist/components/TopicArchivePages.test.ts +22 -0
- package/dist/components/TopicArchivePages.tsx +1 -1
- package/dist/{index-Dr8HaKGm.d.mts → index-CKWrb9a4.d.mts} +43 -1
- package/dist/{index-Dr8HaKGm.d.ts → index-CKWrb9a4.d.ts} +43 -1
- package/dist/index.d.mts +85 -5
- package/dist/index.d.ts +85 -5
- package/dist/index.js +12 -12
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +12 -12
- package/dist/index.mjs.map +1 -1
- package/dist/lib/related-articles.d.mts +1 -1
- package/dist/lib/related-articles.d.ts +1 -1
- package/dist/lib/series.ts +55 -0
- package/dist/lib/sitemap.ts +76 -4
- package/dist/lib/storage/drizzle.ts +9 -0
- package/dist/lib/urls.ts +7 -0
- package/dist/{sitemap-BmCh8BpN.d.ts → sitemap-BDT3d496.d.ts} +7 -1
- package/dist/{sitemap-Dbl4CUGI.d.mts → sitemap-D3rS0iAF.d.mts} +7 -1
- package/dist/sitemap.d.mts +2 -2
- package/dist/sitemap.d.ts +2 -2
- package/dist/sitemap.js +5 -5
- package/dist/sitemap.js.map +1 -1
- package/dist/sitemap.mjs +5 -5
- package/dist/sitemap.mjs.map +1 -1
- package/dist/types/contracts.ts +58 -0
- package/dist/types/index.d.mts +1 -1
- package/dist/types/index.d.ts +1 -1
- package/package.json +1 -1
|
@@ -28,6 +28,7 @@ import {
|
|
|
28
28
|
normalizeBlogBasePath,
|
|
29
29
|
} from "../../../lib/urls";
|
|
30
30
|
import { NewsletterSignup } from "../../../components/NewsletterSignup";
|
|
31
|
+
import { SeriesNavigation } from "../../../components/SeriesNavigation";
|
|
31
32
|
import {
|
|
32
33
|
BLOG_PAGE_TITLE_GRADIENT_CLASS,
|
|
33
34
|
type BlogTheme,
|
|
@@ -162,8 +163,8 @@ export default function ArticlePage({
|
|
|
162
163
|
const imageUrl = article.featuredImage;
|
|
163
164
|
const hasFeaturedImageMeta = Boolean(
|
|
164
165
|
article.featuredImageCaption ??
|
|
165
|
-
|
|
166
|
-
|
|
166
|
+
article.featuredImageCreditText ??
|
|
167
|
+
article.featuredImageCreditUrl,
|
|
167
168
|
);
|
|
168
169
|
const resolvedRelatedArticles =
|
|
169
170
|
relatedArticles.length > 0
|
|
@@ -386,7 +387,7 @@ export default function ArticlePage({
|
|
|
386
387
|
|
|
387
388
|
<div
|
|
388
389
|
id="article-body"
|
|
389
|
-
className="prose prose-sm sm:prose-base lg:prose-lg xl:prose-xl prose-gray dark:prose-invert prose-headings:scroll-mt-20 prose-headings:font-light prose-headings:text-balance prose-headings:text-[var(--blog-text-accent)] prose-h1:text-2xl sm:prose-h1:text-3xl lg:prose-h1:text-4xl prose-h2:text-xl sm:prose-h2:text-2xl lg:prose-h2:text-3xl prose-h3:text-lg sm:prose-h3:text-xl lg:prose-h3:text-2xl prose-p:leading-relaxed prose-p:text-pretty prose-a:text-[var(--blog-text-accent)] prose-a:transition-colors hover:prose-a:text-[var(--blog-text-accent-hover)] [&
|
|
390
|
+
className="prose prose-sm sm:prose-base lg:prose-lg xl:prose-xl prose-gray dark:prose-invert prose-headings:scroll-mt-20 prose-headings:font-light prose-headings:text-balance prose-headings:text-[var(--blog-text-accent)] prose-h1:text-2xl sm:prose-h1:text-3xl lg:prose-h1:text-4xl prose-h2:text-xl sm:prose-h2:text-2xl lg:prose-h2:text-3xl prose-h3:text-lg sm:prose-h3:text-xl lg:prose-h3:text-2xl prose-p:leading-relaxed prose-p:text-pretty prose-a:text-[var(--blog-text-accent)] prose-a:transition-colors hover:prose-a:text-[var(--blog-text-accent-hover)] [&_figcaption]:text-muted-foreground [&_figcaption_.blog-image-credit]:text-muted-foreground/80 [&_figcaption>span:not(.blog-image-credit)]:text-muted-foreground/80 max-w-none [&_figcaption]:!mt-2 [&_figcaption]:text-center [&_figcaption]:!text-sm [&_figcaption]:!leading-snug [&_figcaption_.blog-image-credit]:mt-1 [&_figcaption_.blog-image-credit]:block [&_figcaption_.blog-image-credit]:text-xs [&_figcaption_.blog-image-credit]:tracking-wide [&_figcaption>span:not(.blog-image-credit)]:ml-2 [&_figcaption>span:not(.blog-image-credit)]:border-l [&_figcaption>span:not(.blog-image-credit)]:border-[var(--blog-accent-border)] [&_figcaption>span:not(.blog-image-credit)]:pl-2 [&_figcaption>span:not(.blog-image-credit)]:text-xs [&_figcaption>span:not(.blog-image-credit)]:tracking-wide [&_figure>p]:!my-0"
|
|
390
391
|
>
|
|
391
392
|
<MDXRemote
|
|
392
393
|
source={article.content}
|
|
@@ -404,6 +405,12 @@ export default function ArticlePage({
|
|
|
404
405
|
/>
|
|
405
406
|
</div>
|
|
406
407
|
|
|
408
|
+
<SeriesNavigation
|
|
409
|
+
article={article}
|
|
410
|
+
basePath={normalizedBasePath}
|
|
411
|
+
className="mx-auto mt-12 max-w-4xl"
|
|
412
|
+
/>
|
|
413
|
+
|
|
407
414
|
{/* Author Bio */}
|
|
408
415
|
{article.author?.bio && (
|
|
409
416
|
<div id="about-author" className="author-bio mx-auto mt-12 max-w-4xl">
|
|
@@ -823,7 +830,7 @@ function ArticleCardImage({ src, alt }: { src: string; alt: string }) {
|
|
|
823
830
|
alt={alt}
|
|
824
831
|
width={480}
|
|
825
832
|
height={270}
|
|
826
|
-
className="h-full w-full object-
|
|
833
|
+
className="h-full w-full object-cover transition-transform duration-300 group-hover:scale-105"
|
|
827
834
|
/>
|
|
828
835
|
</div>
|
|
829
836
|
);
|
|
@@ -79,6 +79,26 @@ describe("BlogHomePage", () => {
|
|
|
79
79
|
expect(html).toContain("Recurring work and routines");
|
|
80
80
|
});
|
|
81
81
|
|
|
82
|
+
it("renders article preview images as clipped cover thumbnails", async () => {
|
|
83
|
+
const html = await renderToHtml(
|
|
84
|
+
React.createElement(BlogHomePage, {
|
|
85
|
+
articles: [
|
|
86
|
+
{
|
|
87
|
+
...article,
|
|
88
|
+
featuredImage: "https://cdn.example.com/articles/routines.webp",
|
|
89
|
+
featuredImageAlt: "A tidy workspace",
|
|
90
|
+
},
|
|
91
|
+
],
|
|
92
|
+
}),
|
|
93
|
+
);
|
|
94
|
+
|
|
95
|
+
expect(html).toContain("overflow-hidden rounded-2xl");
|
|
96
|
+
expect(html).toContain(
|
|
97
|
+
"h-full w-full object-cover transition-transform duration-300 group-hover:scale-105",
|
|
98
|
+
);
|
|
99
|
+
expect(html).not.toContain("h-full w-full object-contain");
|
|
100
|
+
});
|
|
101
|
+
|
|
82
102
|
it("renders the newsletter form when configured", async () => {
|
|
83
103
|
const html = await renderToHtml(
|
|
84
104
|
React.createElement(BlogHomePage, {
|
package/dist/app/blog/page.tsx
CHANGED
|
@@ -117,6 +117,12 @@ export default function BlogHomePage({
|
|
|
117
117
|
>
|
|
118
118
|
Topics
|
|
119
119
|
</Link>
|
|
120
|
+
<Link
|
|
121
|
+
href={buildBlogPath(normalizedBasePath, "series")}
|
|
122
|
+
className="text-muted-foreground rounded-full border border-transparent px-3 py-1.5 font-medium transition-colors hover:border-[var(--blog-accent-border)] hover:text-[var(--blog-accent)] hover:no-underline"
|
|
123
|
+
>
|
|
124
|
+
Series
|
|
125
|
+
</Link>
|
|
120
126
|
<Link
|
|
121
127
|
href={buildBlogPath(normalizedBasePath, "authors")}
|
|
122
128
|
className="text-muted-foreground rounded-full border border-transparent px-3 py-1.5 font-medium transition-colors hover:border-[var(--blog-accent-border)] hover:text-[var(--blog-accent)] hover:no-underline"
|
|
@@ -240,7 +246,7 @@ function ArticlePreview({
|
|
|
240
246
|
}
|
|
241
247
|
width={400}
|
|
242
248
|
height={250}
|
|
243
|
-
className="h-full w-full object-
|
|
249
|
+
className="h-full w-full object-cover transition-transform duration-300 group-hover:scale-105"
|
|
244
250
|
/>
|
|
245
251
|
</Link>
|
|
246
252
|
)}
|
|
@@ -282,11 +288,7 @@ function ArticlePreview({
|
|
|
282
288
|
{(article.topics ?? []).length > 0 && (
|
|
283
289
|
<div className="flex flex-wrap gap-2">
|
|
284
290
|
{(article.topics ?? []).map((topic) => (
|
|
285
|
-
<TopicChip
|
|
286
|
-
key={topic.id}
|
|
287
|
-
topic={topic}
|
|
288
|
-
basePath={basePath}
|
|
289
|
-
/>
|
|
291
|
+
<TopicChip key={topic.id} topic={topic} basePath={basePath} />
|
|
290
292
|
))}
|
|
291
293
|
</div>
|
|
292
294
|
)}
|
|
@@ -309,7 +311,7 @@ function ArticlePreview({
|
|
|
309
311
|
}
|
|
310
312
|
width={288}
|
|
311
313
|
height={180}
|
|
312
|
-
className="h-full w-full object-
|
|
314
|
+
className="h-full w-full object-cover transition-transform duration-300 group-hover:scale-105"
|
|
313
315
|
/>
|
|
314
316
|
</Link>
|
|
315
317
|
)}
|
|
@@ -334,11 +336,7 @@ function ArticlePreview({
|
|
|
334
336
|
{(article.topics ?? []).length > 0 && (
|
|
335
337
|
<div className="flex flex-wrap gap-2">
|
|
336
338
|
{(article.topics ?? []).map((topic) => (
|
|
337
|
-
<TopicChip
|
|
338
|
-
key={topic.id}
|
|
339
|
-
topic={topic}
|
|
340
|
-
basePath={basePath}
|
|
341
|
-
/>
|
|
339
|
+
<TopicChip key={topic.id} topic={topic} basePath={basePath} />
|
|
342
340
|
))}
|
|
343
341
|
</div>
|
|
344
342
|
)}
|
|
@@ -383,7 +381,10 @@ function TopicChip({
|
|
|
383
381
|
}
|
|
384
382
|
|
|
385
383
|
return (
|
|
386
|
-
<Link
|
|
384
|
+
<Link
|
|
385
|
+
href={buildBlogPath(basePath, `topics/${slug}`)}
|
|
386
|
+
className={className}
|
|
387
|
+
>
|
|
387
388
|
{topic.name}
|
|
388
389
|
</Link>
|
|
389
390
|
);
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import type { ReactElement } from "react";
|
|
2
|
+
import type { PublishedArticle } from "../../../../types/contracts";
|
|
3
|
+
import type { BlogTheme } from "../../../../lib/article-ui";
|
|
4
|
+
|
|
5
|
+
export interface BlogSeriesArchiveRoutePageProps {
|
|
6
|
+
articles: PublishedArticle[];
|
|
7
|
+
seriesSlug: string;
|
|
8
|
+
basePath?: string;
|
|
9
|
+
className?: string;
|
|
10
|
+
theme?: BlogTheme;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export default function BlogSeriesArchiveRoutePage(
|
|
14
|
+
props: BlogSeriesArchiveRoutePageProps,
|
|
15
|
+
): ReactElement;
|
|
16
|
+
|
|
17
|
+
export function generateSeriesArchiveMetadata(options: {
|
|
18
|
+
title: string;
|
|
19
|
+
description?: string;
|
|
20
|
+
slug: string;
|
|
21
|
+
basePath?: string;
|
|
22
|
+
}): {
|
|
23
|
+
title: string;
|
|
24
|
+
description: string;
|
|
25
|
+
alternates: {
|
|
26
|
+
canonical: string;
|
|
27
|
+
};
|
|
28
|
+
};
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
|
|
3
|
+
import { BlogSeriesArchivePage } from "../../../../components/SeriesArchivePages";
|
|
4
|
+
import type { PublishedArticle } from "../../../../types/contracts";
|
|
5
|
+
import type { BlogTheme } from "../../../../lib/article-ui";
|
|
6
|
+
|
|
7
|
+
export interface BlogSeriesArchiveRoutePageProps {
|
|
8
|
+
articles: PublishedArticle[];
|
|
9
|
+
seriesSlug: string;
|
|
10
|
+
basePath?: string;
|
|
11
|
+
className?: string;
|
|
12
|
+
theme?: BlogTheme;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export default function BlogSeriesArchiveRoutePage(
|
|
16
|
+
props: BlogSeriesArchiveRoutePageProps,
|
|
17
|
+
) {
|
|
18
|
+
return <BlogSeriesArchivePage {...props} />;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function generateSeriesArchiveMetadata({
|
|
22
|
+
title,
|
|
23
|
+
description,
|
|
24
|
+
slug,
|
|
25
|
+
basePath = "/blog",
|
|
26
|
+
}: {
|
|
27
|
+
title: string;
|
|
28
|
+
description?: string;
|
|
29
|
+
slug: string;
|
|
30
|
+
basePath?: string;
|
|
31
|
+
}) {
|
|
32
|
+
return {
|
|
33
|
+
title,
|
|
34
|
+
description: description ?? `Read the ${title} article series.`,
|
|
35
|
+
alternates: {
|
|
36
|
+
canonical: `${basePath.replace(/\/$/, "")}/series/${slug}`,
|
|
37
|
+
},
|
|
38
|
+
};
|
|
39
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import type { ReactElement } from "react";
|
|
2
|
+
import type { PublishedArticle } from "../../../types/contracts";
|
|
3
|
+
import type { BlogTheme } from "../../../lib/article-ui";
|
|
4
|
+
|
|
5
|
+
export interface BlogSeriesPageProps {
|
|
6
|
+
articles: PublishedArticle[];
|
|
7
|
+
basePath?: string;
|
|
8
|
+
title?: string;
|
|
9
|
+
description?: string;
|
|
10
|
+
className?: string;
|
|
11
|
+
theme?: BlogTheme;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export default function BlogSeriesPage(
|
|
15
|
+
props: BlogSeriesPageProps,
|
|
16
|
+
): ReactElement;
|
|
17
|
+
|
|
18
|
+
export function generateSeriesIndexMetadata(basePath?: string): {
|
|
19
|
+
title: string;
|
|
20
|
+
description: string;
|
|
21
|
+
alternates: {
|
|
22
|
+
canonical: string;
|
|
23
|
+
};
|
|
24
|
+
};
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
|
|
3
|
+
import { BlogSeriesIndexPage } from "../../../components/SeriesArchivePages";
|
|
4
|
+
import type { PublishedArticle } from "../../../types/contracts";
|
|
5
|
+
import type { BlogTheme } from "../../../lib/article-ui";
|
|
6
|
+
|
|
7
|
+
export interface BlogSeriesPageProps {
|
|
8
|
+
articles: PublishedArticle[];
|
|
9
|
+
basePath?: string;
|
|
10
|
+
title?: string;
|
|
11
|
+
description?: string;
|
|
12
|
+
className?: string;
|
|
13
|
+
theme?: BlogTheme;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export default function BlogSeriesPage(props: BlogSeriesPageProps) {
|
|
17
|
+
return <BlogSeriesIndexPage {...props} />;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function generateSeriesIndexMetadata(basePath = "/blog") {
|
|
21
|
+
return {
|
|
22
|
+
title: "Series",
|
|
23
|
+
description: "Browse related article series.",
|
|
24
|
+
alternates: {
|
|
25
|
+
canonical: `${basePath.replace(/\/$/, "")}/series`,
|
|
26
|
+
},
|
|
27
|
+
};
|
|
28
|
+
}
|
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
import Image from "next/image.js";
|
|
3
|
+
import Link from "next/link.js";
|
|
4
|
+
import { formatDistanceToNow } from "date-fns";
|
|
5
|
+
|
|
6
|
+
import type { PublishedArticle } from "../types/contracts";
|
|
7
|
+
import type { BlogTheme } from "../lib/article-ui";
|
|
8
|
+
import {
|
|
9
|
+
BLOG_PAGE_TITLE_GRADIENT_CLASS,
|
|
10
|
+
getBlogThemeStyle,
|
|
11
|
+
} from "../lib/article-ui";
|
|
12
|
+
import { getAllSeries, getSeriesArchive } from "../lib/series";
|
|
13
|
+
import {
|
|
14
|
+
buildBlogPath,
|
|
15
|
+
buildSeriesPath,
|
|
16
|
+
normalizeBlogBasePath,
|
|
17
|
+
} from "../lib/urls";
|
|
18
|
+
import { cn } from "../lib/utils";
|
|
19
|
+
|
|
20
|
+
interface BlogSeriesIndexPageProps {
|
|
21
|
+
articles: PublishedArticle[];
|
|
22
|
+
basePath?: string;
|
|
23
|
+
title?: string;
|
|
24
|
+
description?: string;
|
|
25
|
+
className?: string;
|
|
26
|
+
theme?: BlogTheme;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
interface BlogSeriesArchivePageProps {
|
|
30
|
+
articles: PublishedArticle[];
|
|
31
|
+
seriesSlug: string;
|
|
32
|
+
basePath?: string;
|
|
33
|
+
className?: string;
|
|
34
|
+
theme?: BlogTheme;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function BlogSeriesIndexPage({
|
|
38
|
+
articles,
|
|
39
|
+
basePath = "/blog",
|
|
40
|
+
title = "Series",
|
|
41
|
+
description = "Browse related article series.",
|
|
42
|
+
className,
|
|
43
|
+
theme,
|
|
44
|
+
}: BlogSeriesIndexPageProps) {
|
|
45
|
+
const normalizedBasePath = normalizeBlogBasePath(basePath) || "/";
|
|
46
|
+
const allSeries = getAllSeries(articles);
|
|
47
|
+
|
|
48
|
+
return (
|
|
49
|
+
<main
|
|
50
|
+
style={getBlogThemeStyle(theme)}
|
|
51
|
+
className={cn(
|
|
52
|
+
"blog-series-page container mx-auto max-w-5xl px-4 py-8 sm:px-6 sm:py-12 lg:px-8 lg:py-16",
|
|
53
|
+
className,
|
|
54
|
+
)}
|
|
55
|
+
>
|
|
56
|
+
<header className="mb-10 space-y-3 text-center">
|
|
57
|
+
<Link
|
|
58
|
+
href={normalizedBasePath}
|
|
59
|
+
className="text-muted-foreground text-sm font-medium transition-colors hover:text-[var(--blog-accent)]"
|
|
60
|
+
>
|
|
61
|
+
Back to all articles
|
|
62
|
+
</Link>
|
|
63
|
+
<h1
|
|
64
|
+
className={cn(
|
|
65
|
+
"text-4xl font-light tracking-tight text-balance sm:text-5xl",
|
|
66
|
+
BLOG_PAGE_TITLE_GRADIENT_CLASS,
|
|
67
|
+
)}
|
|
68
|
+
>
|
|
69
|
+
{title}
|
|
70
|
+
</h1>
|
|
71
|
+
<p className="text-muted-foreground mx-auto max-w-2xl text-lg text-pretty">
|
|
72
|
+
{description}
|
|
73
|
+
</p>
|
|
74
|
+
</header>
|
|
75
|
+
|
|
76
|
+
{allSeries.length === 0 ? (
|
|
77
|
+
<p className="text-muted-foreground rounded-2xl border p-8 text-center">
|
|
78
|
+
No series have published articles yet.
|
|
79
|
+
</p>
|
|
80
|
+
) : (
|
|
81
|
+
<div className="grid gap-4 md:grid-cols-2">
|
|
82
|
+
{allSeries.map((series) => (
|
|
83
|
+
<Link
|
|
84
|
+
key={series.slug}
|
|
85
|
+
href={buildSeriesPath(normalizedBasePath, series.slug)}
|
|
86
|
+
className="group rounded-2xl border border-[var(--blog-accent-border)] bg-[var(--blog-card-bg)] p-5 transition hover:border-[var(--blog-accent)] hover:no-underline"
|
|
87
|
+
>
|
|
88
|
+
<p className="text-muted-foreground mb-2 text-sm font-medium">
|
|
89
|
+
{series.total} {series.total === 1 ? "article" : "articles"}
|
|
90
|
+
</p>
|
|
91
|
+
<h2 className="text-xl font-semibold transition-colors group-hover:text-[var(--blog-accent)]">
|
|
92
|
+
{series.title}
|
|
93
|
+
</h2>
|
|
94
|
+
{series.description && (
|
|
95
|
+
<p className="text-muted-foreground mt-2 line-clamp-3 text-sm leading-relaxed">
|
|
96
|
+
{series.description}
|
|
97
|
+
</p>
|
|
98
|
+
)}
|
|
99
|
+
</Link>
|
|
100
|
+
))}
|
|
101
|
+
</div>
|
|
102
|
+
)}
|
|
103
|
+
</main>
|
|
104
|
+
);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
export function BlogSeriesArchivePage({
|
|
108
|
+
articles,
|
|
109
|
+
seriesSlug,
|
|
110
|
+
basePath = "/blog",
|
|
111
|
+
className,
|
|
112
|
+
theme,
|
|
113
|
+
}: BlogSeriesArchivePageProps) {
|
|
114
|
+
const normalizedBasePath = normalizeBlogBasePath(basePath) || "/";
|
|
115
|
+
const archive = getSeriesArchive(articles, seriesSlug);
|
|
116
|
+
|
|
117
|
+
return (
|
|
118
|
+
<main
|
|
119
|
+
style={getBlogThemeStyle(theme)}
|
|
120
|
+
className={cn(
|
|
121
|
+
"blog-series-archive-page container mx-auto max-w-5xl px-4 py-8 sm:px-6 sm:py-12 lg:px-8 lg:py-16",
|
|
122
|
+
className,
|
|
123
|
+
)}
|
|
124
|
+
>
|
|
125
|
+
<header className="mb-10 space-y-3 text-center">
|
|
126
|
+
<Link
|
|
127
|
+
href={buildBlogPath(normalizedBasePath, "series")}
|
|
128
|
+
className="text-muted-foreground text-sm font-medium transition-colors hover:text-[var(--blog-accent)]"
|
|
129
|
+
>
|
|
130
|
+
Back to all series
|
|
131
|
+
</Link>
|
|
132
|
+
<h1
|
|
133
|
+
className={cn(
|
|
134
|
+
"text-4xl font-light tracking-tight text-balance sm:text-5xl",
|
|
135
|
+
BLOG_PAGE_TITLE_GRADIENT_CLASS,
|
|
136
|
+
)}
|
|
137
|
+
>
|
|
138
|
+
{archive?.series.title ?? "Series not found"}
|
|
139
|
+
</h1>
|
|
140
|
+
{archive?.series.description && (
|
|
141
|
+
<p className="text-muted-foreground mx-auto max-w-2xl text-lg text-pretty">
|
|
142
|
+
{archive.series.description}
|
|
143
|
+
</p>
|
|
144
|
+
)}
|
|
145
|
+
</header>
|
|
146
|
+
|
|
147
|
+
{!archive ? (
|
|
148
|
+
<p className="text-muted-foreground rounded-2xl border p-8 text-center">
|
|
149
|
+
No published articles were found for this series.
|
|
150
|
+
</p>
|
|
151
|
+
) : (
|
|
152
|
+
<div className="grid gap-5 md:grid-cols-2">
|
|
153
|
+
{archive.articles.map((article) => (
|
|
154
|
+
<SeriesArticleCard
|
|
155
|
+
key={article.id}
|
|
156
|
+
article={article}
|
|
157
|
+
basePath={normalizedBasePath}
|
|
158
|
+
/>
|
|
159
|
+
))}
|
|
160
|
+
</div>
|
|
161
|
+
)}
|
|
162
|
+
</main>
|
|
163
|
+
);
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
function SeriesArticleCard({
|
|
167
|
+
article,
|
|
168
|
+
basePath,
|
|
169
|
+
}: {
|
|
170
|
+
article: PublishedArticle;
|
|
171
|
+
basePath: string;
|
|
172
|
+
}) {
|
|
173
|
+
const articleUrl = buildBlogPath(basePath, article.slug);
|
|
174
|
+
const publishedAgo = formatDistanceToNow(new Date(article.publishedAt), {
|
|
175
|
+
addSuffix: true,
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
return (
|
|
179
|
+
<Link
|
|
180
|
+
href={articleUrl}
|
|
181
|
+
className="group overflow-hidden rounded-2xl border border-[var(--blog-accent-border)] bg-[var(--blog-card-bg)] transition hover:border-[var(--blog-accent)] hover:no-underline"
|
|
182
|
+
>
|
|
183
|
+
{article.featuredImage && (
|
|
184
|
+
<div className="relative aspect-[16/9] overflow-hidden">
|
|
185
|
+
<Image
|
|
186
|
+
src={article.featuredImage}
|
|
187
|
+
alt={article.featuredImageAlt ?? article.title}
|
|
188
|
+
width={640}
|
|
189
|
+
height={360}
|
|
190
|
+
className="h-full w-full object-cover transition-transform duration-300 group-hover:scale-105"
|
|
191
|
+
/>
|
|
192
|
+
</div>
|
|
193
|
+
)}
|
|
194
|
+
<div className="space-y-3 p-5">
|
|
195
|
+
{article.series && (
|
|
196
|
+
<span className="inline-flex rounded-full border border-[var(--blog-accent-border)] px-2.5 py-1 text-xs font-medium text-[var(--blog-text-accent)]">
|
|
197
|
+
Part {article.series.position}
|
|
198
|
+
</span>
|
|
199
|
+
)}
|
|
200
|
+
<h2 className="text-xl font-semibold text-balance transition-colors group-hover:text-[var(--blog-accent)]">
|
|
201
|
+
{article.title}
|
|
202
|
+
</h2>
|
|
203
|
+
{article.excerpt && (
|
|
204
|
+
<p className="text-muted-foreground line-clamp-3 text-sm leading-relaxed">
|
|
205
|
+
{article.excerpt}
|
|
206
|
+
</p>
|
|
207
|
+
)}
|
|
208
|
+
<p className="text-muted-foreground text-sm">{publishedAgo}</p>
|
|
209
|
+
</div>
|
|
210
|
+
</Link>
|
|
211
|
+
);
|
|
212
|
+
}
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
import Link from "next/link.js";
|
|
3
|
+
|
|
4
|
+
import type { PublishedArticle } from "../types/contracts";
|
|
5
|
+
import { buildBlogPath, buildSeriesPath } from "../lib/urls";
|
|
6
|
+
import { cn } from "../lib/utils";
|
|
7
|
+
|
|
8
|
+
interface SeriesNavigationProps {
|
|
9
|
+
article: PublishedArticle;
|
|
10
|
+
basePath?: string;
|
|
11
|
+
className?: string;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function SeriesNavigation({
|
|
15
|
+
article,
|
|
16
|
+
basePath = "/blog",
|
|
17
|
+
className,
|
|
18
|
+
}: SeriesNavigationProps) {
|
|
19
|
+
const series = article.series;
|
|
20
|
+
|
|
21
|
+
if (!series) {
|
|
22
|
+
return null;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
return (
|
|
26
|
+
<section
|
|
27
|
+
aria-labelledby="article-series-title"
|
|
28
|
+
className={cn(
|
|
29
|
+
"rounded-2xl border border-[var(--blog-accent-border)] bg-[var(--blog-accent-bg)] p-5 sm:p-6",
|
|
30
|
+
className,
|
|
31
|
+
)}
|
|
32
|
+
>
|
|
33
|
+
<div className="flex flex-col gap-5 md:flex-row md:items-start md:justify-between">
|
|
34
|
+
<div className="space-y-2">
|
|
35
|
+
<p className="text-muted-foreground text-sm font-medium">
|
|
36
|
+
Part {series.position} of {series.total}
|
|
37
|
+
</p>
|
|
38
|
+
<h2 id="article-series-title" className="text-xl font-semibold">
|
|
39
|
+
<Link
|
|
40
|
+
href={buildSeriesPath(basePath, series.slug)}
|
|
41
|
+
className="transition-colors hover:text-[var(--blog-accent)] hover:no-underline"
|
|
42
|
+
>
|
|
43
|
+
{series.title}
|
|
44
|
+
</Link>
|
|
45
|
+
</h2>
|
|
46
|
+
{series.description && (
|
|
47
|
+
<p className="text-muted-foreground max-w-2xl leading-relaxed">
|
|
48
|
+
{series.description}
|
|
49
|
+
</p>
|
|
50
|
+
)}
|
|
51
|
+
</div>
|
|
52
|
+
|
|
53
|
+
{(series.previous ?? series.next) && (
|
|
54
|
+
<div className="grid shrink-0 gap-2 sm:grid-cols-2 md:min-w-64 md:grid-cols-1">
|
|
55
|
+
{series.previous && (
|
|
56
|
+
<Link
|
|
57
|
+
href={buildBlogPath(basePath, series.previous.slug)}
|
|
58
|
+
className="rounded-xl border border-[var(--blog-accent-border)] bg-[var(--blog-card-bg)] px-3 py-2 text-sm transition-colors hover:border-[var(--blog-accent)] hover:no-underline"
|
|
59
|
+
>
|
|
60
|
+
<span className="text-muted-foreground block text-xs">
|
|
61
|
+
Previous
|
|
62
|
+
</span>
|
|
63
|
+
<span className="line-clamp-2 font-medium">
|
|
64
|
+
{series.previous.title}
|
|
65
|
+
</span>
|
|
66
|
+
</Link>
|
|
67
|
+
)}
|
|
68
|
+
{series.next && (
|
|
69
|
+
<Link
|
|
70
|
+
href={buildBlogPath(basePath, series.next.slug)}
|
|
71
|
+
className="rounded-xl border border-[var(--blog-accent-border)] bg-[var(--blog-card-bg)] px-3 py-2 text-sm transition-colors hover:border-[var(--blog-accent)] hover:no-underline"
|
|
72
|
+
>
|
|
73
|
+
<span className="text-muted-foreground block text-xs">
|
|
74
|
+
Next
|
|
75
|
+
</span>
|
|
76
|
+
<span className="line-clamp-2 font-medium">
|
|
77
|
+
{series.next.title}
|
|
78
|
+
</span>
|
|
79
|
+
</Link>
|
|
80
|
+
)}
|
|
81
|
+
</div>
|
|
82
|
+
)}
|
|
83
|
+
</div>
|
|
84
|
+
|
|
85
|
+
{series.articles.length > 0 && (
|
|
86
|
+
<ol className="mt-5 grid gap-2">
|
|
87
|
+
{series.articles.map((seriesArticle) => (
|
|
88
|
+
<li key={seriesArticle.id}>
|
|
89
|
+
<Link
|
|
90
|
+
href={buildBlogPath(basePath, seriesArticle.slug)}
|
|
91
|
+
aria-current={seriesArticle.isCurrent ? "page" : undefined}
|
|
92
|
+
className={cn(
|
|
93
|
+
"hover:bg-background/60 flex gap-3 rounded-xl px-3 py-2 text-sm transition-colors hover:no-underline",
|
|
94
|
+
seriesArticle.isCurrent && "bg-background/70",
|
|
95
|
+
)}
|
|
96
|
+
>
|
|
97
|
+
<span className="text-muted-foreground shrink-0 tabular-nums">
|
|
98
|
+
{seriesArticle.position}
|
|
99
|
+
</span>
|
|
100
|
+
<span
|
|
101
|
+
className={cn(
|
|
102
|
+
"line-clamp-2",
|
|
103
|
+
seriesArticle.isCurrent
|
|
104
|
+
? "font-semibold text-[var(--blog-text-accent)]"
|
|
105
|
+
: "font-medium",
|
|
106
|
+
)}
|
|
107
|
+
>
|
|
108
|
+
{seriesArticle.title}
|
|
109
|
+
</span>
|
|
110
|
+
</Link>
|
|
111
|
+
</li>
|
|
112
|
+
))}
|
|
113
|
+
</ol>
|
|
114
|
+
)}
|
|
115
|
+
</section>
|
|
116
|
+
);
|
|
117
|
+
}
|
|
@@ -76,4 +76,26 @@ describe("Blog topic archive pages", () => {
|
|
|
76
76
|
expect(html).toContain("via-[var(--blog-text-accent)]");
|
|
77
77
|
expect(html).toContain("to-fuchsia-200");
|
|
78
78
|
});
|
|
79
|
+
|
|
80
|
+
it("renders topic archive article images as clipped cover thumbnails", () => {
|
|
81
|
+
const html = renderToStaticMarkup(
|
|
82
|
+
React.createElement(BlogTopicArchivePage, {
|
|
83
|
+
articles: [
|
|
84
|
+
{
|
|
85
|
+
...article,
|
|
86
|
+
featuredImage: "https://cdn.example.com/articles/routines.webp",
|
|
87
|
+
featuredImageAlt: "A tidy workspace",
|
|
88
|
+
},
|
|
89
|
+
],
|
|
90
|
+
topicSlug: "recurring-work-and-routines",
|
|
91
|
+
basePath: "/new-blog",
|
|
92
|
+
}),
|
|
93
|
+
);
|
|
94
|
+
|
|
95
|
+
expect(html).toContain("overflow-hidden rounded-2xl");
|
|
96
|
+
expect(html).toContain(
|
|
97
|
+
"h-full w-full object-cover transition-transform duration-300 group-hover:scale-105",
|
|
98
|
+
);
|
|
99
|
+
expect(html).not.toContain("h-full w-full object-contain");
|
|
100
|
+
});
|
|
79
101
|
});
|
|
@@ -194,7 +194,7 @@ function TopicArticleCard({
|
|
|
194
194
|
alt={article.featuredImageAlt ?? article.title}
|
|
195
195
|
width={640}
|
|
196
196
|
height={360}
|
|
197
|
-
className="h-full w-full object-
|
|
197
|
+
className="h-full w-full object-cover transition-transform duration-300 group-hover:scale-105"
|
|
198
198
|
/>
|
|
199
199
|
</div>
|
|
200
200
|
)}
|