@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.
Files changed (38) hide show
  1. package/dist/app/blog/[slug]/page.tsx +11 -4
  2. package/dist/app/blog/authors/[slug]/page.tsx +1 -1
  3. package/dist/app/blog/page.test.ts +20 -0
  4. package/dist/app/blog/page.tsx +14 -13
  5. package/dist/app/blog/series/[slug]/page.d.ts +28 -0
  6. package/dist/app/blog/series/[slug]/page.tsx +39 -0
  7. package/dist/app/blog/series/page.d.ts +24 -0
  8. package/dist/app/blog/series/page.tsx +28 -0
  9. package/dist/components/SeriesArchivePages.tsx +212 -0
  10. package/dist/components/SeriesNavigation.tsx +117 -0
  11. package/dist/components/TopicArchivePages.test.ts +22 -0
  12. package/dist/components/TopicArchivePages.tsx +1 -1
  13. package/dist/{index-Dr8HaKGm.d.mts → index-CKWrb9a4.d.mts} +43 -1
  14. package/dist/{index-Dr8HaKGm.d.ts → index-CKWrb9a4.d.ts} +43 -1
  15. package/dist/index.d.mts +85 -5
  16. package/dist/index.d.ts +85 -5
  17. package/dist/index.js +12 -12
  18. package/dist/index.js.map +1 -1
  19. package/dist/index.mjs +12 -12
  20. package/dist/index.mjs.map +1 -1
  21. package/dist/lib/related-articles.d.mts +1 -1
  22. package/dist/lib/related-articles.d.ts +1 -1
  23. package/dist/lib/series.ts +55 -0
  24. package/dist/lib/sitemap.ts +76 -4
  25. package/dist/lib/storage/drizzle.ts +9 -0
  26. package/dist/lib/urls.ts +7 -0
  27. package/dist/{sitemap-BmCh8BpN.d.ts → sitemap-BDT3d496.d.ts} +7 -1
  28. package/dist/{sitemap-Dbl4CUGI.d.mts → sitemap-D3rS0iAF.d.mts} +7 -1
  29. package/dist/sitemap.d.mts +2 -2
  30. package/dist/sitemap.d.ts +2 -2
  31. package/dist/sitemap.js +5 -5
  32. package/dist/sitemap.js.map +1 -1
  33. package/dist/sitemap.mjs +5 -5
  34. package/dist/sitemap.mjs.map +1 -1
  35. package/dist/types/contracts.ts +58 -0
  36. package/dist/types/index.d.mts +1 -1
  37. package/dist/types/index.d.ts +1 -1
  38. 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
- article.featuredImageCreditText ??
166
- article.featuredImageCreditUrl,
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)] [&_figure>p]:!my-0 [&_figcaption]:!mt-2 [&_figcaption]:text-center [&_figcaption]:!text-sm [&_figcaption]:!leading-snug [&_figcaption]:text-muted-foreground [&_figcaption_.blog-image-credit]:mt-1 [&_figcaption_.blog-image-credit]:block [&_figcaption_.blog-image-credit]:text-xs [&_figcaption_.blog-image-credit]:tracking-wide [&_figcaption_.blog-image-credit]:text-muted-foreground/80 [&_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 [&_figcaption>span:not(.blog-image-credit)]:text-muted-foreground/80 max-w-none"
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-contain"
833
+ className="h-full w-full object-cover transition-transform duration-300 group-hover:scale-105"
827
834
  />
828
835
  </div>
829
836
  );
@@ -315,7 +315,7 @@ function AuthorArticlePreview({
315
315
  }
316
316
  width={320}
317
317
  height={200}
318
- className="h-full w-full object-contain"
318
+ className="h-full w-full object-cover transition-transform duration-300 group-hover:scale-105"
319
319
  />
320
320
  </div>
321
321
  )}
@@ -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, {
@@ -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-contain"
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-contain"
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 href={buildBlogPath(basePath, `topics/${slug}`)} className={className}>
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-contain"
197
+ className="h-full w-full object-cover transition-transform duration-300 group-hover:scale-105"
198
198
  />
199
199
  </div>
200
200
  )}