@xexr/blog-platform 1.0.2 → 1.0.4
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 +313 -46
- package/dist/app/blog/page.tsx +14 -6
- package/dist/index.d.mts +55 -4
- package/dist/index.d.ts +55 -4
- package/dist/index.js +10 -9
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +10 -9
- package/dist/index.mjs.map +1 -1
- package/package.json +2 -1
|
@@ -3,16 +3,11 @@ import Image from "next/image";
|
|
|
3
3
|
import Link from "next/link";
|
|
4
4
|
import { MDXRemote } from "next-mdx-remote-client/rsc";
|
|
5
5
|
import { type PublishedArticle } from "../../../types/contracts";
|
|
6
|
-
import { format, formatDistanceToNow } from "date-fns";
|
|
7
6
|
import { cn } from "../../../lib/utils";
|
|
8
|
-
import { ArrowLeft } from "lucide-react";
|
|
7
|
+
import { ArrowLeft, ArrowUpRight, Mail, Share2 } from "lucide-react";
|
|
9
8
|
import { Badge } from "../../../components/ui/badge";
|
|
10
|
-
import {
|
|
11
|
-
|
|
12
|
-
CardContent,
|
|
13
|
-
CardHeader,
|
|
14
|
-
CardTitle,
|
|
15
|
-
} from "../../../components/ui/card";
|
|
9
|
+
import { Card, CardContent } from "../../../components/ui/card";
|
|
10
|
+
import { ExpandableArticleImage } from "../../../components/ExpandableArticleImage";
|
|
16
11
|
import { Separator } from "../../../components/ui/separator";
|
|
17
12
|
import {
|
|
18
13
|
generateCompleteArticleStructuredData,
|
|
@@ -20,6 +15,12 @@ import {
|
|
|
20
15
|
} from "../../../lib/structured-data";
|
|
21
16
|
import { articleMdxOptions } from "../../../lib/mdx";
|
|
22
17
|
import { buildBlogPath, normalizeBlogBasePath } from "../../../lib/urls";
|
|
18
|
+
import {
|
|
19
|
+
type BlogTheme,
|
|
20
|
+
extractTableOfContents,
|
|
21
|
+
formatArticleDateMeta,
|
|
22
|
+
getBlogThemeStyle,
|
|
23
|
+
} from "../../../lib/article-ui";
|
|
23
24
|
|
|
24
25
|
interface ArticlePageProps {
|
|
25
26
|
article: PublishedArticle;
|
|
@@ -33,14 +34,31 @@ interface ArticlePageProps {
|
|
|
33
34
|
previousArticle?: {
|
|
34
35
|
url: string;
|
|
35
36
|
title: string;
|
|
37
|
+
featuredImage?: string;
|
|
38
|
+
featuredImageAlt?: string;
|
|
36
39
|
};
|
|
37
40
|
nextArticle?: {
|
|
38
41
|
url: string;
|
|
39
42
|
title: string;
|
|
43
|
+
featuredImage?: string;
|
|
44
|
+
featuredImageAlt?: string;
|
|
40
45
|
};
|
|
41
46
|
};
|
|
42
47
|
/** Public path where this blog is mounted, e.g. "/blog" or "/new-blog". */
|
|
43
48
|
basePath?: string;
|
|
49
|
+
/** Optional theme values for host-site brand integration. */
|
|
50
|
+
theme?: BlogTheme;
|
|
51
|
+
/** Optional post-article CTA supplied by the host site. */
|
|
52
|
+
postArticleCta?: {
|
|
53
|
+
title: string;
|
|
54
|
+
description?: string;
|
|
55
|
+
href: string;
|
|
56
|
+
label: string;
|
|
57
|
+
};
|
|
58
|
+
/** Optional related articles supplied by the host site. */
|
|
59
|
+
relatedArticles?: PublishedArticle[];
|
|
60
|
+
/** Whether to render static article share links. */
|
|
61
|
+
showShareLinks?: boolean;
|
|
44
62
|
}
|
|
45
63
|
|
|
46
64
|
/**
|
|
@@ -63,30 +81,35 @@ export default function ArticlePage({
|
|
|
63
81
|
className,
|
|
64
82
|
navigation,
|
|
65
83
|
basePath = "/blog",
|
|
84
|
+
theme,
|
|
85
|
+
postArticleCta,
|
|
86
|
+
relatedArticles = [],
|
|
87
|
+
showShareLinks = true,
|
|
66
88
|
}: ArticlePageProps) {
|
|
67
89
|
const normalizedBasePath = normalizeBlogBasePath(basePath) || "/";
|
|
68
|
-
const
|
|
69
|
-
const updatedDate = new Date(article.updatedAt);
|
|
70
|
-
const isUpdated = updatedDate.getTime() !== publishedDate.getTime();
|
|
71
|
-
const formattedDate = formatDistanceToNow(publishedDate, { addSuffix: true });
|
|
90
|
+
const dateMeta = formatArticleDateMeta(article.publishedAt, article.updatedAt);
|
|
72
91
|
const readingTime = formatReadingTime(article.readingSeconds);
|
|
92
|
+
const tocItems = extractTableOfContents(article.content);
|
|
93
|
+
const articleUrl =
|
|
94
|
+
article.meta.canonicalUrl ?? buildBlogPath(normalizedBasePath, article.slug);
|
|
73
95
|
|
|
74
96
|
// Use the featured image URL provided by Bloggy (hosted on Bloggy's domain)
|
|
75
97
|
const imageUrl = article.featuredImage;
|
|
76
98
|
|
|
77
99
|
return (
|
|
78
100
|
<article
|
|
101
|
+
style={getBlogThemeStyle(theme)}
|
|
79
102
|
className={cn(
|
|
80
|
-
"article-page mx-auto max-w-
|
|
103
|
+
"article-page mx-auto max-w-6xl px-4 py-6 pb-12 sm:px-6 sm:py-8 sm:pb-16 lg:px-8 lg:py-12 lg:pb-24",
|
|
81
104
|
className,
|
|
82
105
|
)}
|
|
83
106
|
>
|
|
84
107
|
{/* Navigation */}
|
|
85
108
|
{navigation?.backToHome && (
|
|
86
|
-
<header className="mb-8 sm:mb-12 lg:mb-16">
|
|
109
|
+
<header className="mx-auto mb-8 max-w-4xl sm:mb-12 lg:mb-16">
|
|
87
110
|
<Link
|
|
88
111
|
href={navigation.backToHome.url}
|
|
89
|
-
className="text-muted-foreground hover:text-
|
|
112
|
+
className="text-muted-foreground hover:text-[var(--blog-accent)] group focus-visible:ring-ring inline-flex touch-manipulation items-center space-x-2 rounded-md px-2 py-1 transition-colors focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-none sm:space-x-3"
|
|
90
113
|
aria-label={navigation.backToHome.label ?? "Return to blog"}
|
|
91
114
|
>
|
|
92
115
|
<ArrowLeft
|
|
@@ -104,15 +127,15 @@ export default function ArticlePage({
|
|
|
104
127
|
)}
|
|
105
128
|
|
|
106
129
|
{/* Article Header */}
|
|
107
|
-
<header className="article-header mb-6 space-y-4 text-center sm:mb-8 sm:space-y-6 lg:mb-12 lg:space-y-8">
|
|
130
|
+
<header className="article-header mx-auto mb-6 max-w-4xl space-y-4 text-center sm:mb-8 sm:space-y-6 lg:mb-12 lg:space-y-8">
|
|
108
131
|
{/* Title */}
|
|
109
|
-
<h1 className="article-title text-
|
|
132
|
+
<h1 className="article-title bg-linear-to-br from-[var(--blog-accent-hover)] via-[var(--blog-text-accent)] to-fuchsia-200 bg-clip-text px-4 text-2xl leading-tight font-light tracking-tight text-balance text-transparent sm:px-0 sm:text-3xl md:text-4xl lg:text-5xl xl:text-6xl">
|
|
110
133
|
{article.title}
|
|
111
134
|
</h1>
|
|
112
135
|
|
|
113
136
|
{/* Excerpt */}
|
|
114
137
|
{article.excerpt && (
|
|
115
|
-
<p className="article-excerpt text-muted-foreground mx-auto mt-6 max-w-
|
|
138
|
+
<p className="article-excerpt text-muted-foreground mx-auto mt-6 max-w-[42rem] px-4 text-lg leading-relaxed text-balance sm:mt-8 sm:max-w-[46rem] sm:px-0 sm:text-xl md:max-w-[52rem] md:text-2xl lg:max-w-4xl lg:text-2xl">
|
|
116
139
|
{article.excerpt}
|
|
117
140
|
</p>
|
|
118
141
|
)}
|
|
@@ -135,17 +158,19 @@ export default function ArticlePage({
|
|
|
135
158
|
<time
|
|
136
159
|
dateTime={article.publishedAt}
|
|
137
160
|
className="font-medium"
|
|
138
|
-
aria-label={`Published ${
|
|
161
|
+
aria-label={`Published ${dateMeta.publishedDateLabel}`}
|
|
139
162
|
>
|
|
140
|
-
Published {
|
|
163
|
+
Published {dateMeta.publishedDateLabel}
|
|
141
164
|
</time>
|
|
142
165
|
<span className="text-border">•</span>
|
|
166
|
+
<span className="font-medium">{dateMeta.publishedRelativeLabel}</span>
|
|
167
|
+
<span className="text-border">•</span>
|
|
143
168
|
<span className="font-medium">{readingTime}</span>
|
|
144
169
|
</div>
|
|
145
170
|
|
|
146
|
-
{isUpdated && (
|
|
171
|
+
{dateMeta.isUpdated && (
|
|
147
172
|
<time dateTime={article.updatedAt} className="text-xs">
|
|
148
|
-
Updated {
|
|
173
|
+
Updated {dateMeta.updatedDateLabel}
|
|
149
174
|
</time>
|
|
150
175
|
)}
|
|
151
176
|
|
|
@@ -162,7 +187,7 @@ export default function ArticlePage({
|
|
|
162
187
|
|
|
163
188
|
{/* Featured Image */}
|
|
164
189
|
{imageUrl && (
|
|
165
|
-
<div className="article-featured-image
|
|
190
|
+
<div className="article-featured-image mx-auto my-8 aspect-[16/9] w-full max-w-4xl overflow-hidden rounded-3xl border border-[var(--blog-accent-border)] shadow-[0_24px_80px_rgb(168_85_247_/_0.14)] sm:my-12 lg:my-16">
|
|
166
191
|
<Image
|
|
167
192
|
src={imageUrl}
|
|
168
193
|
alt={
|
|
@@ -177,50 +202,98 @@ export default function ArticlePage({
|
|
|
177
202
|
)}
|
|
178
203
|
|
|
179
204
|
{/* Article Content */}
|
|
180
|
-
<div className="article-content">
|
|
181
|
-
<div className="
|
|
182
|
-
<
|
|
205
|
+
<div className="article-content mx-auto grid max-w-6xl grid-cols-1 gap-8 lg:grid-cols-[minmax(0,1fr)_14rem] lg:gap-12">
|
|
206
|
+
<div className="min-w-0">
|
|
207
|
+
<ArticleTableOfContents items={tocItems} variant="mobile" />
|
|
208
|
+
|
|
209
|
+
<div 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)] max-w-none">
|
|
210
|
+
<MDXRemote
|
|
211
|
+
source={article.content}
|
|
212
|
+
options={articleMdxOptions}
|
|
213
|
+
components={{ img: ExpandableArticleImage }}
|
|
214
|
+
/>
|
|
215
|
+
</div>
|
|
183
216
|
</div>
|
|
217
|
+
|
|
218
|
+
<ArticleTableOfContents items={tocItems} variant="desktop" />
|
|
184
219
|
</div>
|
|
185
220
|
|
|
186
221
|
{/* Author Bio */}
|
|
187
222
|
{article.author?.bio && (
|
|
188
|
-
<div className="author-bio mt-12">
|
|
223
|
+
<div className="author-bio mx-auto mt-12 max-w-4xl">
|
|
189
224
|
<Separator className="mb-8" />
|
|
190
|
-
<Card>
|
|
191
|
-
<
|
|
192
|
-
<
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
225
|
+
<Card className="border-[var(--blog-accent-border)] bg-[var(--blog-accent-bg)]">
|
|
226
|
+
<CardContent className="flex flex-col gap-4 p-5 sm:flex-row sm:items-start sm:p-6">
|
|
227
|
+
<div
|
|
228
|
+
className="flex size-12 shrink-0 items-center justify-center rounded-full bg-[var(--blog-accent)] text-lg font-semibold text-white"
|
|
229
|
+
aria-hidden="true"
|
|
230
|
+
>
|
|
231
|
+
{article.author.name.slice(0, 1)}
|
|
232
|
+
</div>
|
|
233
|
+
<div className="min-w-0 space-y-2">
|
|
234
|
+
<div>
|
|
235
|
+
<p className="text-muted-foreground text-sm font-medium">
|
|
236
|
+
Written by
|
|
237
|
+
</p>
|
|
238
|
+
<h2 className="text-foreground flex flex-wrap items-center gap-2 text-xl font-semibold">
|
|
239
|
+
{article.author.name}
|
|
240
|
+
{article.author.isGuest && (
|
|
241
|
+
<Badge variant="outline" className="text-xs">
|
|
242
|
+
Guest Author
|
|
243
|
+
</Badge>
|
|
244
|
+
)}
|
|
245
|
+
</h2>
|
|
246
|
+
</div>
|
|
247
|
+
<p className="text-muted-foreground leading-relaxed">
|
|
248
|
+
{article.author.bio}
|
|
249
|
+
</p>
|
|
204
250
|
</div>
|
|
205
251
|
</CardContent>
|
|
206
252
|
</Card>
|
|
207
253
|
</div>
|
|
208
254
|
)}
|
|
209
255
|
|
|
256
|
+
{((postArticleCta ?? false) ||
|
|
257
|
+
showShareLinks ||
|
|
258
|
+
relatedArticles.length > 0) && (
|
|
259
|
+
<div className="mx-auto mt-12 max-w-4xl space-y-8">
|
|
260
|
+
<Separator />
|
|
261
|
+
{postArticleCta && <PostArticleCta cta={postArticleCta} />}
|
|
262
|
+
{showShareLinks && (
|
|
263
|
+
<ArticleShareLinks title={article.title} url={articleUrl} />
|
|
264
|
+
)}
|
|
265
|
+
{relatedArticles.length > 0 && (
|
|
266
|
+
<RelatedArticles
|
|
267
|
+
articles={relatedArticles.slice(0, 3)}
|
|
268
|
+
basePath={normalizedBasePath}
|
|
269
|
+
/>
|
|
270
|
+
)}
|
|
271
|
+
</div>
|
|
272
|
+
)}
|
|
273
|
+
|
|
210
274
|
{/* Article Navigation */}
|
|
211
275
|
{(Boolean(navigation?.previousArticle) ||
|
|
212
276
|
Boolean(navigation?.nextArticle)) && (
|
|
213
|
-
<nav className="article-nav mt-12">
|
|
277
|
+
<nav className="article-nav mx-auto mt-12 max-w-4xl">
|
|
214
278
|
<Separator className="mb-8" />
|
|
215
279
|
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
|
|
216
280
|
{navigation?.previousArticle && (
|
|
217
|
-
<Card className="group hover:bg-accent
|
|
281
|
+
<Card className="group hover:border-[var(--blog-accent-border)] hover:bg-[var(--blog-accent-bg)] cursor-pointer transition-colors">
|
|
218
282
|
<Link href={navigation.previousArticle.url} className="block">
|
|
283
|
+
{navigation.previousArticle.featuredImage && (
|
|
284
|
+
<ArticleCardImage
|
|
285
|
+
src={navigation.previousArticle.featuredImage}
|
|
286
|
+
alt={
|
|
287
|
+
navigation.previousArticle.featuredImageAlt ??
|
|
288
|
+
`Featured image for ${navigation.previousArticle.title}`
|
|
289
|
+
}
|
|
290
|
+
/>
|
|
291
|
+
)}
|
|
219
292
|
<CardContent className="p-4">
|
|
220
293
|
<div className="text-muted-foreground mb-2 text-sm font-medium">
|
|
221
294
|
← Previous Article
|
|
222
295
|
</div>
|
|
223
|
-
<div className="text-foreground group-hover:text-
|
|
296
|
+
<div className="text-foreground group-hover:text-[var(--blog-text-accent)] transition-colors">
|
|
224
297
|
{navigation.previousArticle.title}
|
|
225
298
|
</div>
|
|
226
299
|
</CardContent>
|
|
@@ -230,16 +303,25 @@ export default function ArticlePage({
|
|
|
230
303
|
{navigation?.nextArticle && (
|
|
231
304
|
<Card
|
|
232
305
|
className={cn(
|
|
233
|
-
"group hover:bg-accent
|
|
306
|
+
"group hover:border-[var(--blog-accent-border)] hover:bg-[var(--blog-accent-bg)] cursor-pointer transition-colors",
|
|
234
307
|
!navigation?.previousArticle && "md:col-start-2",
|
|
235
308
|
)}
|
|
236
309
|
>
|
|
237
310
|
<Link href={navigation.nextArticle.url} className="block">
|
|
311
|
+
{navigation.nextArticle.featuredImage && (
|
|
312
|
+
<ArticleCardImage
|
|
313
|
+
src={navigation.nextArticle.featuredImage}
|
|
314
|
+
alt={
|
|
315
|
+
navigation.nextArticle.featuredImageAlt ??
|
|
316
|
+
`Featured image for ${navigation.nextArticle.title}`
|
|
317
|
+
}
|
|
318
|
+
/>
|
|
319
|
+
)}
|
|
238
320
|
<CardContent className="p-4 md:text-right">
|
|
239
321
|
<div className="text-muted-foreground mb-2 text-sm font-medium">
|
|
240
322
|
Next Article →
|
|
241
323
|
</div>
|
|
242
|
-
<div className="text-foreground group-hover:text-
|
|
324
|
+
<div className="text-foreground group-hover:text-[var(--blog-text-accent)] transition-colors">
|
|
243
325
|
{navigation.nextArticle.title}
|
|
244
326
|
</div>
|
|
245
327
|
</CardContent>
|
|
@@ -270,6 +352,191 @@ export default function ArticlePage({
|
|
|
270
352
|
);
|
|
271
353
|
}
|
|
272
354
|
|
|
355
|
+
function ArticleTableOfContents({
|
|
356
|
+
items,
|
|
357
|
+
variant,
|
|
358
|
+
}: {
|
|
359
|
+
items: ReturnType<typeof extractTableOfContents>;
|
|
360
|
+
variant: "mobile" | "desktop";
|
|
361
|
+
}) {
|
|
362
|
+
if (items.length === 0) {
|
|
363
|
+
return null;
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
const list = (
|
|
367
|
+
<ul className="space-y-2 text-sm">
|
|
368
|
+
{items.map((item) => (
|
|
369
|
+
<li key={item.id} className={item.level === 3 ? "pl-4" : undefined}>
|
|
370
|
+
<a
|
|
371
|
+
href={`#${item.id}`}
|
|
372
|
+
className="text-muted-foreground hover:text-[var(--blog-text-accent)] block leading-snug transition-colors"
|
|
373
|
+
>
|
|
374
|
+
{item.text}
|
|
375
|
+
</a>
|
|
376
|
+
</li>
|
|
377
|
+
))}
|
|
378
|
+
</ul>
|
|
379
|
+
);
|
|
380
|
+
|
|
381
|
+
if (variant === "mobile") {
|
|
382
|
+
return (
|
|
383
|
+
<nav
|
|
384
|
+
aria-label="Table of contents"
|
|
385
|
+
className="mb-8 rounded-2xl border border-[var(--blog-accent-border)] bg-[var(--blog-accent-bg)] p-4 lg:hidden"
|
|
386
|
+
>
|
|
387
|
+
<p className="text-foreground mb-3 text-sm font-semibold">
|
|
388
|
+
On this page
|
|
389
|
+
</p>
|
|
390
|
+
{list}
|
|
391
|
+
</nav>
|
|
392
|
+
);
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
return (
|
|
396
|
+
<aside className="hidden lg:block">
|
|
397
|
+
<nav
|
|
398
|
+
aria-label="Table of contents"
|
|
399
|
+
className="sticky top-24 max-h-[calc(100vh-8rem)] overflow-y-auto border-l border-border pl-5"
|
|
400
|
+
>
|
|
401
|
+
<p className="text-muted-foreground mb-3 text-xs font-semibold tracking-wider uppercase">
|
|
402
|
+
On this page
|
|
403
|
+
</p>
|
|
404
|
+
{list}
|
|
405
|
+
</nav>
|
|
406
|
+
</aside>
|
|
407
|
+
);
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
function PostArticleCta({
|
|
411
|
+
cta,
|
|
412
|
+
}: {
|
|
413
|
+
cta: NonNullable<ArticlePageProps["postArticleCta"]>;
|
|
414
|
+
}) {
|
|
415
|
+
return (
|
|
416
|
+
<div className="rounded-2xl border border-[var(--blog-accent-border)] bg-[var(--blog-accent-bg)] p-5 sm:p-6">
|
|
417
|
+
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
|
|
418
|
+
<div className="space-y-1">
|
|
419
|
+
<h2 className="text-foreground text-xl font-semibold">{cta.title}</h2>
|
|
420
|
+
{cta.description && (
|
|
421
|
+
<p className="text-muted-foreground leading-relaxed">
|
|
422
|
+
{cta.description}
|
|
423
|
+
</p>
|
|
424
|
+
)}
|
|
425
|
+
</div>
|
|
426
|
+
<Link
|
|
427
|
+
href={cta.href}
|
|
428
|
+
className="inline-flex shrink-0 items-center justify-center gap-2 rounded-full bg-[var(--blog-accent)] px-4 py-2 text-sm font-semibold text-white transition-opacity hover:opacity-90"
|
|
429
|
+
>
|
|
430
|
+
{cta.label}
|
|
431
|
+
<ArrowUpRight className="size-4" aria-hidden="true" />
|
|
432
|
+
</Link>
|
|
433
|
+
</div>
|
|
434
|
+
</div>
|
|
435
|
+
);
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
function ArticleShareLinks({ title, url }: { title: string; url: string }) {
|
|
439
|
+
const encodedTitle = encodeURIComponent(title);
|
|
440
|
+
const encodedUrl = encodeURIComponent(url);
|
|
441
|
+
|
|
442
|
+
return (
|
|
443
|
+
<div className="flex flex-wrap items-center justify-center gap-2 sm:gap-3">
|
|
444
|
+
<span className="text-muted-foreground inline-flex basis-full items-center justify-center gap-2 text-sm font-medium sm:basis-auto">
|
|
445
|
+
<Share2 className="size-4" aria-hidden="true" />
|
|
446
|
+
Share
|
|
447
|
+
</span>
|
|
448
|
+
<a
|
|
449
|
+
href={`https://x.com/intent/tweet?text=${encodedTitle}&url=${encodedUrl}`}
|
|
450
|
+
target="_blank"
|
|
451
|
+
rel="noopener noreferrer"
|
|
452
|
+
aria-label="Share on X"
|
|
453
|
+
className="text-muted-foreground hover:border-[var(--blog-accent-border)] hover:bg-[var(--blog-accent-bg)] hover:text-foreground inline-flex size-9 items-center justify-center rounded-full border border-border grayscale transition-colors"
|
|
454
|
+
>
|
|
455
|
+
<span className="text-sm font-semibold" aria-hidden="true">
|
|
456
|
+
X
|
|
457
|
+
</span>
|
|
458
|
+
</a>
|
|
459
|
+
<a
|
|
460
|
+
href={`https://www.linkedin.com/sharing/share-offsite/?url=${encodedUrl}`}
|
|
461
|
+
target="_blank"
|
|
462
|
+
rel="noopener noreferrer"
|
|
463
|
+
aria-label="Share on LinkedIn"
|
|
464
|
+
className="text-muted-foreground hover:border-[var(--blog-accent-border)] hover:bg-[var(--blog-accent-bg)] hover:text-foreground inline-flex size-9 items-center justify-center rounded-full border border-border grayscale transition-colors"
|
|
465
|
+
>
|
|
466
|
+
<span className="text-sm font-bold" aria-hidden="true">
|
|
467
|
+
in
|
|
468
|
+
</span>
|
|
469
|
+
</a>
|
|
470
|
+
<a
|
|
471
|
+
href={`mailto:?subject=${encodedTitle}&body=${encodedUrl}`}
|
|
472
|
+
aria-label="Share by email"
|
|
473
|
+
className="text-muted-foreground hover:border-[var(--blog-accent-border)] hover:bg-[var(--blog-accent-bg)] hover:text-foreground inline-flex size-9 items-center justify-center rounded-full border border-border grayscale transition-colors"
|
|
474
|
+
>
|
|
475
|
+
<Mail className="size-4" aria-hidden="true" />
|
|
476
|
+
</a>
|
|
477
|
+
</div>
|
|
478
|
+
);
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
function RelatedArticles({
|
|
482
|
+
articles,
|
|
483
|
+
basePath,
|
|
484
|
+
}: {
|
|
485
|
+
articles: PublishedArticle[];
|
|
486
|
+
basePath: string;
|
|
487
|
+
}) {
|
|
488
|
+
return (
|
|
489
|
+
<section className="space-y-4" aria-labelledby="related-articles-title">
|
|
490
|
+
<h2 id="related-articles-title" className="text-xl font-semibold">
|
|
491
|
+
You might also like
|
|
492
|
+
</h2>
|
|
493
|
+
<div className="grid gap-4 md:grid-cols-3">
|
|
494
|
+
{articles.map((article) => (
|
|
495
|
+
<Link
|
|
496
|
+
key={article.id}
|
|
497
|
+
href={buildBlogPath(basePath, article.slug)}
|
|
498
|
+
className="group overflow-hidden rounded-2xl border border-border transition-colors hover:border-[var(--blog-accent-border)] hover:bg-[var(--blog-accent-bg)] hover:no-underline"
|
|
499
|
+
>
|
|
500
|
+
{article.featuredImage && (
|
|
501
|
+
<ArticleCardImage
|
|
502
|
+
src={article.featuredImage}
|
|
503
|
+
alt={
|
|
504
|
+
article.featuredImageAlt ??
|
|
505
|
+
`Featured image for ${article.title}`
|
|
506
|
+
}
|
|
507
|
+
/>
|
|
508
|
+
)}
|
|
509
|
+
<div className="p-4">
|
|
510
|
+
<h3 className="text-foreground group-hover:text-[var(--blog-text-accent)] line-clamp-3 font-semibold transition-colors">
|
|
511
|
+
{article.title}
|
|
512
|
+
</h3>
|
|
513
|
+
{article.excerpt && (
|
|
514
|
+
<p className="text-muted-foreground mt-2 line-clamp-3 text-sm leading-relaxed">
|
|
515
|
+
{article.excerpt}
|
|
516
|
+
</p>
|
|
517
|
+
)}
|
|
518
|
+
</div>
|
|
519
|
+
</Link>
|
|
520
|
+
))}
|
|
521
|
+
</div>
|
|
522
|
+
</section>
|
|
523
|
+
);
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
function ArticleCardImage({ src, alt }: { src: string; alt: string }) {
|
|
527
|
+
return (
|
|
528
|
+
<div className="aspect-[16/9] overflow-hidden border-b border-border bg-[var(--blog-accent-bg)]">
|
|
529
|
+
<Image
|
|
530
|
+
src={src}
|
|
531
|
+
alt={alt}
|
|
532
|
+
width={480}
|
|
533
|
+
height={270}
|
|
534
|
+
className="h-full w-full object-cover transition-transform duration-500 group-hover:scale-105"
|
|
535
|
+
/>
|
|
536
|
+
</div>
|
|
537
|
+
);
|
|
538
|
+
}
|
|
539
|
+
|
|
273
540
|
/**
|
|
274
541
|
* Generate metadata for the article page for SEO optimization.
|
|
275
542
|
* This function should be used in the Next.js metadata API.
|
package/dist/app/blog/page.tsx
CHANGED
|
@@ -13,6 +13,10 @@ import {
|
|
|
13
13
|
} from "../../lib/structured-data";
|
|
14
14
|
import { buildBlogPath, normalizeBlogBasePath } from "../../lib/urls";
|
|
15
15
|
import { formatDistanceToNow } from "date-fns";
|
|
16
|
+
import {
|
|
17
|
+
type BlogTheme,
|
|
18
|
+
getBlogThemeStyle,
|
|
19
|
+
} from "../../lib/article-ui";
|
|
16
20
|
|
|
17
21
|
interface BlogHomePageProps {
|
|
18
22
|
articles: PublishedArticle[];
|
|
@@ -31,6 +35,8 @@ interface BlogHomePageProps {
|
|
|
31
35
|
totalPages: number;
|
|
32
36
|
baseUrl?: string;
|
|
33
37
|
};
|
|
38
|
+
/** Optional theme values for host-site brand integration. */
|
|
39
|
+
theme?: BlogTheme;
|
|
34
40
|
}
|
|
35
41
|
|
|
36
42
|
/**
|
|
@@ -49,11 +55,13 @@ export default function BlogHomePage({
|
|
|
49
55
|
includeStructuredData = false,
|
|
50
56
|
basePath = "/blog",
|
|
51
57
|
pagination,
|
|
58
|
+
theme,
|
|
52
59
|
}: BlogHomePageProps) {
|
|
53
60
|
const normalizedBasePath = normalizeBlogBasePath(basePath) || "/";
|
|
54
61
|
|
|
55
62
|
return (
|
|
56
63
|
<div
|
|
64
|
+
style={getBlogThemeStyle(theme)}
|
|
57
65
|
className={cn(
|
|
58
66
|
"blog-home-page container mx-auto max-w-5xl px-4 py-8 sm:px-6 sm:py-12 lg:px-8 lg:py-16",
|
|
59
67
|
className,
|
|
@@ -62,7 +70,7 @@ export default function BlogHomePage({
|
|
|
62
70
|
{/* Site Header */}
|
|
63
71
|
{siteName && (
|
|
64
72
|
<header className="blog-header mb-8 text-center sm:mb-12 lg:mb-16">
|
|
65
|
-
<h1 className="blog-title
|
|
73
|
+
<h1 className="blog-title mb-2 bg-linear-to-r from-[var(--blog-accent-hover)] to-[var(--blog-accent)] bg-clip-text text-3xl font-semibold tracking-tight text-balance text-transparent sm:text-4xl lg:text-5xl">
|
|
66
74
|
{siteName}
|
|
67
75
|
</h1>
|
|
68
76
|
{siteTagline && (
|
|
@@ -162,13 +170,13 @@ function ArticlePreview({
|
|
|
162
170
|
<article className="group border-border mb-6 block border-b pb-6 last:mb-0 last:border-b-0 last:pb-0 sm:mb-8 sm:pb-8">
|
|
163
171
|
<Link
|
|
164
172
|
href={articleUrl}
|
|
165
|
-
className="focus-visible:ring-ring block rounded-
|
|
173
|
+
className="focus-visible:ring-ring block rounded-2xl hover:no-underline focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-none"
|
|
166
174
|
aria-label={`Read article: ${article.title}`}
|
|
167
175
|
>
|
|
168
176
|
{/* Mobile/Small Tablet: Vertical layout */}
|
|
169
177
|
<div className="space-y-4 lg:hidden">
|
|
170
178
|
{imageUrl && (
|
|
171
|
-
<div className="relative aspect-[16/10] w-full overflow-hidden rounded-
|
|
179
|
+
<div className="relative aspect-[16/10] w-full overflow-hidden rounded-2xl">
|
|
172
180
|
<Image
|
|
173
181
|
src={imageUrl}
|
|
174
182
|
alt={
|
|
@@ -183,7 +191,7 @@ function ArticlePreview({
|
|
|
183
191
|
)}
|
|
184
192
|
|
|
185
193
|
<div className="space-y-3">
|
|
186
|
-
<h2 className="group-hover:text-
|
|
194
|
+
<h2 className="group-hover:text-[var(--blog-accent)] text-lg leading-tight font-bold text-balance transition-colors sm:text-xl md:text-2xl">
|
|
187
195
|
{article.title}
|
|
188
196
|
</h2>
|
|
189
197
|
|
|
@@ -216,7 +224,7 @@ function ArticlePreview({
|
|
|
216
224
|
{/* Large Tablet/Desktop: Horizontal layout */}
|
|
217
225
|
<div className="hidden items-start gap-6 lg:flex xl:gap-8">
|
|
218
226
|
{imageUrl && (
|
|
219
|
-
<div className="relative aspect-[16/10] w-64 shrink-0 overflow-hidden rounded-
|
|
227
|
+
<div className="relative aspect-[16/10] w-64 shrink-0 overflow-hidden rounded-2xl xl:w-72">
|
|
220
228
|
<Image
|
|
221
229
|
src={imageUrl}
|
|
222
230
|
alt={
|
|
@@ -232,7 +240,7 @@ function ArticlePreview({
|
|
|
232
240
|
|
|
233
241
|
<div className="flex min-h-[180px] flex-1 flex-col justify-between space-y-4 xl:min-h-[200px]">
|
|
234
242
|
<div className="space-y-4">
|
|
235
|
-
<h2 className="group-hover:text-
|
|
243
|
+
<h2 className="group-hover:text-[var(--blog-accent)] text-2xl leading-tight font-bold text-balance transition-colors xl:text-3xl">
|
|
236
244
|
{article.title}
|
|
237
245
|
</h2>
|
|
238
246
|
|