@xexr/blog-platform 1.0.6 → 1.0.8
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/CHANGELOG.md +7 -0
- package/dist/app/blog/[slug]/page.d.ts +68 -0
- package/dist/app/blog/[slug]/page.tsx +224 -98
- package/dist/app/blog/page.tsx +83 -2
- package/dist/components/ArticleReadingProgress.tsx +208 -0
- package/dist/components/ArticleTopActions.tsx +179 -0
- package/dist/components/AuthorByline.tsx +569 -0
- package/dist/components/ExpandableArticleImage.tsx +79 -0
- package/dist/components/Pagination.tsx +236 -0
- package/dist/components/TopicArchivePages.tsx +204 -0
- package/dist/components/ui/badge.tsx +36 -0
- package/dist/components/ui/button.tsx +59 -0
- package/dist/components/ui/card.tsx +92 -0
- package/dist/components/ui/pagination.tsx +127 -0
- package/dist/components/ui/separator.tsx +29 -0
- package/dist/index.d.mts +88 -71
- package/dist/index.d.ts +88 -71
- package/dist/index.js +83 -8
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +83 -8
- package/dist/index.mjs.map +1 -1
- package/dist/lib/article-ui.ts +158 -0
- package/dist/lib/auth.ts +354 -0
- package/dist/lib/cors.ts +243 -0
- package/dist/lib/database.ts +277 -0
- package/dist/lib/init.ts +186 -0
- package/dist/lib/mdx.ts +73 -0
- package/dist/lib/metadata.ts +558 -0
- package/dist/lib/ratelimit.ts +224 -0
- package/dist/lib/security-headers.ts +322 -0
- package/dist/lib/seo.ts +295 -0
- package/dist/lib/sitemap.ts +719 -0
- package/dist/lib/storage/drizzle.ts +400 -0
- package/dist/lib/structured-data.ts +608 -0
- package/dist/lib/topics.ts +56 -0
- package/dist/lib/urls.ts +29 -0
- package/dist/lib/utils.ts +6 -0
- package/dist/lib/version.ts +154 -0
- package/dist/types/contracts.ts +256 -0
- package/dist/types/index.d.mts +37 -1
- package/dist/types/index.d.ts +37 -1
- package/dist/types/index.ts +16 -0
- package/package.json +14 -9
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,12 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 1.0.8
|
|
4
|
+
|
|
5
|
+
### Patch Changes
|
|
6
|
+
|
|
7
|
+
- Add reading progress and active table-of-contents state to public article pages.
|
|
8
|
+
- Include route runtime dependencies in the package build output for subpath app imports.
|
|
9
|
+
|
|
3
10
|
## 1.0.1
|
|
4
11
|
|
|
5
12
|
### Patch Changes
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import type { ReactElement } from "react";
|
|
2
|
+
import type { PublishedArticle } from "../../../types/contracts";
|
|
3
|
+
import type { BlogTheme } from "../../../lib/article-ui";
|
|
4
|
+
|
|
5
|
+
interface ArticlePageProps {
|
|
6
|
+
article: PublishedArticle;
|
|
7
|
+
className?: string;
|
|
8
|
+
navigation?: {
|
|
9
|
+
backToHome?: {
|
|
10
|
+
url: string;
|
|
11
|
+
label?: string;
|
|
12
|
+
};
|
|
13
|
+
previousArticle?: {
|
|
14
|
+
url: string;
|
|
15
|
+
title: string;
|
|
16
|
+
featuredImage?: string;
|
|
17
|
+
featuredImageAlt?: string;
|
|
18
|
+
};
|
|
19
|
+
nextArticle?: {
|
|
20
|
+
url: string;
|
|
21
|
+
title: string;
|
|
22
|
+
featuredImage?: string;
|
|
23
|
+
featuredImageAlt?: string;
|
|
24
|
+
};
|
|
25
|
+
};
|
|
26
|
+
basePath?: string;
|
|
27
|
+
theme?: BlogTheme;
|
|
28
|
+
postArticleCta?: {
|
|
29
|
+
title: string;
|
|
30
|
+
description?: string;
|
|
31
|
+
href: string;
|
|
32
|
+
label: string;
|
|
33
|
+
};
|
|
34
|
+
relatedArticles?: PublishedArticle[];
|
|
35
|
+
showShareLinks?: boolean;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export default function ArticlePage(props: ArticlePageProps): ReactElement;
|
|
39
|
+
|
|
40
|
+
export function generateArticleMetadata(
|
|
41
|
+
article: PublishedArticle,
|
|
42
|
+
basePath?: string,
|
|
43
|
+
): {
|
|
44
|
+
title: string;
|
|
45
|
+
description: string;
|
|
46
|
+
keywords: string;
|
|
47
|
+
authors: { name: string }[] | undefined;
|
|
48
|
+
openGraph: {
|
|
49
|
+
title: string;
|
|
50
|
+
description: string;
|
|
51
|
+
type: string;
|
|
52
|
+
url: string;
|
|
53
|
+
images: { url: string; alt: string }[] | undefined;
|
|
54
|
+
publishedTime: string;
|
|
55
|
+
modifiedTime: string;
|
|
56
|
+
authors: string[] | undefined;
|
|
57
|
+
siteName: string;
|
|
58
|
+
};
|
|
59
|
+
twitter: {
|
|
60
|
+
card: string;
|
|
61
|
+
title: string;
|
|
62
|
+
description: string;
|
|
63
|
+
images: string[] | undefined;
|
|
64
|
+
};
|
|
65
|
+
alternates: {
|
|
66
|
+
canonical: string;
|
|
67
|
+
};
|
|
68
|
+
};
|
|
@@ -2,12 +2,18 @@ import React from "react";
|
|
|
2
2
|
import Image from "next/image";
|
|
3
3
|
import Link from "next/link";
|
|
4
4
|
import { MDXRemote } from "next-mdx-remote-client/rsc";
|
|
5
|
-
import {
|
|
5
|
+
import {
|
|
6
|
+
type PublishedArticle,
|
|
7
|
+
type PublishedAuthor,
|
|
8
|
+
} from "../../../types/contracts";
|
|
6
9
|
import { cn } from "../../../lib/utils";
|
|
7
|
-
import { ArrowLeft, ArrowUpRight,
|
|
10
|
+
import { ArrowLeft, ArrowUpRight, Globe, Mail } from "lucide-react";
|
|
11
|
+
import { FaGithub, FaLinkedinIn, FaXTwitter } from "react-icons/fa6";
|
|
8
12
|
import { Badge } from "../../../components/ui/badge";
|
|
9
13
|
import { Card, CardContent } from "../../../components/ui/card";
|
|
14
|
+
import ArticleTopActions from "../../../components/ArticleTopActions";
|
|
10
15
|
import { ExpandableArticleImage } from "../../../components/ExpandableArticleImage";
|
|
16
|
+
import { ArticleReadingProgress } from "../../../components/ArticleReadingProgress";
|
|
11
17
|
import { Separator } from "../../../components/ui/separator";
|
|
12
18
|
import {
|
|
13
19
|
generateCompleteArticleStructuredData,
|
|
@@ -87,11 +93,15 @@ export default function ArticlePage({
|
|
|
87
93
|
showShareLinks = true,
|
|
88
94
|
}: ArticlePageProps) {
|
|
89
95
|
const normalizedBasePath = normalizeBlogBasePath(basePath) || "/";
|
|
90
|
-
const dateMeta = formatArticleDateMeta(
|
|
96
|
+
const dateMeta = formatArticleDateMeta(
|
|
97
|
+
article.publishedAt,
|
|
98
|
+
article.updatedAt,
|
|
99
|
+
);
|
|
91
100
|
const readingTime = formatReadingTime(article.readingSeconds);
|
|
92
101
|
const tocItems = extractTableOfContents(article.content);
|
|
93
102
|
const articleUrl =
|
|
94
|
-
article.meta.canonicalUrl ??
|
|
103
|
+
article.meta.canonicalUrl ??
|
|
104
|
+
buildBlogPath(normalizedBasePath, article.slug);
|
|
95
105
|
|
|
96
106
|
// Use the featured image URL provided by Bloggy (hosted on Bloggy's domain)
|
|
97
107
|
const imageUrl = article.featuredImage;
|
|
@@ -109,7 +119,7 @@ export default function ArticlePage({
|
|
|
109
119
|
<header className="mx-auto mb-8 max-w-4xl sm:mb-12 lg:mb-16">
|
|
110
120
|
<Link
|
|
111
121
|
href={navigation.backToHome.url}
|
|
112
|
-
className="text-muted-foreground
|
|
122
|
+
className="text-muted-foreground group focus-visible:ring-ring inline-flex touch-manipulation items-center space-x-2 rounded-md px-2 py-1 transition-colors hover:text-[var(--blog-accent)] focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-none sm:space-x-3"
|
|
113
123
|
aria-label={navigation.backToHome.label ?? "Return to blog"}
|
|
114
124
|
>
|
|
115
125
|
<ArrowLeft
|
|
@@ -145,6 +155,7 @@ export default function ArticlePage({
|
|
|
145
155
|
{article.author && (
|
|
146
156
|
<div className="flex items-center gap-2">
|
|
147
157
|
<span className="sr-only">Author</span>
|
|
158
|
+
<AuthorAvatar author={article.author} size="md" />
|
|
148
159
|
<span className="font-medium">{article.author.name}</span>
|
|
149
160
|
{article.author.isGuest && (
|
|
150
161
|
<Badge variant="outline" className="text-xs">
|
|
@@ -163,7 +174,9 @@ export default function ArticlePage({
|
|
|
163
174
|
Published {dateMeta.publishedDateLabel}
|
|
164
175
|
</time>
|
|
165
176
|
<span className="text-border">•</span>
|
|
166
|
-
<span className="font-medium">
|
|
177
|
+
<span className="font-medium">
|
|
178
|
+
{dateMeta.publishedRelativeLabel}
|
|
179
|
+
</span>
|
|
167
180
|
<span className="text-border">•</span>
|
|
168
181
|
<span className="font-medium">{readingTime}</span>
|
|
169
182
|
</div>
|
|
@@ -174,14 +187,42 @@ export default function ArticlePage({
|
|
|
174
187
|
</time>
|
|
175
188
|
)}
|
|
176
189
|
|
|
190
|
+
{showShareLinks && (
|
|
191
|
+
<ArticleTopActions
|
|
192
|
+
title={article.title}
|
|
193
|
+
url={articleUrl}
|
|
194
|
+
size="sm"
|
|
195
|
+
/>
|
|
196
|
+
)}
|
|
197
|
+
|
|
177
198
|
<div className="bg-border h-px w-6 sm:w-8" aria-hidden="true"></div>
|
|
178
199
|
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
200
|
+
{(article.topics ?? []).length > 0 ? (
|
|
201
|
+
<nav
|
|
202
|
+
aria-label="Article topics"
|
|
203
|
+
className="flex flex-wrap justify-center gap-2"
|
|
204
|
+
>
|
|
205
|
+
{(article.topics ?? []).map((topic) => (
|
|
206
|
+
<Link
|
|
207
|
+
key={topic.id}
|
|
208
|
+
href={buildBlogPath(
|
|
209
|
+
(navigation?.backToHome?.url ?? "/blog").replace(/\/$/, ""),
|
|
210
|
+
`topics/${topic.slug}`,
|
|
211
|
+
)}
|
|
212
|
+
className="rounded-full border border-[var(--blog-accent-border)] bg-[var(--blog-accent-bg)] px-3 py-1 text-xs font-medium text-[var(--blog-text-accent)] transition-colors hover:border-[var(--blog-text-accent)] hover:text-[var(--blog-text-accent-hover)]"
|
|
213
|
+
>
|
|
214
|
+
{topic.name}
|
|
215
|
+
</Link>
|
|
216
|
+
))}
|
|
217
|
+
</nav>
|
|
218
|
+
) : (
|
|
219
|
+
<span
|
|
220
|
+
className="text-xs tracking-wider uppercase sm:text-sm"
|
|
221
|
+
role="text"
|
|
222
|
+
>
|
|
223
|
+
From {article.site.name}
|
|
224
|
+
</span>
|
|
225
|
+
)}
|
|
185
226
|
</div>
|
|
186
227
|
</header>
|
|
187
228
|
|
|
@@ -207,9 +248,16 @@ export default function ArticlePage({
|
|
|
207
248
|
className="article-content mx-auto grid max-w-6xl grid-cols-1 gap-8 lg:grid-cols-[minmax(0,1fr)_14rem] lg:gap-12"
|
|
208
249
|
>
|
|
209
250
|
<div className="min-w-0">
|
|
210
|
-
<
|
|
251
|
+
<ArticleReadingProgress
|
|
252
|
+
items={tocItems}
|
|
253
|
+
contentId="article-body"
|
|
254
|
+
variant="mobile"
|
|
255
|
+
/>
|
|
211
256
|
|
|
212
|
-
<div
|
|
257
|
+
<div
|
|
258
|
+
id="article-body"
|
|
259
|
+
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"
|
|
260
|
+
>
|
|
213
261
|
<MDXRemote
|
|
214
262
|
source={article.content}
|
|
215
263
|
options={articleMdxOptions}
|
|
@@ -218,34 +266,39 @@ export default function ArticlePage({
|
|
|
218
266
|
</div>
|
|
219
267
|
</div>
|
|
220
268
|
|
|
221
|
-
<
|
|
269
|
+
<ArticleReadingProgress
|
|
270
|
+
items={tocItems}
|
|
271
|
+
contentId="article-body"
|
|
272
|
+
variant="desktop"
|
|
273
|
+
showProgress={false}
|
|
274
|
+
/>
|
|
222
275
|
</div>
|
|
223
276
|
|
|
224
277
|
{/* Author Bio */}
|
|
225
278
|
{article.author?.bio && (
|
|
226
|
-
<div className="author-bio mx-auto mt-12 max-w-4xl">
|
|
279
|
+
<div id="about-author" className="author-bio mx-auto mt-12 max-w-4xl">
|
|
227
280
|
<Separator className="mb-8" />
|
|
228
281
|
<Card className="border-[var(--blog-accent-border)] bg-[var(--blog-accent-bg)]">
|
|
229
282
|
<CardContent className="flex flex-col gap-4 p-5 sm:flex-row sm:items-start sm:p-6">
|
|
230
|
-
<div
|
|
231
|
-
|
|
232
|
-
aria-hidden="true"
|
|
233
|
-
>
|
|
234
|
-
{article.author.name.slice(0, 1)}
|
|
283
|
+
<div className="shrink-0">
|
|
284
|
+
<AuthorAvatar author={article.author} size="lg" />
|
|
235
285
|
</div>
|
|
236
|
-
<div className="min-w-0 space-y-2">
|
|
237
|
-
<div>
|
|
238
|
-
<
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
286
|
+
<div className="min-w-0 flex-1 space-y-2">
|
|
287
|
+
<div className="flex flex-col gap-2 sm:flex-row sm:items-start sm:justify-between">
|
|
288
|
+
<div className="min-w-0">
|
|
289
|
+
<p className="text-muted-foreground text-sm font-medium">
|
|
290
|
+
Written by
|
|
291
|
+
</p>
|
|
292
|
+
<h2 className="text-foreground flex flex-wrap items-center gap-2 text-xl font-semibold">
|
|
293
|
+
{article.author.name}
|
|
294
|
+
{article.author.isGuest && (
|
|
295
|
+
<Badge variant="outline" className="text-xs">
|
|
296
|
+
Guest Author
|
|
297
|
+
</Badge>
|
|
298
|
+
)}
|
|
299
|
+
</h2>
|
|
300
|
+
</div>
|
|
301
|
+
<AuthorSocialLinks author={article.author} />
|
|
249
302
|
</div>
|
|
250
303
|
<p className="text-muted-foreground leading-relaxed">
|
|
251
304
|
{article.author.bio}
|
|
@@ -281,7 +334,7 @@ export default function ArticlePage({
|
|
|
281
334
|
<Separator className="mb-8" />
|
|
282
335
|
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
|
|
283
336
|
{navigation?.previousArticle && (
|
|
284
|
-
<Card className="group hover:border-[var(--blog-accent-border)] hover:bg-[var(--blog-accent-bg)]
|
|
337
|
+
<Card className="group cursor-pointer transition-colors hover:border-[var(--blog-accent-border)] hover:bg-[var(--blog-accent-bg)]">
|
|
285
338
|
<Link href={navigation.previousArticle.url} className="block">
|
|
286
339
|
{navigation.previousArticle.featuredImage && (
|
|
287
340
|
<ArticleCardImage
|
|
@@ -296,7 +349,7 @@ export default function ArticlePage({
|
|
|
296
349
|
<div className="text-muted-foreground mb-2 text-sm font-medium">
|
|
297
350
|
← Previous Article
|
|
298
351
|
</div>
|
|
299
|
-
<div className="text-foreground group-hover:text-[var(--blog-text-accent)]
|
|
352
|
+
<div className="text-foreground transition-colors group-hover:text-[var(--blog-text-accent)]">
|
|
300
353
|
{navigation.previousArticle.title}
|
|
301
354
|
</div>
|
|
302
355
|
</CardContent>
|
|
@@ -306,7 +359,7 @@ export default function ArticlePage({
|
|
|
306
359
|
{navigation?.nextArticle && (
|
|
307
360
|
<Card
|
|
308
361
|
className={cn(
|
|
309
|
-
"group hover:border-[var(--blog-accent-border)] hover:bg-[var(--blog-accent-bg)]
|
|
362
|
+
"group cursor-pointer transition-colors hover:border-[var(--blog-accent-border)] hover:bg-[var(--blog-accent-bg)]",
|
|
310
363
|
!navigation?.previousArticle && "md:col-start-2",
|
|
311
364
|
)}
|
|
312
365
|
>
|
|
@@ -324,7 +377,7 @@ export default function ArticlePage({
|
|
|
324
377
|
<div className="text-muted-foreground mb-2 text-sm font-medium">
|
|
325
378
|
Next Article →
|
|
326
379
|
</div>
|
|
327
|
-
<div className="text-foreground group-hover:text-[var(--blog-text-accent)]
|
|
380
|
+
<div className="text-foreground transition-colors group-hover:text-[var(--blog-text-accent)]">
|
|
328
381
|
{navigation.nextArticle.title}
|
|
329
382
|
</div>
|
|
330
383
|
</CardContent>
|
|
@@ -355,61 +408,141 @@ export default function ArticlePage({
|
|
|
355
408
|
);
|
|
356
409
|
}
|
|
357
410
|
|
|
358
|
-
function
|
|
359
|
-
|
|
360
|
-
|
|
411
|
+
function AuthorAvatar({
|
|
412
|
+
author,
|
|
413
|
+
size,
|
|
361
414
|
}: {
|
|
362
|
-
|
|
363
|
-
|
|
415
|
+
author: PublishedAuthor;
|
|
416
|
+
size: "md" | "lg";
|
|
364
417
|
}) {
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
}
|
|
368
|
-
|
|
369
|
-
const list = (
|
|
370
|
-
<ul className="space-y-2 text-sm">
|
|
371
|
-
{items.map((item) => (
|
|
372
|
-
<li key={item.id} className={item.level === 3 ? "pl-4" : undefined}>
|
|
373
|
-
<a
|
|
374
|
-
href={`#${item.id}`}
|
|
375
|
-
className="text-muted-foreground hover:text-[var(--blog-text-accent)] block leading-snug transition-colors"
|
|
376
|
-
>
|
|
377
|
-
{item.text}
|
|
378
|
-
</a>
|
|
379
|
-
</li>
|
|
380
|
-
))}
|
|
381
|
-
</ul>
|
|
382
|
-
);
|
|
418
|
+
const sizeClass = size === "lg" ? "h-12 w-12 text-base" : "h-9 w-9 text-sm";
|
|
419
|
+
const initials = getAuthorInitials(author.name);
|
|
383
420
|
|
|
384
|
-
if (
|
|
421
|
+
if (author.avatarUrl) {
|
|
385
422
|
return (
|
|
386
|
-
<
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
</nav>
|
|
423
|
+
<Image
|
|
424
|
+
src={author.avatarUrl}
|
|
425
|
+
alt={author.name}
|
|
426
|
+
width={size === "lg" ? 48 : 36}
|
|
427
|
+
height={size === "lg" ? 48 : 36}
|
|
428
|
+
className={cn("shrink-0 rounded-full object-cover", sizeClass)}
|
|
429
|
+
loading="lazy"
|
|
430
|
+
/>
|
|
395
431
|
);
|
|
396
432
|
}
|
|
397
433
|
|
|
398
434
|
return (
|
|
399
|
-
<
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
435
|
+
<span
|
|
436
|
+
className={cn(
|
|
437
|
+
"bg-muted text-muted-foreground inline-flex shrink-0 items-center justify-center rounded-full font-medium",
|
|
438
|
+
sizeClass,
|
|
439
|
+
)}
|
|
440
|
+
aria-hidden="true"
|
|
441
|
+
>
|
|
442
|
+
{initials}
|
|
443
|
+
</span>
|
|
444
|
+
);
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
interface AuthorSocialLinkView {
|
|
448
|
+
key: string;
|
|
449
|
+
href: string;
|
|
450
|
+
label: string;
|
|
451
|
+
external: boolean;
|
|
452
|
+
icon: React.ReactNode;
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
function AuthorSocialLinks({ author }: { author: PublishedAuthor }) {
|
|
456
|
+
const links = getAuthorSocialLinks(author);
|
|
457
|
+
if (links.length === 0) return null;
|
|
458
|
+
|
|
459
|
+
return (
|
|
460
|
+
<div className="flex flex-wrap items-center gap-1 sm:justify-end">
|
|
461
|
+
{links.map((link) => (
|
|
462
|
+
<a
|
|
463
|
+
key={link.key}
|
|
464
|
+
href={link.href}
|
|
465
|
+
target={link.external ? "_blank" : undefined}
|
|
466
|
+
rel={link.external ? "noopener noreferrer" : undefined}
|
|
467
|
+
aria-label={`${author.name} ${link.label}`}
|
|
468
|
+
className="text-muted-foreground hover:bg-background/60 hover:text-foreground inline-flex size-7 items-center justify-center rounded-full transition-colors"
|
|
469
|
+
>
|
|
470
|
+
{link.icon}
|
|
471
|
+
</a>
|
|
472
|
+
))}
|
|
473
|
+
</div>
|
|
474
|
+
);
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
function getAuthorSocialLinks(author: PublishedAuthor): AuthorSocialLinkView[] {
|
|
478
|
+
const links = author.socialLinks;
|
|
479
|
+
if (!links) return [];
|
|
480
|
+
|
|
481
|
+
const socialLinks: Array<AuthorSocialLinkView | false | undefined> = [
|
|
482
|
+
links.website
|
|
483
|
+
? {
|
|
484
|
+
key: "website",
|
|
485
|
+
href: links.website,
|
|
486
|
+
label: "website",
|
|
487
|
+
external: true,
|
|
488
|
+
icon: <Globe className="size-4" aria-hidden="true" />,
|
|
489
|
+
}
|
|
490
|
+
: undefined,
|
|
491
|
+
links.x
|
|
492
|
+
? {
|
|
493
|
+
key: "x",
|
|
494
|
+
href: links.x,
|
|
495
|
+
label: "on X",
|
|
496
|
+
external: true,
|
|
497
|
+
icon: <FaXTwitter className="size-4" aria-hidden="true" />,
|
|
498
|
+
}
|
|
499
|
+
: undefined,
|
|
500
|
+
links.linkedin
|
|
501
|
+
? {
|
|
502
|
+
key: "linkedin",
|
|
503
|
+
href: links.linkedin,
|
|
504
|
+
label: "on LinkedIn",
|
|
505
|
+
external: true,
|
|
506
|
+
icon: <FaLinkedinIn className="size-4" aria-hidden="true" />,
|
|
507
|
+
}
|
|
508
|
+
: undefined,
|
|
509
|
+
links.github
|
|
510
|
+
? {
|
|
511
|
+
key: "github",
|
|
512
|
+
href: links.github,
|
|
513
|
+
label: "on GitHub",
|
|
514
|
+
external: true,
|
|
515
|
+
icon: <FaGithub className="size-4" aria-hidden="true" />,
|
|
516
|
+
}
|
|
517
|
+
: undefined,
|
|
518
|
+
links.email
|
|
519
|
+
? {
|
|
520
|
+
key: "email",
|
|
521
|
+
href: `mailto:${links.email}`,
|
|
522
|
+
label: "email",
|
|
523
|
+
external: false,
|
|
524
|
+
icon: <Mail className="size-4" aria-hidden="true" />,
|
|
525
|
+
}
|
|
526
|
+
: undefined,
|
|
527
|
+
];
|
|
528
|
+
|
|
529
|
+
return socialLinks.filter((link): link is AuthorSocialLinkView =>
|
|
530
|
+
Boolean(link),
|
|
410
531
|
);
|
|
411
532
|
}
|
|
412
533
|
|
|
534
|
+
function getAuthorInitials(name: string): string {
|
|
535
|
+
const initials = name
|
|
536
|
+
.trim()
|
|
537
|
+
.split(/\s+/)
|
|
538
|
+
.filter(Boolean)
|
|
539
|
+
.slice(0, 2)
|
|
540
|
+
.map((part) => part[0]?.toUpperCase())
|
|
541
|
+
.join("");
|
|
542
|
+
|
|
543
|
+
return initials || "?";
|
|
544
|
+
}
|
|
545
|
+
|
|
413
546
|
function PostArticleCta({
|
|
414
547
|
cta,
|
|
415
548
|
}: {
|
|
@@ -444,36 +577,29 @@ function ArticleShareLinks({ title, url }: { title: string; url: string }) {
|
|
|
444
577
|
|
|
445
578
|
return (
|
|
446
579
|
<div className="flex flex-wrap items-center justify-center gap-2 sm:gap-3">
|
|
447
|
-
<
|
|
448
|
-
<Share2 className="size-4" aria-hidden="true" />
|
|
449
|
-
Share
|
|
450
|
-
</span>
|
|
580
|
+
<ArticleTopActions title={title} url={url} size="sm" />
|
|
451
581
|
<a
|
|
452
582
|
href={`https://x.com/intent/tweet?text=${encodedTitle}&url=${encodedUrl}`}
|
|
453
583
|
target="_blank"
|
|
454
584
|
rel="noopener noreferrer"
|
|
455
585
|
aria-label="Share on X"
|
|
456
|
-
className="text-muted-foreground hover:border-
|
|
586
|
+
className="text-muted-foreground hover:text-foreground border-border inline-flex size-9 items-center justify-center rounded-full border grayscale transition-colors hover:border-[var(--blog-accent-border)] hover:bg-[var(--blog-accent-bg)]"
|
|
457
587
|
>
|
|
458
|
-
<
|
|
459
|
-
X
|
|
460
|
-
</span>
|
|
588
|
+
<FaXTwitter className="size-4" aria-hidden="true" />
|
|
461
589
|
</a>
|
|
462
590
|
<a
|
|
463
591
|
href={`https://www.linkedin.com/sharing/share-offsite/?url=${encodedUrl}`}
|
|
464
592
|
target="_blank"
|
|
465
593
|
rel="noopener noreferrer"
|
|
466
594
|
aria-label="Share on LinkedIn"
|
|
467
|
-
className="text-muted-foreground hover:border-
|
|
595
|
+
className="text-muted-foreground hover:text-foreground border-border inline-flex size-9 items-center justify-center rounded-full border grayscale transition-colors hover:border-[var(--blog-accent-border)] hover:bg-[var(--blog-accent-bg)]"
|
|
468
596
|
>
|
|
469
|
-
<
|
|
470
|
-
in
|
|
471
|
-
</span>
|
|
597
|
+
<FaLinkedinIn className="size-4" aria-hidden="true" />
|
|
472
598
|
</a>
|
|
473
599
|
<a
|
|
474
600
|
href={`mailto:?subject=${encodedTitle}&body=${encodedUrl}`}
|
|
475
601
|
aria-label="Share by email"
|
|
476
|
-
className="text-muted-foreground hover:border-
|
|
602
|
+
className="text-muted-foreground hover:text-foreground border-border inline-flex size-9 items-center justify-center rounded-full border grayscale transition-colors hover:border-[var(--blog-accent-border)] hover:bg-[var(--blog-accent-bg)]"
|
|
477
603
|
>
|
|
478
604
|
<Mail className="size-4" aria-hidden="true" />
|
|
479
605
|
</a>
|
|
@@ -498,7 +624,7 @@ function RelatedArticles({
|
|
|
498
624
|
<Link
|
|
499
625
|
key={article.id}
|
|
500
626
|
href={buildBlogPath(basePath, article.slug)}
|
|
501
|
-
className="group overflow-hidden rounded-2xl border
|
|
627
|
+
className="group border-border overflow-hidden rounded-2xl border transition-colors hover:border-[var(--blog-accent-border)] hover:bg-[var(--blog-accent-bg)] hover:no-underline"
|
|
502
628
|
>
|
|
503
629
|
{article.featuredImage && (
|
|
504
630
|
<ArticleCardImage
|
|
@@ -510,7 +636,7 @@ function RelatedArticles({
|
|
|
510
636
|
/>
|
|
511
637
|
)}
|
|
512
638
|
<div className="p-4">
|
|
513
|
-
<h3 className="text-foreground group-hover:text-[var(--blog-text-accent)]
|
|
639
|
+
<h3 className="text-foreground line-clamp-3 font-semibold transition-colors group-hover:text-[var(--blog-text-accent)]">
|
|
514
640
|
{article.title}
|
|
515
641
|
</h3>
|
|
516
642
|
{article.excerpt && (
|
|
@@ -528,7 +654,7 @@ function RelatedArticles({
|
|
|
528
654
|
|
|
529
655
|
function ArticleCardImage({ src, alt }: { src: string; alt: string }) {
|
|
530
656
|
return (
|
|
531
|
-
<div className="aspect-[16/9] overflow-hidden border-b
|
|
657
|
+
<div className="border-border aspect-[16/9] overflow-hidden border-b bg-[var(--blog-accent-bg)]">
|
|
532
658
|
<Image
|
|
533
659
|
src={src}
|
|
534
660
|
alt={alt}
|