@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
|
@@ -0,0 +1,569 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
import Image from "next/image";
|
|
3
|
+
import { Globe, Mail } from "lucide-react";
|
|
4
|
+
import { FaGithub, FaLinkedinIn, FaXTwitter } from "react-icons/fa6";
|
|
5
|
+
import { type PublishedAuthor } from "../types/contracts";
|
|
6
|
+
import { cn } from "../lib/utils";
|
|
7
|
+
import { Badge } from "./ui/badge";
|
|
8
|
+
import {
|
|
9
|
+
generateAuthorStructuredData,
|
|
10
|
+
createStructuredDataScript,
|
|
11
|
+
} from "../lib/structured-data";
|
|
12
|
+
|
|
13
|
+
interface AuthorSocialLinkView {
|
|
14
|
+
key: string;
|
|
15
|
+
href: string;
|
|
16
|
+
label: string;
|
|
17
|
+
external: boolean;
|
|
18
|
+
icon: React.ReactNode;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
interface AuthorBylineProps {
|
|
22
|
+
/** The author to display */
|
|
23
|
+
author: PublishedAuthor | null;
|
|
24
|
+
/** Optional publication date */
|
|
25
|
+
publishedAt?: string;
|
|
26
|
+
/** Optional reading time in minutes */
|
|
27
|
+
readingTime?: number;
|
|
28
|
+
/** Optional word count */
|
|
29
|
+
wordCount?: number;
|
|
30
|
+
/** Whether to show the full bio */
|
|
31
|
+
showBio?: boolean;
|
|
32
|
+
/** Whether to include JSON-LD structured data */
|
|
33
|
+
includeStructuredData?: boolean;
|
|
34
|
+
/** Custom className for styling */
|
|
35
|
+
className?: string;
|
|
36
|
+
/** Layout variant */
|
|
37
|
+
variant?: "inline" | "stacked" | "minimal";
|
|
38
|
+
/** Size variant */
|
|
39
|
+
size?: "sm" | "md" | "lg";
|
|
40
|
+
/** Whether to show tone adjectives */
|
|
41
|
+
showToneAdjectives?: boolean;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* AuthorByline component for displaying author information with optional structured data.
|
|
46
|
+
*
|
|
47
|
+
* This component provides a consistent way to display author information across
|
|
48
|
+
* different contexts (article listings, article pages, etc.) with proper
|
|
49
|
+
* semantic markup and SEO optimization through JSON-LD structured data.
|
|
50
|
+
*/
|
|
51
|
+
export default function AuthorByline({
|
|
52
|
+
author,
|
|
53
|
+
publishedAt,
|
|
54
|
+
readingTime,
|
|
55
|
+
wordCount,
|
|
56
|
+
showBio = false,
|
|
57
|
+
includeStructuredData = false,
|
|
58
|
+
className,
|
|
59
|
+
variant = "inline",
|
|
60
|
+
size = "md",
|
|
61
|
+
showToneAdjectives = false,
|
|
62
|
+
}: AuthorBylineProps) {
|
|
63
|
+
// If no author, show minimal byline
|
|
64
|
+
if (!author) {
|
|
65
|
+
return (
|
|
66
|
+
<div
|
|
67
|
+
className={cn(
|
|
68
|
+
"author-byline text-muted-foreground",
|
|
69
|
+
getVariantStyles(variant, size),
|
|
70
|
+
className,
|
|
71
|
+
)}
|
|
72
|
+
>
|
|
73
|
+
{publishedAt && (
|
|
74
|
+
<time dateTime={publishedAt} className="published-date">
|
|
75
|
+
{formatDate(publishedAt)}
|
|
76
|
+
</time>
|
|
77
|
+
)}
|
|
78
|
+
{readingTime && (
|
|
79
|
+
<span className="reading-time">{readingTime} min read</span>
|
|
80
|
+
)}
|
|
81
|
+
{wordCount && (
|
|
82
|
+
<span className="word-count">{wordCount.toLocaleString()} words</span>
|
|
83
|
+
)}
|
|
84
|
+
</div>
|
|
85
|
+
);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const structuredDataProps = includeStructuredData
|
|
89
|
+
? createStructuredDataScript(generateAuthorStructuredData(author))
|
|
90
|
+
: null;
|
|
91
|
+
|
|
92
|
+
return (
|
|
93
|
+
<>
|
|
94
|
+
<div
|
|
95
|
+
className={cn(
|
|
96
|
+
"author-byline",
|
|
97
|
+
getVariantStyles(variant, size),
|
|
98
|
+
className,
|
|
99
|
+
)}
|
|
100
|
+
>
|
|
101
|
+
{variant === "stacked" ? (
|
|
102
|
+
<StackedByline
|
|
103
|
+
author={author}
|
|
104
|
+
publishedAt={publishedAt}
|
|
105
|
+
readingTime={readingTime}
|
|
106
|
+
wordCount={wordCount}
|
|
107
|
+
showBio={showBio}
|
|
108
|
+
showToneAdjectives={showToneAdjectives}
|
|
109
|
+
size={size}
|
|
110
|
+
/>
|
|
111
|
+
) : variant === "minimal" ? (
|
|
112
|
+
<MinimalByline
|
|
113
|
+
author={author}
|
|
114
|
+
publishedAt={publishedAt}
|
|
115
|
+
readingTime={readingTime}
|
|
116
|
+
wordCount={wordCount}
|
|
117
|
+
size={size}
|
|
118
|
+
/>
|
|
119
|
+
) : (
|
|
120
|
+
<InlineByline
|
|
121
|
+
author={author}
|
|
122
|
+
publishedAt={publishedAt}
|
|
123
|
+
readingTime={readingTime}
|
|
124
|
+
wordCount={wordCount}
|
|
125
|
+
showBio={showBio}
|
|
126
|
+
showToneAdjectives={showToneAdjectives}
|
|
127
|
+
size={size}
|
|
128
|
+
/>
|
|
129
|
+
)}
|
|
130
|
+
</div>
|
|
131
|
+
|
|
132
|
+
{/* JSON-LD Structured Data */}
|
|
133
|
+
{includeStructuredData && structuredDataProps && (
|
|
134
|
+
<script
|
|
135
|
+
type={structuredDataProps.type}
|
|
136
|
+
dangerouslySetInnerHTML={structuredDataProps.dangerouslySetInnerHTML}
|
|
137
|
+
/>
|
|
138
|
+
)}
|
|
139
|
+
</>
|
|
140
|
+
);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* Inline layout (default) - author info in a single line
|
|
145
|
+
*/
|
|
146
|
+
function InlineByline({
|
|
147
|
+
author,
|
|
148
|
+
publishedAt,
|
|
149
|
+
readingTime,
|
|
150
|
+
wordCount,
|
|
151
|
+
showBio,
|
|
152
|
+
showToneAdjectives,
|
|
153
|
+
size,
|
|
154
|
+
}: {
|
|
155
|
+
author: PublishedAuthor;
|
|
156
|
+
publishedAt?: string;
|
|
157
|
+
readingTime?: number;
|
|
158
|
+
wordCount?: number;
|
|
159
|
+
showBio: boolean;
|
|
160
|
+
showToneAdjectives: boolean;
|
|
161
|
+
size: "sm" | "md" | "lg";
|
|
162
|
+
}) {
|
|
163
|
+
const textSize = getTextSizeClass(size);
|
|
164
|
+
|
|
165
|
+
return (
|
|
166
|
+
<div className="inline-byline">
|
|
167
|
+
{/* Author info line */}
|
|
168
|
+
<div
|
|
169
|
+
className={cn(
|
|
170
|
+
"flex flex-wrap items-center gap-3",
|
|
171
|
+
textSize,
|
|
172
|
+
"text-muted-foreground",
|
|
173
|
+
)}
|
|
174
|
+
>
|
|
175
|
+
<div className="author-info flex items-center gap-2">
|
|
176
|
+
<AuthorAvatar author={author} size={size} />
|
|
177
|
+
<span className="author-prefix">By</span>
|
|
178
|
+
<span className="author-name text-foreground font-medium">
|
|
179
|
+
{author.name}
|
|
180
|
+
</span>
|
|
181
|
+
{author.isGuest && (
|
|
182
|
+
<Badge
|
|
183
|
+
variant="secondary"
|
|
184
|
+
className={cn(size === "sm" && "px-1.5 py-0.5 text-xs")}
|
|
185
|
+
>
|
|
186
|
+
Guest
|
|
187
|
+
</Badge>
|
|
188
|
+
)}
|
|
189
|
+
</div>
|
|
190
|
+
|
|
191
|
+
<AuthorSocialLinks author={author} />
|
|
192
|
+
|
|
193
|
+
{publishedAt && (
|
|
194
|
+
<time dateTime={publishedAt} className="published-date">
|
|
195
|
+
{formatDate(publishedAt)}
|
|
196
|
+
</time>
|
|
197
|
+
)}
|
|
198
|
+
|
|
199
|
+
{readingTime && (
|
|
200
|
+
<span className="reading-time">{readingTime} min read</span>
|
|
201
|
+
)}
|
|
202
|
+
|
|
203
|
+
{wordCount && (
|
|
204
|
+
<span className="word-count">{wordCount.toLocaleString()} words</span>
|
|
205
|
+
)}
|
|
206
|
+
</div>
|
|
207
|
+
|
|
208
|
+
{/* Optional bio */}
|
|
209
|
+
{showBio && author.bio && (
|
|
210
|
+
<div className={cn("author-bio mt-2", textSize)}>
|
|
211
|
+
<p className="text-muted-foreground">{author.bio}</p>
|
|
212
|
+
</div>
|
|
213
|
+
)}
|
|
214
|
+
|
|
215
|
+
{/* Optional tone adjectives */}
|
|
216
|
+
{showToneAdjectives && author.toneAdjectives.length > 0 && (
|
|
217
|
+
<div
|
|
218
|
+
className={cn(
|
|
219
|
+
"author-tone mt-1",
|
|
220
|
+
size === "sm" ? "text-xs" : "text-sm",
|
|
221
|
+
)}
|
|
222
|
+
>
|
|
223
|
+
<span className="text-muted-foreground">
|
|
224
|
+
Writing style: {author.toneAdjectives.join(", ")}
|
|
225
|
+
</span>
|
|
226
|
+
</div>
|
|
227
|
+
)}
|
|
228
|
+
</div>
|
|
229
|
+
);
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
/**
|
|
233
|
+
* Stacked layout - author info in multiple lines for better readability
|
|
234
|
+
*/
|
|
235
|
+
function StackedByline({
|
|
236
|
+
author,
|
|
237
|
+
publishedAt,
|
|
238
|
+
readingTime,
|
|
239
|
+
wordCount,
|
|
240
|
+
showBio,
|
|
241
|
+
showToneAdjectives,
|
|
242
|
+
size,
|
|
243
|
+
}: {
|
|
244
|
+
author: PublishedAuthor;
|
|
245
|
+
publishedAt?: string;
|
|
246
|
+
readingTime?: number;
|
|
247
|
+
wordCount?: number;
|
|
248
|
+
showBio: boolean;
|
|
249
|
+
showToneAdjectives: boolean;
|
|
250
|
+
size: "sm" | "md" | "lg";
|
|
251
|
+
}) {
|
|
252
|
+
const textSize = getTextSizeClass(size);
|
|
253
|
+
|
|
254
|
+
return (
|
|
255
|
+
<div className="stacked-byline space-y-2">
|
|
256
|
+
{/* Author name and guest indicator */}
|
|
257
|
+
<div className="author-info flex items-center gap-2">
|
|
258
|
+
<AuthorAvatar author={author} size={size} />
|
|
259
|
+
<span
|
|
260
|
+
className={cn(
|
|
261
|
+
"author-name text-foreground font-medium",
|
|
262
|
+
size === "lg" ? "text-lg" : size === "md" ? "text-base" : "text-sm",
|
|
263
|
+
)}
|
|
264
|
+
>
|
|
265
|
+
{author.name}
|
|
266
|
+
</span>
|
|
267
|
+
{author.isGuest && (
|
|
268
|
+
<Badge
|
|
269
|
+
variant="secondary"
|
|
270
|
+
className={cn(size === "sm" && "px-1.5 py-0.5 text-xs")}
|
|
271
|
+
>
|
|
272
|
+
Guest Author
|
|
273
|
+
</Badge>
|
|
274
|
+
)}
|
|
275
|
+
<AuthorSocialLinks author={author} />
|
|
276
|
+
</div>
|
|
277
|
+
|
|
278
|
+
{/* Publication metadata */}
|
|
279
|
+
{(Boolean(publishedAt) || Boolean(readingTime) || Boolean(wordCount)) && (
|
|
280
|
+
<div
|
|
281
|
+
className={cn(
|
|
282
|
+
"metadata flex flex-wrap items-center gap-3",
|
|
283
|
+
textSize,
|
|
284
|
+
"text-muted-foreground",
|
|
285
|
+
)}
|
|
286
|
+
>
|
|
287
|
+
{publishedAt && (
|
|
288
|
+
<time dateTime={publishedAt} className="published-date">
|
|
289
|
+
{formatDate(publishedAt)}
|
|
290
|
+
</time>
|
|
291
|
+
)}
|
|
292
|
+
{readingTime && (
|
|
293
|
+
<span className="reading-time">{readingTime} min read</span>
|
|
294
|
+
)}
|
|
295
|
+
{wordCount && (
|
|
296
|
+
<span className="word-count">
|
|
297
|
+
{wordCount.toLocaleString()} words
|
|
298
|
+
</span>
|
|
299
|
+
)}
|
|
300
|
+
</div>
|
|
301
|
+
)}
|
|
302
|
+
|
|
303
|
+
{/* Optional bio */}
|
|
304
|
+
{showBio && author.bio && (
|
|
305
|
+
<div className={cn("author-bio", textSize)}>
|
|
306
|
+
<p className="text-muted-foreground">{author.bio}</p>
|
|
307
|
+
</div>
|
|
308
|
+
)}
|
|
309
|
+
|
|
310
|
+
{/* Optional tone adjectives */}
|
|
311
|
+
{showToneAdjectives && author.toneAdjectives.length > 0 && (
|
|
312
|
+
<div
|
|
313
|
+
className={cn("author-tone", size === "sm" ? "text-xs" : "text-sm")}
|
|
314
|
+
>
|
|
315
|
+
<span className="text-muted-foreground">
|
|
316
|
+
Writing style: {author.toneAdjectives.join(", ")}
|
|
317
|
+
</span>
|
|
318
|
+
</div>
|
|
319
|
+
)}
|
|
320
|
+
</div>
|
|
321
|
+
);
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
/**
|
|
325
|
+
* Minimal layout - just author name and essential info
|
|
326
|
+
*/
|
|
327
|
+
function MinimalByline({
|
|
328
|
+
author,
|
|
329
|
+
publishedAt,
|
|
330
|
+
readingTime,
|
|
331
|
+
wordCount: _wordCount,
|
|
332
|
+
size,
|
|
333
|
+
}: {
|
|
334
|
+
author: PublishedAuthor;
|
|
335
|
+
publishedAt?: string;
|
|
336
|
+
readingTime?: number;
|
|
337
|
+
wordCount?: number;
|
|
338
|
+
size: "sm" | "md" | "lg";
|
|
339
|
+
}) {
|
|
340
|
+
const textSize = getTextSizeClass(size);
|
|
341
|
+
|
|
342
|
+
return (
|
|
343
|
+
<div
|
|
344
|
+
className={cn(
|
|
345
|
+
"minimal-byline flex items-center gap-2",
|
|
346
|
+
textSize,
|
|
347
|
+
"text-muted-foreground",
|
|
348
|
+
)}
|
|
349
|
+
>
|
|
350
|
+
<AuthorAvatar author={author} size={size} />
|
|
351
|
+
<span className="author-name text-foreground font-medium">
|
|
352
|
+
{author.name}
|
|
353
|
+
</span>
|
|
354
|
+
{author.isGuest && (
|
|
355
|
+
<Badge
|
|
356
|
+
variant="outline"
|
|
357
|
+
className={cn(size === "sm" && "px-1.5 py-0.5 text-xs")}
|
|
358
|
+
>
|
|
359
|
+
Guest
|
|
360
|
+
</Badge>
|
|
361
|
+
)}
|
|
362
|
+
{publishedAt && (
|
|
363
|
+
<>
|
|
364
|
+
<span>•</span>
|
|
365
|
+
<time dateTime={publishedAt}>{formatDate(publishedAt, "short")}</time>
|
|
366
|
+
</>
|
|
367
|
+
)}
|
|
368
|
+
{readingTime && (
|
|
369
|
+
<>
|
|
370
|
+
<span>•</span>
|
|
371
|
+
<span>{readingTime} min</span>
|
|
372
|
+
</>
|
|
373
|
+
)}
|
|
374
|
+
</div>
|
|
375
|
+
);
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
function AuthorAvatar({
|
|
379
|
+
author,
|
|
380
|
+
size,
|
|
381
|
+
}: {
|
|
382
|
+
author: PublishedAuthor;
|
|
383
|
+
size: "sm" | "md" | "lg";
|
|
384
|
+
}) {
|
|
385
|
+
const sizeClass =
|
|
386
|
+
size === "lg"
|
|
387
|
+
? "h-12 w-12 text-base"
|
|
388
|
+
: size === "md"
|
|
389
|
+
? "h-9 w-9 text-sm"
|
|
390
|
+
: "h-7 w-7 text-xs";
|
|
391
|
+
const initials = getAuthorInitials(author.name);
|
|
392
|
+
|
|
393
|
+
if (author.avatarUrl) {
|
|
394
|
+
return (
|
|
395
|
+
<Image
|
|
396
|
+
src={author.avatarUrl}
|
|
397
|
+
alt={author.name}
|
|
398
|
+
width={size === "lg" ? 48 : size === "md" ? 36 : 28}
|
|
399
|
+
height={size === "lg" ? 48 : size === "md" ? 36 : 28}
|
|
400
|
+
className={cn(
|
|
401
|
+
"author-avatar shrink-0 rounded-full object-cover",
|
|
402
|
+
sizeClass,
|
|
403
|
+
)}
|
|
404
|
+
loading="lazy"
|
|
405
|
+
/>
|
|
406
|
+
);
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
return (
|
|
410
|
+
<span
|
|
411
|
+
className={cn(
|
|
412
|
+
"author-avatar-fallback bg-muted text-muted-foreground inline-flex shrink-0 items-center justify-center rounded-full font-medium",
|
|
413
|
+
sizeClass,
|
|
414
|
+
)}
|
|
415
|
+
aria-hidden="true"
|
|
416
|
+
>
|
|
417
|
+
{initials}
|
|
418
|
+
</span>
|
|
419
|
+
);
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
function AuthorSocialLinks({ author }: { author: PublishedAuthor }) {
|
|
423
|
+
const links = getAuthorSocialLinks(author);
|
|
424
|
+
if (links.length === 0) return null;
|
|
425
|
+
|
|
426
|
+
return (
|
|
427
|
+
<span className="author-social-links inline-flex items-center gap-1">
|
|
428
|
+
{links.map((link) => (
|
|
429
|
+
<a
|
|
430
|
+
key={link.key}
|
|
431
|
+
href={link.href}
|
|
432
|
+
target={link.external ? "_blank" : undefined}
|
|
433
|
+
rel={link.external ? "noopener noreferrer" : undefined}
|
|
434
|
+
aria-label={`${author.name} ${link.label}`}
|
|
435
|
+
className="text-muted-foreground hover:text-foreground hover:bg-muted inline-flex size-6 items-center justify-center rounded-full transition-colors"
|
|
436
|
+
>
|
|
437
|
+
{link.icon}
|
|
438
|
+
</a>
|
|
439
|
+
))}
|
|
440
|
+
</span>
|
|
441
|
+
);
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
function getAuthorSocialLinks(author: PublishedAuthor): AuthorSocialLinkView[] {
|
|
445
|
+
const links = author.socialLinks;
|
|
446
|
+
if (!links) return [];
|
|
447
|
+
|
|
448
|
+
const socialLinks: Array<AuthorSocialLinkView | false | undefined> = [
|
|
449
|
+
links.website
|
|
450
|
+
? {
|
|
451
|
+
key: "website",
|
|
452
|
+
href: links.website,
|
|
453
|
+
label: "website",
|
|
454
|
+
external: true,
|
|
455
|
+
icon: <Globe className="size-3.5" aria-hidden="true" />,
|
|
456
|
+
}
|
|
457
|
+
: undefined,
|
|
458
|
+
links.x
|
|
459
|
+
? {
|
|
460
|
+
key: "x",
|
|
461
|
+
href: links.x,
|
|
462
|
+
label: "on X",
|
|
463
|
+
external: true,
|
|
464
|
+
icon: <FaXTwitter className="size-3.5" aria-hidden="true" />,
|
|
465
|
+
}
|
|
466
|
+
: undefined,
|
|
467
|
+
links.linkedin
|
|
468
|
+
? {
|
|
469
|
+
key: "linkedin",
|
|
470
|
+
href: links.linkedin,
|
|
471
|
+
label: "on LinkedIn",
|
|
472
|
+
external: true,
|
|
473
|
+
icon: <FaLinkedinIn className="size-3.5" aria-hidden="true" />,
|
|
474
|
+
}
|
|
475
|
+
: undefined,
|
|
476
|
+
links.github
|
|
477
|
+
? {
|
|
478
|
+
key: "github",
|
|
479
|
+
href: links.github,
|
|
480
|
+
label: "on GitHub",
|
|
481
|
+
external: true,
|
|
482
|
+
icon: <FaGithub className="size-3.5" aria-hidden="true" />,
|
|
483
|
+
}
|
|
484
|
+
: undefined,
|
|
485
|
+
links.email
|
|
486
|
+
? {
|
|
487
|
+
key: "email",
|
|
488
|
+
href: `mailto:${links.email}`,
|
|
489
|
+
label: "email",
|
|
490
|
+
external: false,
|
|
491
|
+
icon: <Mail className="size-3.5" aria-hidden="true" />,
|
|
492
|
+
}
|
|
493
|
+
: undefined,
|
|
494
|
+
];
|
|
495
|
+
|
|
496
|
+
return socialLinks.filter((link): link is AuthorSocialLinkView =>
|
|
497
|
+
Boolean(link),
|
|
498
|
+
);
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
function getAuthorInitials(name: string): string {
|
|
502
|
+
const initials = name
|
|
503
|
+
.trim()
|
|
504
|
+
.split(/\s+/)
|
|
505
|
+
.filter(Boolean)
|
|
506
|
+
.slice(0, 2)
|
|
507
|
+
.map((part) => part[0]?.toUpperCase())
|
|
508
|
+
.join("");
|
|
509
|
+
|
|
510
|
+
return initials || "?";
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
/**
|
|
514
|
+
* Get variant-specific styles
|
|
515
|
+
*/
|
|
516
|
+
function getVariantStyles(
|
|
517
|
+
variant: "inline" | "stacked" | "minimal",
|
|
518
|
+
size: "sm" | "md" | "lg",
|
|
519
|
+
): string {
|
|
520
|
+
const baseStyles = "author-byline";
|
|
521
|
+
|
|
522
|
+
const sizeStyles = {
|
|
523
|
+
sm: "text-sm",
|
|
524
|
+
md: "text-base",
|
|
525
|
+
lg: "text-lg",
|
|
526
|
+
};
|
|
527
|
+
|
|
528
|
+
return cn(baseStyles, sizeStyles[size]);
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
/**
|
|
532
|
+
* Get text size class for content
|
|
533
|
+
*/
|
|
534
|
+
function getTextSizeClass(size: "sm" | "md" | "lg"): string {
|
|
535
|
+
switch (size) {
|
|
536
|
+
case "sm":
|
|
537
|
+
return "text-xs";
|
|
538
|
+
case "md":
|
|
539
|
+
return "text-sm";
|
|
540
|
+
case "lg":
|
|
541
|
+
return "text-base";
|
|
542
|
+
default:
|
|
543
|
+
return "text-sm";
|
|
544
|
+
}
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
/**
|
|
548
|
+
* Format date for display
|
|
549
|
+
*/
|
|
550
|
+
function formatDate(
|
|
551
|
+
dateString: string,
|
|
552
|
+
format: "long" | "short" = "long",
|
|
553
|
+
): string {
|
|
554
|
+
const date = new Date(dateString);
|
|
555
|
+
|
|
556
|
+
if (format === "short") {
|
|
557
|
+
return date.toLocaleDateString("en-US", {
|
|
558
|
+
month: "short",
|
|
559
|
+
day: "numeric",
|
|
560
|
+
year: "numeric",
|
|
561
|
+
});
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
return date.toLocaleDateString("en-US", {
|
|
565
|
+
month: "long",
|
|
566
|
+
day: "numeric",
|
|
567
|
+
year: "numeric",
|
|
568
|
+
});
|
|
569
|
+
}
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
/* eslint-disable @next/next/no-img-element */
|
|
2
|
+
|
|
3
|
+
import type React from "react";
|
|
4
|
+
|
|
5
|
+
import { cn } from "../lib/utils";
|
|
6
|
+
|
|
7
|
+
type ArticleImageProps = React.ImgHTMLAttributes<HTMLImageElement>;
|
|
8
|
+
|
|
9
|
+
export function ExpandableArticleImage({
|
|
10
|
+
alt = "",
|
|
11
|
+
className,
|
|
12
|
+
src,
|
|
13
|
+
...props
|
|
14
|
+
}: ArticleImageProps) {
|
|
15
|
+
if (typeof src !== "string" || src.length === 0) {
|
|
16
|
+
return <img alt={alt} className={className} {...props} />;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const previewId = `blog-image-preview-${hashImageSrc(src)}`;
|
|
20
|
+
|
|
21
|
+
return (
|
|
22
|
+
<>
|
|
23
|
+
<a
|
|
24
|
+
href={`#${previewId}`}
|
|
25
|
+
className="group mx-auto block cursor-zoom-in rounded-2xl focus-visible:ring-2 focus-visible:ring-[var(--blog-text-accent)] focus-visible:ring-offset-2 focus-visible:outline-none"
|
|
26
|
+
aria-label={alt ? `Expand image: ${alt}` : "Expand article image"}
|
|
27
|
+
>
|
|
28
|
+
<img
|
|
29
|
+
src={src}
|
|
30
|
+
alt={alt}
|
|
31
|
+
className={cn(
|
|
32
|
+
"mx-auto rounded-2xl border border-[var(--blog-accent-border)] shadow-[0_18px_60px_rgb(168_85_247_/_0.12)] transition-opacity group-hover:opacity-95",
|
|
33
|
+
className,
|
|
34
|
+
)}
|
|
35
|
+
{...props}
|
|
36
|
+
/>
|
|
37
|
+
</a>
|
|
38
|
+
|
|
39
|
+
<div
|
|
40
|
+
id={previewId}
|
|
41
|
+
className="invisible fixed inset-0 z-50 flex opacity-0 transition-opacity target:visible target:opacity-100"
|
|
42
|
+
role="dialog"
|
|
43
|
+
aria-modal="true"
|
|
44
|
+
aria-label={alt ? `Image preview: ${alt}` : "Image preview"}
|
|
45
|
+
>
|
|
46
|
+
<a
|
|
47
|
+
href="#article-content"
|
|
48
|
+
className="absolute inset-0 bg-black/85 backdrop-blur-sm"
|
|
49
|
+
aria-label="Close image preview"
|
|
50
|
+
/>
|
|
51
|
+
<div className="pointer-events-none relative z-10 flex min-h-screen w-full items-center justify-center p-4 sm:p-6">
|
|
52
|
+
<img
|
|
53
|
+
src={src}
|
|
54
|
+
alt={alt}
|
|
55
|
+
className="pointer-events-auto h-[88vh] w-[94vw] rounded-3xl object-contain shadow-2xl"
|
|
56
|
+
/>
|
|
57
|
+
</div>
|
|
58
|
+
<a
|
|
59
|
+
href="#article-content"
|
|
60
|
+
className="absolute top-4 right-4 z-20 inline-flex size-10 items-center justify-center rounded-full border border-white/20 bg-black/50 text-xl leading-none text-white no-underline transition-colors hover:bg-white/10 focus-visible:ring-2 focus-visible:ring-white focus-visible:outline-none"
|
|
61
|
+
aria-label="Close image preview"
|
|
62
|
+
>
|
|
63
|
+
×
|
|
64
|
+
</a>
|
|
65
|
+
</div>
|
|
66
|
+
</>
|
|
67
|
+
);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function hashImageSrc(src: string): string {
|
|
71
|
+
let hash = 0;
|
|
72
|
+
|
|
73
|
+
for (let index = 0; index < src.length; index += 1) {
|
|
74
|
+
hash = (hash << 5) - hash + src.charCodeAt(index);
|
|
75
|
+
hash |= 0;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
return Math.abs(hash).toString(36);
|
|
79
|
+
}
|