@xexr/blog-platform 1.0.7 → 1.0.9
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/README.md +110 -29
- package/dist/app/blog/[slug]/page.d.ts +68 -0
- package/dist/app/blog/[slug]/page.tsx +175 -116
- package/dist/app/blog/authors/[slug]/page.test.ts +108 -0
- package/dist/app/blog/authors/[slug]/page.tsx +387 -0
- package/dist/app/blog/page.tsx +138 -113
- package/dist/components/ArticleReadingProgress.tsx +208 -0
- package/dist/components/ArticleTopActions.tsx +179 -0
- package/dist/components/AuthorByline.tsx +569 -0
- package/dist/components/AuthorPages.test.ts +87 -0
- package/dist/components/AuthorPages.tsx +129 -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 +174 -112
- package/dist/index.d.ts +174 -112
- 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/authors.ts +100 -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 +738 -0
- package/dist/lib/storage/drizzle.ts +400 -0
- package/dist/lib/structured-data.ts +609 -0
- package/dist/lib/topics.ts +56 -0
- package/dist/lib/urls.ts +36 -0
- package/dist/lib/utils.ts +6 -0
- package/dist/lib/version.ts +154 -0
- package/dist/types/contracts.ts +259 -0
- package/dist/types/index.d.mts +20 -1
- package/dist/types/index.d.ts +20 -1
- package/dist/types/index.ts +16 -0
- package/package.json +14 -9
|
@@ -0,0 +1,387 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
import Image from "next/image";
|
|
3
|
+
import Link from "next/link";
|
|
4
|
+
import { ArrowLeft, Globe, Mail } from "lucide-react";
|
|
5
|
+
import { formatDistanceToNow } from "date-fns";
|
|
6
|
+
|
|
7
|
+
import Pagination from "../../../../components/Pagination";
|
|
8
|
+
import { Badge } from "../../../../components/ui/badge";
|
|
9
|
+
import { Separator } from "../../../../components/ui/separator";
|
|
10
|
+
import {
|
|
11
|
+
createStructuredDataScript,
|
|
12
|
+
generateAuthorStructuredData,
|
|
13
|
+
} from "../../../../lib/structured-data";
|
|
14
|
+
import { cn } from "../../../../lib/utils";
|
|
15
|
+
import {
|
|
16
|
+
buildBlogPath,
|
|
17
|
+
buildAuthorPath,
|
|
18
|
+
normalizeBlogBasePath,
|
|
19
|
+
} from "../../../../lib/urls";
|
|
20
|
+
import { type BlogTheme, getBlogThemeStyle } from "../../../../lib/article-ui";
|
|
21
|
+
import type {
|
|
22
|
+
PublishedArticle,
|
|
23
|
+
PublishedAuthor,
|
|
24
|
+
PublishedSite,
|
|
25
|
+
} from "../../../../types/contracts";
|
|
26
|
+
|
|
27
|
+
interface AuthorPageProps {
|
|
28
|
+
author: PublishedAuthor;
|
|
29
|
+
articles: PublishedArticle[];
|
|
30
|
+
site: PublishedSite;
|
|
31
|
+
className?: string;
|
|
32
|
+
basePath?: string;
|
|
33
|
+
theme?: BlogTheme;
|
|
34
|
+
pagination?: {
|
|
35
|
+
currentPage: number;
|
|
36
|
+
totalPages: number;
|
|
37
|
+
baseUrl?: string;
|
|
38
|
+
totalItems?: number;
|
|
39
|
+
};
|
|
40
|
+
navigation?: {
|
|
41
|
+
backToHome?: {
|
|
42
|
+
url: string;
|
|
43
|
+
label?: string;
|
|
44
|
+
};
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export default function AuthorPage({
|
|
49
|
+
author,
|
|
50
|
+
articles,
|
|
51
|
+
site,
|
|
52
|
+
className,
|
|
53
|
+
basePath = "/blog",
|
|
54
|
+
theme,
|
|
55
|
+
pagination,
|
|
56
|
+
navigation,
|
|
57
|
+
}: AuthorPageProps) {
|
|
58
|
+
const normalizedBasePath = normalizeBlogBasePath(basePath) || "/";
|
|
59
|
+
const backUrl = navigation?.backToHome?.url ?? normalizedBasePath;
|
|
60
|
+
const authorUrl = buildAuthorPath(normalizedBasePath, author.slug);
|
|
61
|
+
const totalArticleCount = pagination?.totalItems ?? articles.length;
|
|
62
|
+
const structuredDataProps = createStructuredDataScript(
|
|
63
|
+
generateAuthorStructuredData(author, {
|
|
64
|
+
pathPrefix: normalizedBasePath,
|
|
65
|
+
}),
|
|
66
|
+
);
|
|
67
|
+
|
|
68
|
+
return (
|
|
69
|
+
<main
|
|
70
|
+
style={getBlogThemeStyle(theme)}
|
|
71
|
+
className={cn(
|
|
72
|
+
"author-page mx-auto max-w-5xl px-4 py-8 sm:px-6 sm:py-12 lg:px-8 lg:py-16",
|
|
73
|
+
className,
|
|
74
|
+
)}
|
|
75
|
+
>
|
|
76
|
+
<header className="mb-10 space-y-8 sm:mb-14">
|
|
77
|
+
<Link
|
|
78
|
+
href={backUrl}
|
|
79
|
+
className="text-muted-foreground group focus-visible:ring-ring inline-flex items-center gap-2 rounded-md px-2 py-1 text-sm font-medium transition-colors hover:text-[var(--blog-accent)] focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-none"
|
|
80
|
+
>
|
|
81
|
+
<ArrowLeft
|
|
82
|
+
className="size-4 transition-transform group-hover:-translate-x-1"
|
|
83
|
+
aria-hidden="true"
|
|
84
|
+
/>
|
|
85
|
+
{navigation?.backToHome?.label ?? "Back to all articles"}
|
|
86
|
+
</Link>
|
|
87
|
+
|
|
88
|
+
<div className="grid gap-6 sm:grid-cols-[auto_minmax(0,1fr)] sm:items-center">
|
|
89
|
+
<AuthorAvatar author={author} />
|
|
90
|
+
<div className="min-w-0 space-y-4">
|
|
91
|
+
<div className="space-y-2">
|
|
92
|
+
<div className="flex flex-wrap items-center gap-2">
|
|
93
|
+
{author.isGuest && (
|
|
94
|
+
<Badge variant="outline" className="text-xs">
|
|
95
|
+
Guest Author
|
|
96
|
+
</Badge>
|
|
97
|
+
)}
|
|
98
|
+
</div>
|
|
99
|
+
<h1 className="text-foreground text-4xl leading-tight font-semibold text-balance sm:text-5xl">
|
|
100
|
+
{author.name}
|
|
101
|
+
</h1>
|
|
102
|
+
</div>
|
|
103
|
+
|
|
104
|
+
{author.bio && (
|
|
105
|
+
<p className="text-muted-foreground max-w-3xl text-lg leading-relaxed text-pretty">
|
|
106
|
+
{author.bio}
|
|
107
|
+
</p>
|
|
108
|
+
)}
|
|
109
|
+
|
|
110
|
+
<AuthorSocialLinks author={author} />
|
|
111
|
+
</div>
|
|
112
|
+
</div>
|
|
113
|
+
</header>
|
|
114
|
+
|
|
115
|
+
<Separator className="mb-8" />
|
|
116
|
+
|
|
117
|
+
<section aria-labelledby="author-articles-title" className="space-y-6">
|
|
118
|
+
<div className="flex flex-wrap items-end justify-between gap-3">
|
|
119
|
+
<div>
|
|
120
|
+
<h2
|
|
121
|
+
id="author-articles-title"
|
|
122
|
+
className="text-foreground text-2xl font-semibold"
|
|
123
|
+
>
|
|
124
|
+
Articles by {author.name}
|
|
125
|
+
</h2>
|
|
126
|
+
<p className="text-muted-foreground mt-1 text-sm">
|
|
127
|
+
{totalArticleCount}{" "}
|
|
128
|
+
{totalArticleCount === 1 ? "article" : "articles"} on {site.name}
|
|
129
|
+
</p>
|
|
130
|
+
</div>
|
|
131
|
+
</div>
|
|
132
|
+
|
|
133
|
+
{articles.length === 0 ? (
|
|
134
|
+
<p className="text-muted-foreground border-border rounded-2xl border p-6">
|
|
135
|
+
This author does not have any published articles yet.
|
|
136
|
+
</p>
|
|
137
|
+
) : (
|
|
138
|
+
<div className="divide-border divide-y">
|
|
139
|
+
{articles.map((article) => (
|
|
140
|
+
<AuthorArticlePreview
|
|
141
|
+
key={article.id}
|
|
142
|
+
article={article}
|
|
143
|
+
basePath={normalizedBasePath}
|
|
144
|
+
/>
|
|
145
|
+
))}
|
|
146
|
+
</div>
|
|
147
|
+
)}
|
|
148
|
+
|
|
149
|
+
{pagination && pagination.totalPages > 1 && (
|
|
150
|
+
<div className="pt-4">
|
|
151
|
+
<Pagination
|
|
152
|
+
currentPage={pagination.currentPage}
|
|
153
|
+
totalPages={pagination.totalPages}
|
|
154
|
+
baseUrl={pagination.baseUrl ?? authorUrl}
|
|
155
|
+
className="justify-center"
|
|
156
|
+
/>
|
|
157
|
+
</div>
|
|
158
|
+
)}
|
|
159
|
+
</section>
|
|
160
|
+
|
|
161
|
+
<script
|
|
162
|
+
type={structuredDataProps.type}
|
|
163
|
+
dangerouslySetInnerHTML={structuredDataProps.dangerouslySetInnerHTML}
|
|
164
|
+
/>
|
|
165
|
+
</main>
|
|
166
|
+
);
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
function AuthorSocialLinks({ author }: { author: PublishedAuthor }) {
|
|
170
|
+
const links = getAuthorSocialLinks(author);
|
|
171
|
+
|
|
172
|
+
if (links.length === 0) {
|
|
173
|
+
return null;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
return (
|
|
177
|
+
<div
|
|
178
|
+
className="flex flex-wrap items-center gap-2"
|
|
179
|
+
aria-label="Author links"
|
|
180
|
+
>
|
|
181
|
+
{links.map((link) => (
|
|
182
|
+
<a
|
|
183
|
+
key={link.label}
|
|
184
|
+
href={link.href}
|
|
185
|
+
target={link.external ? "_blank" : undefined}
|
|
186
|
+
rel={link.external ? "noopener noreferrer" : undefined}
|
|
187
|
+
aria-label={link.ariaLabel}
|
|
188
|
+
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)]"
|
|
189
|
+
>
|
|
190
|
+
{link.icon}
|
|
191
|
+
</a>
|
|
192
|
+
))}
|
|
193
|
+
</div>
|
|
194
|
+
);
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
function getAuthorSocialLinks(author: PublishedAuthor) {
|
|
198
|
+
const links = author.socialLinks;
|
|
199
|
+
|
|
200
|
+
if (!links) {
|
|
201
|
+
return [];
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
return [
|
|
205
|
+
links.website
|
|
206
|
+
? {
|
|
207
|
+
href: links.website,
|
|
208
|
+
label: "website",
|
|
209
|
+
ariaLabel: `${author.name}'s website`,
|
|
210
|
+
external: true,
|
|
211
|
+
icon: <Globe className="size-4" aria-hidden="true" />,
|
|
212
|
+
}
|
|
213
|
+
: null,
|
|
214
|
+
(links.x ?? links.twitter)
|
|
215
|
+
? {
|
|
216
|
+
href: links.x ?? links.twitter ?? "",
|
|
217
|
+
label: "x",
|
|
218
|
+
ariaLabel: `${author.name} on X`,
|
|
219
|
+
external: true,
|
|
220
|
+
icon: (
|
|
221
|
+
<span className="text-sm font-semibold" aria-hidden="true">
|
|
222
|
+
X
|
|
223
|
+
</span>
|
|
224
|
+
),
|
|
225
|
+
}
|
|
226
|
+
: null,
|
|
227
|
+
links.linkedin
|
|
228
|
+
? {
|
|
229
|
+
href: links.linkedin,
|
|
230
|
+
label: "linkedin",
|
|
231
|
+
ariaLabel: `${author.name} on LinkedIn`,
|
|
232
|
+
external: true,
|
|
233
|
+
icon: (
|
|
234
|
+
<span className="text-sm font-bold" aria-hidden="true">
|
|
235
|
+
in
|
|
236
|
+
</span>
|
|
237
|
+
),
|
|
238
|
+
}
|
|
239
|
+
: null,
|
|
240
|
+
links.github
|
|
241
|
+
? {
|
|
242
|
+
href: links.github,
|
|
243
|
+
label: "github",
|
|
244
|
+
ariaLabel: `${author.name} on GitHub`,
|
|
245
|
+
external: true,
|
|
246
|
+
icon: (
|
|
247
|
+
<span className="text-xs font-bold" aria-hidden="true">
|
|
248
|
+
GH
|
|
249
|
+
</span>
|
|
250
|
+
),
|
|
251
|
+
}
|
|
252
|
+
: null,
|
|
253
|
+
links.email
|
|
254
|
+
? {
|
|
255
|
+
href: `mailto:${links.email}`,
|
|
256
|
+
label: "email",
|
|
257
|
+
ariaLabel: `Email ${author.name}`,
|
|
258
|
+
external: false,
|
|
259
|
+
icon: <Mail className="size-4" aria-hidden="true" />,
|
|
260
|
+
}
|
|
261
|
+
: null,
|
|
262
|
+
].filter((link): link is NonNullable<typeof link> => link !== null);
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
function AuthorArticlePreview({
|
|
266
|
+
article,
|
|
267
|
+
basePath,
|
|
268
|
+
}: {
|
|
269
|
+
article: PublishedArticle;
|
|
270
|
+
basePath: string;
|
|
271
|
+
}) {
|
|
272
|
+
const publishedRelativeLabel = formatDistanceToNow(
|
|
273
|
+
new Date(article.publishedAt),
|
|
274
|
+
{ addSuffix: true },
|
|
275
|
+
);
|
|
276
|
+
|
|
277
|
+
return (
|
|
278
|
+
<article className="py-6 first:pt-0 last:pb-0">
|
|
279
|
+
<Link
|
|
280
|
+
href={buildBlogPath(basePath, article.slug)}
|
|
281
|
+
className="group grid gap-4 hover:no-underline sm:grid-cols-[minmax(0,1fr)_12rem] sm:items-start"
|
|
282
|
+
>
|
|
283
|
+
<div className="min-w-0 space-y-3">
|
|
284
|
+
<h3 className="text-xl leading-tight font-semibold text-balance transition-colors group-hover:text-[var(--blog-text-accent)]">
|
|
285
|
+
{article.title}
|
|
286
|
+
</h3>
|
|
287
|
+
{article.excerpt && (
|
|
288
|
+
<p className="text-muted-foreground line-clamp-3 leading-relaxed">
|
|
289
|
+
{article.excerpt}
|
|
290
|
+
</p>
|
|
291
|
+
)}
|
|
292
|
+
<div className="text-muted-foreground flex flex-wrap items-center gap-x-3 gap-y-1 text-sm">
|
|
293
|
+
<time dateTime={article.publishedAt}>{publishedRelativeLabel}</time>
|
|
294
|
+
<span aria-hidden="true">•</span>
|
|
295
|
+
<span>{Math.ceil(article.readingSeconds / 60)} min read</span>
|
|
296
|
+
</div>
|
|
297
|
+
</div>
|
|
298
|
+
|
|
299
|
+
{article.featuredImage && (
|
|
300
|
+
<div className="border-border relative aspect-[16/10] overflow-hidden rounded-xl border bg-[var(--blog-accent-bg)]">
|
|
301
|
+
<Image
|
|
302
|
+
src={article.featuredImage}
|
|
303
|
+
alt={
|
|
304
|
+
article.featuredImageAlt ??
|
|
305
|
+
`Featured image for ${article.title}`
|
|
306
|
+
}
|
|
307
|
+
width={320}
|
|
308
|
+
height={200}
|
|
309
|
+
className="h-full w-full object-cover transition-transform duration-500 group-hover:scale-105"
|
|
310
|
+
/>
|
|
311
|
+
</div>
|
|
312
|
+
)}
|
|
313
|
+
</Link>
|
|
314
|
+
</article>
|
|
315
|
+
);
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
function AuthorAvatar({ author }: { author: PublishedAuthor }) {
|
|
319
|
+
if (author.avatarUrl) {
|
|
320
|
+
return (
|
|
321
|
+
<Image
|
|
322
|
+
src={author.avatarUrl}
|
|
323
|
+
alt={author.name}
|
|
324
|
+
width={128}
|
|
325
|
+
height={128}
|
|
326
|
+
className="size-28 rounded-full border border-[var(--blog-accent-border)] object-cover sm:size-32"
|
|
327
|
+
priority
|
|
328
|
+
/>
|
|
329
|
+
);
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
return (
|
|
333
|
+
<span className="bg-muted text-muted-foreground inline-flex size-28 items-center justify-center rounded-full text-3xl font-semibold sm:size-32">
|
|
334
|
+
{getAuthorInitials(author.name)}
|
|
335
|
+
</span>
|
|
336
|
+
);
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
function getAuthorInitials(name: string): string {
|
|
340
|
+
const initials = name
|
|
341
|
+
.trim()
|
|
342
|
+
.split(/\s+/)
|
|
343
|
+
.filter(Boolean)
|
|
344
|
+
.slice(0, 2)
|
|
345
|
+
.map((part) => part[0]?.toUpperCase())
|
|
346
|
+
.join("");
|
|
347
|
+
|
|
348
|
+
return initials || "?";
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
export function generateAuthorPageMetadata(
|
|
352
|
+
author: PublishedAuthor,
|
|
353
|
+
site: PublishedSite,
|
|
354
|
+
basePath = "/blog",
|
|
355
|
+
) {
|
|
356
|
+
const canonicalUrl = `https://${site.domain
|
|
357
|
+
.replace(/^https?:\/\//, "")
|
|
358
|
+
.replace(/\/+$/, "")}${buildAuthorPath(basePath, author.slug)}`;
|
|
359
|
+
const title = `${author.name} - ${site.name}`;
|
|
360
|
+
const description =
|
|
361
|
+
author.bio ?? `Read articles by ${author.name} on ${site.name}.`;
|
|
362
|
+
|
|
363
|
+
return {
|
|
364
|
+
title,
|
|
365
|
+
description,
|
|
366
|
+
authors: [{ name: author.name }],
|
|
367
|
+
openGraph: {
|
|
368
|
+
title,
|
|
369
|
+
description,
|
|
370
|
+
type: "profile",
|
|
371
|
+
url: canonicalUrl,
|
|
372
|
+
images: author.avatarUrl
|
|
373
|
+
? [{ url: author.avatarUrl, alt: author.name }]
|
|
374
|
+
: undefined,
|
|
375
|
+
siteName: site.name,
|
|
376
|
+
},
|
|
377
|
+
twitter: {
|
|
378
|
+
card: author.avatarUrl ? "summary_large_image" : "summary",
|
|
379
|
+
title,
|
|
380
|
+
description,
|
|
381
|
+
images: author.avatarUrl ? [author.avatarUrl] : undefined,
|
|
382
|
+
},
|
|
383
|
+
alternates: {
|
|
384
|
+
canonical: canonicalUrl,
|
|
385
|
+
},
|
|
386
|
+
};
|
|
387
|
+
}
|
package/dist/app/blog/page.tsx
CHANGED
|
@@ -12,12 +12,13 @@ import {
|
|
|
12
12
|
generateBlogHomeStructuredData,
|
|
13
13
|
createStructuredDataScript,
|
|
14
14
|
} from "../../lib/structured-data";
|
|
15
|
-
import { buildBlogPath, normalizeBlogBasePath } from "../../lib/urls";
|
|
16
|
-
import { formatDistanceToNow } from "date-fns";
|
|
17
15
|
import {
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
16
|
+
buildAuthorPath,
|
|
17
|
+
buildBlogPath,
|
|
18
|
+
normalizeBlogBasePath,
|
|
19
|
+
} from "../../lib/urls";
|
|
20
|
+
import { formatDistanceToNow } from "date-fns";
|
|
21
|
+
import { type BlogTheme, getBlogThemeStyle } from "../../lib/article-ui";
|
|
21
22
|
|
|
22
23
|
interface BlogHomePageProps {
|
|
23
24
|
articles: PublishedArticle[];
|
|
@@ -169,56 +170,110 @@ function ArticlePreview({
|
|
|
169
170
|
|
|
170
171
|
return (
|
|
171
172
|
<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">
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
<
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
)}
|
|
173
|
+
{/* Mobile/Small Tablet: Vertical layout */}
|
|
174
|
+
<div className="space-y-4 lg:hidden">
|
|
175
|
+
{imageUrl && (
|
|
176
|
+
<Link
|
|
177
|
+
href={articleUrl}
|
|
178
|
+
className="focus-visible:ring-ring relative block aspect-[16/10] w-full overflow-hidden rounded-2xl focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-none"
|
|
179
|
+
aria-label={`Read article: ${article.title}`}
|
|
180
|
+
>
|
|
181
|
+
<Image
|
|
182
|
+
src={imageUrl}
|
|
183
|
+
alt={
|
|
184
|
+
article.featuredImageAlt ??
|
|
185
|
+
`Featured image for ${article.title}`
|
|
186
|
+
}
|
|
187
|
+
width={400}
|
|
188
|
+
height={250}
|
|
189
|
+
className="h-full w-full object-cover transition-transform duration-300 group-hover:scale-105"
|
|
190
|
+
/>
|
|
191
|
+
</Link>
|
|
192
|
+
)}
|
|
193
193
|
|
|
194
|
-
|
|
195
|
-
|
|
194
|
+
<div className="space-y-3">
|
|
195
|
+
<Link
|
|
196
|
+
href={articleUrl}
|
|
197
|
+
className="focus-visible:ring-ring block rounded-md hover:no-underline focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-none"
|
|
198
|
+
>
|
|
199
|
+
<h2 className="text-lg leading-tight font-bold text-balance transition-colors group-hover:text-[var(--blog-accent)] sm:text-xl md:text-2xl">
|
|
196
200
|
{article.title}
|
|
197
201
|
</h2>
|
|
202
|
+
</Link>
|
|
198
203
|
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
204
|
+
<div className="text-muted-foreground flex flex-wrap items-center gap-x-3 gap-y-1 text-sm">
|
|
205
|
+
{article.author && (
|
|
206
|
+
<>
|
|
207
|
+
<AuthorLink author={article.author} basePath={basePath} />
|
|
208
|
+
<span className="text-border">•</span>
|
|
209
|
+
</>
|
|
210
|
+
)}
|
|
211
|
+
<time
|
|
212
|
+
dateTime={article.publishedAt}
|
|
213
|
+
className="font-medium"
|
|
214
|
+
aria-label={`Published ${formattedDate}`}
|
|
215
|
+
>
|
|
216
|
+
{formattedDate}
|
|
217
|
+
</time>
|
|
218
|
+
<span className="text-border">•</span>
|
|
219
|
+
<span className="font-medium">{readingTime}</span>
|
|
220
|
+
</div>
|
|
221
|
+
|
|
222
|
+
{article.excerpt && (
|
|
223
|
+
<p className="text-muted-foreground line-clamp-3 text-sm leading-relaxed sm:text-base">
|
|
224
|
+
{article.excerpt}
|
|
225
|
+
</p>
|
|
226
|
+
)}
|
|
227
|
+
|
|
228
|
+
{(article.topics ?? []).length > 0 && (
|
|
229
|
+
<div className="flex flex-wrap gap-2">
|
|
230
|
+
{(article.topics ?? []).map((topic) => (
|
|
231
|
+
<span
|
|
232
|
+
key={topic.id}
|
|
233
|
+
className="rounded-full border border-[var(--blog-accent-border)] px-2.5 py-1 text-xs font-medium text-[var(--blog-text-accent)]"
|
|
234
|
+
>
|
|
235
|
+
{topic.name}
|
|
236
|
+
</span>
|
|
237
|
+
))}
|
|
218
238
|
</div>
|
|
239
|
+
)}
|
|
240
|
+
</div>
|
|
241
|
+
</div>
|
|
242
|
+
|
|
243
|
+
{/* Large Tablet/Desktop: Horizontal layout */}
|
|
244
|
+
<div className="hidden items-start gap-6 lg:flex xl:gap-8">
|
|
245
|
+
{imageUrl && (
|
|
246
|
+
<Link
|
|
247
|
+
href={articleUrl}
|
|
248
|
+
className="focus-visible:ring-ring relative block aspect-[16/10] w-64 shrink-0 overflow-hidden rounded-2xl focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-none xl:w-72"
|
|
249
|
+
aria-label={`Read article: ${article.title}`}
|
|
250
|
+
>
|
|
251
|
+
<Image
|
|
252
|
+
src={imageUrl}
|
|
253
|
+
alt={
|
|
254
|
+
article.featuredImageAlt ??
|
|
255
|
+
`Featured image for ${article.title}`
|
|
256
|
+
}
|
|
257
|
+
width={288}
|
|
258
|
+
height={180}
|
|
259
|
+
className="h-full w-full object-cover transition-transform duration-300 group-hover:scale-105"
|
|
260
|
+
/>
|
|
261
|
+
</Link>
|
|
262
|
+
)}
|
|
263
|
+
|
|
264
|
+
<div className="flex min-h-[180px] flex-1 flex-col justify-between space-y-4 xl:min-h-[200px]">
|
|
265
|
+
<div className="space-y-4">
|
|
266
|
+
<Link
|
|
267
|
+
href={articleUrl}
|
|
268
|
+
className="focus-visible:ring-ring block rounded-md hover:no-underline focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-none"
|
|
269
|
+
>
|
|
270
|
+
<h2 className="text-2xl leading-tight font-bold text-balance transition-colors group-hover:text-[var(--blog-accent)] xl:text-3xl">
|
|
271
|
+
{article.title}
|
|
272
|
+
</h2>
|
|
273
|
+
</Link>
|
|
219
274
|
|
|
220
275
|
{article.excerpt && (
|
|
221
|
-
<p className="text-muted-foreground line-clamp-3 text-
|
|
276
|
+
<p className="text-muted-foreground line-clamp-3 text-base leading-relaxed xl:text-lg">
|
|
222
277
|
{article.excerpt}
|
|
223
278
|
</p>
|
|
224
279
|
)}
|
|
@@ -236,78 +291,48 @@ function ArticlePreview({
|
|
|
236
291
|
</div>
|
|
237
292
|
)}
|
|
238
293
|
</div>
|
|
239
|
-
</div>
|
|
240
294
|
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
<div className="flex min-h-[180px] flex-1 flex-col justify-between space-y-4 xl:min-h-[200px]">
|
|
259
|
-
<div className="space-y-4">
|
|
260
|
-
<h2 className="group-hover:text-[var(--blog-accent)] text-2xl leading-tight font-bold text-balance transition-colors xl:text-3xl">
|
|
261
|
-
{article.title}
|
|
262
|
-
</h2>
|
|
263
|
-
|
|
264
|
-
{article.excerpt && (
|
|
265
|
-
<p className="text-muted-foreground line-clamp-3 text-base leading-relaxed xl:text-lg">
|
|
266
|
-
{article.excerpt}
|
|
267
|
-
</p>
|
|
268
|
-
)}
|
|
269
|
-
|
|
270
|
-
{(article.topics ?? []).length > 0 && (
|
|
271
|
-
<div className="flex flex-wrap gap-2">
|
|
272
|
-
{(article.topics ?? []).map((topic) => (
|
|
273
|
-
<span
|
|
274
|
-
key={topic.id}
|
|
275
|
-
className="rounded-full border border-[var(--blog-accent-border)] px-2.5 py-1 text-xs font-medium text-[var(--blog-text-accent)]"
|
|
276
|
-
>
|
|
277
|
-
{topic.name}
|
|
278
|
-
</span>
|
|
279
|
-
))}
|
|
280
|
-
</div>
|
|
281
|
-
)}
|
|
282
|
-
</div>
|
|
283
|
-
|
|
284
|
-
<div className="text-muted-foreground flex flex-wrap items-center gap-x-3 gap-y-1 text-sm">
|
|
285
|
-
{article.author && (
|
|
286
|
-
<>
|
|
287
|
-
<span className="inline-flex items-center gap-2 font-medium">
|
|
288
|
-
<AuthorAvatar author={article.author} size="sm" />
|
|
289
|
-
{article.author.name}
|
|
290
|
-
</span>
|
|
291
|
-
<span className="text-border">•</span>
|
|
292
|
-
</>
|
|
293
|
-
)}
|
|
294
|
-
<time
|
|
295
|
-
dateTime={article.publishedAt}
|
|
296
|
-
className="font-medium"
|
|
297
|
-
aria-label={`Published ${formattedDate}`}
|
|
298
|
-
>
|
|
299
|
-
{formattedDate}
|
|
300
|
-
</time>
|
|
301
|
-
<span className="text-border">•</span>
|
|
302
|
-
<span className="font-medium">{readingTime}</span>
|
|
303
|
-
</div>
|
|
295
|
+
<div className="text-muted-foreground flex flex-wrap items-center gap-x-3 gap-y-1 text-sm">
|
|
296
|
+
{article.author && (
|
|
297
|
+
<>
|
|
298
|
+
<AuthorLink author={article.author} basePath={basePath} />
|
|
299
|
+
<span className="text-border">•</span>
|
|
300
|
+
</>
|
|
301
|
+
)}
|
|
302
|
+
<time
|
|
303
|
+
dateTime={article.publishedAt}
|
|
304
|
+
className="font-medium"
|
|
305
|
+
aria-label={`Published ${formattedDate}`}
|
|
306
|
+
>
|
|
307
|
+
{formattedDate}
|
|
308
|
+
</time>
|
|
309
|
+
<span className="text-border">•</span>
|
|
310
|
+
<span className="font-medium">{readingTime}</span>
|
|
304
311
|
</div>
|
|
305
312
|
</div>
|
|
306
|
-
</
|
|
313
|
+
</div>
|
|
307
314
|
</article>
|
|
308
315
|
);
|
|
309
316
|
}
|
|
310
317
|
|
|
318
|
+
function AuthorLink({
|
|
319
|
+
author,
|
|
320
|
+
basePath,
|
|
321
|
+
}: {
|
|
322
|
+
author: PublishedAuthor;
|
|
323
|
+
basePath: string;
|
|
324
|
+
}) {
|
|
325
|
+
return (
|
|
326
|
+
<Link
|
|
327
|
+
href={buildAuthorPath(basePath, author.slug)}
|
|
328
|
+
className="inline-flex items-center gap-2 font-medium transition-colors hover:text-[var(--blog-accent)] hover:no-underline"
|
|
329
|
+
>
|
|
330
|
+
<AuthorAvatar author={author} size="sm" />
|
|
331
|
+
{author.name}
|
|
332
|
+
</Link>
|
|
333
|
+
);
|
|
334
|
+
}
|
|
335
|
+
|
|
311
336
|
function AuthorAvatar({
|
|
312
337
|
author,
|
|
313
338
|
size,
|