@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.
Files changed (49) hide show
  1. package/CHANGELOG.md +7 -0
  2. package/README.md +110 -29
  3. package/dist/app/blog/[slug]/page.d.ts +68 -0
  4. package/dist/app/blog/[slug]/page.tsx +175 -116
  5. package/dist/app/blog/authors/[slug]/page.test.ts +108 -0
  6. package/dist/app/blog/authors/[slug]/page.tsx +387 -0
  7. package/dist/app/blog/page.tsx +138 -113
  8. package/dist/components/ArticleReadingProgress.tsx +208 -0
  9. package/dist/components/ArticleTopActions.tsx +179 -0
  10. package/dist/components/AuthorByline.tsx +569 -0
  11. package/dist/components/AuthorPages.test.ts +87 -0
  12. package/dist/components/AuthorPages.tsx +129 -0
  13. package/dist/components/ExpandableArticleImage.tsx +79 -0
  14. package/dist/components/Pagination.tsx +236 -0
  15. package/dist/components/TopicArchivePages.tsx +204 -0
  16. package/dist/components/ui/badge.tsx +36 -0
  17. package/dist/components/ui/button.tsx +59 -0
  18. package/dist/components/ui/card.tsx +92 -0
  19. package/dist/components/ui/pagination.tsx +127 -0
  20. package/dist/components/ui/separator.tsx +29 -0
  21. package/dist/index.d.mts +174 -112
  22. package/dist/index.d.ts +174 -112
  23. package/dist/index.js +83 -8
  24. package/dist/index.js.map +1 -1
  25. package/dist/index.mjs +83 -8
  26. package/dist/index.mjs.map +1 -1
  27. package/dist/lib/article-ui.ts +158 -0
  28. package/dist/lib/auth.ts +354 -0
  29. package/dist/lib/authors.ts +100 -0
  30. package/dist/lib/cors.ts +243 -0
  31. package/dist/lib/database.ts +277 -0
  32. package/dist/lib/init.ts +186 -0
  33. package/dist/lib/mdx.ts +73 -0
  34. package/dist/lib/metadata.ts +558 -0
  35. package/dist/lib/ratelimit.ts +224 -0
  36. package/dist/lib/security-headers.ts +322 -0
  37. package/dist/lib/seo.ts +295 -0
  38. package/dist/lib/sitemap.ts +738 -0
  39. package/dist/lib/storage/drizzle.ts +400 -0
  40. package/dist/lib/structured-data.ts +609 -0
  41. package/dist/lib/topics.ts +56 -0
  42. package/dist/lib/urls.ts +36 -0
  43. package/dist/lib/utils.ts +6 -0
  44. package/dist/lib/version.ts +154 -0
  45. package/dist/types/contracts.ts +259 -0
  46. package/dist/types/index.d.mts +20 -1
  47. package/dist/types/index.d.ts +20 -1
  48. package/dist/types/index.ts +16 -0
  49. package/package.json +14 -9
@@ -0,0 +1,129 @@
1
+ import React from "react";
2
+ import Image from "next/image";
3
+ import Link from "next/link";
4
+
5
+ import type { PublishedArticle, PublishedAuthor } from "../types/contracts";
6
+ import type { BlogTheme } from "../lib/article-ui";
7
+ import { getBlogThemeStyle } from "../lib/article-ui";
8
+ import { getAuthorsFromArticles } from "../lib/authors";
9
+ import { buildAuthorPath, normalizeBlogBasePath } from "../lib/urls";
10
+ import { cn } from "../lib/utils";
11
+
12
+ interface BlogAuthorsIndexPageProps {
13
+ articles: PublishedArticle[];
14
+ basePath?: string;
15
+ title?: string;
16
+ description?: string;
17
+ className?: string;
18
+ theme?: BlogTheme;
19
+ }
20
+
21
+ export function BlogAuthorsIndexPage({
22
+ articles,
23
+ basePath = "/blog",
24
+ title = "Authors",
25
+ description = "Browse articles by author.",
26
+ className,
27
+ theme,
28
+ }: BlogAuthorsIndexPageProps) {
29
+ const normalizedBasePath = normalizeBlogBasePath(basePath) || "/";
30
+ const authors = getAuthorsFromArticles(articles);
31
+
32
+ return (
33
+ <main
34
+ style={getBlogThemeStyle(theme)}
35
+ className={cn(
36
+ "blog-authors-page container mx-auto max-w-5xl px-4 py-8 sm:px-6 sm:py-12 lg:px-8 lg:py-16",
37
+ className,
38
+ )}
39
+ >
40
+ <header className="mb-10 space-y-3 text-center">
41
+ <Link
42
+ href={normalizedBasePath}
43
+ className="text-muted-foreground text-sm font-medium transition-colors hover:text-[var(--blog-accent)]"
44
+ >
45
+ Back to all articles
46
+ </Link>
47
+ <h1 className="text-4xl font-semibold tracking-tight text-balance sm:text-5xl">
48
+ {title}
49
+ </h1>
50
+ <p className="text-muted-foreground mx-auto max-w-2xl text-lg text-pretty">
51
+ {description}
52
+ </p>
53
+ </header>
54
+
55
+ {authors.length === 0 ? (
56
+ <p className="text-muted-foreground rounded-2xl border p-8 text-center">
57
+ No authors have published articles yet.
58
+ </p>
59
+ ) : (
60
+ <div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
61
+ {authors.map((author) => {
62
+ const count = articles.filter(
63
+ (article) => article.author?.slug === author.slug,
64
+ ).length;
65
+
66
+ return (
67
+ <Link
68
+ key={author.slug}
69
+ href={buildAuthorPath(normalizedBasePath, author.slug)}
70
+ className="group rounded-2xl border border-[var(--blog-accent-border)] bg-[var(--blog-card-bg)] p-5 transition hover:border-[var(--blog-accent)] hover:no-underline"
71
+ >
72
+ <div className="flex items-start gap-4">
73
+ <AuthorAvatar author={author} />
74
+ <div className="min-w-0 space-y-2">
75
+ <h2 className="text-xl font-semibold transition-colors group-hover:text-[var(--blog-accent)]">
76
+ {author.name}
77
+ </h2>
78
+ {author.bio && (
79
+ <p className="text-muted-foreground line-clamp-3 text-sm leading-relaxed">
80
+ {author.bio}
81
+ </p>
82
+ )}
83
+ <p className="text-muted-foreground text-sm">
84
+ {count} {count === 1 ? "article" : "articles"}
85
+ </p>
86
+ </div>
87
+ </div>
88
+ </Link>
89
+ );
90
+ })}
91
+ </div>
92
+ )}
93
+ </main>
94
+ );
95
+ }
96
+
97
+ function AuthorAvatar({ author }: { author: PublishedAuthor }) {
98
+ const initials = getAuthorInitials(author.name);
99
+
100
+ if (author.avatarUrl) {
101
+ return (
102
+ <Image
103
+ src={author.avatarUrl}
104
+ alt={author.name}
105
+ width={56}
106
+ height={56}
107
+ className="size-14 shrink-0 rounded-full border border-[var(--blog-accent-border)] object-cover"
108
+ />
109
+ );
110
+ }
111
+
112
+ return (
113
+ <span className="bg-muted text-muted-foreground inline-flex size-14 shrink-0 items-center justify-center rounded-full text-base font-semibold">
114
+ {initials}
115
+ </span>
116
+ );
117
+ }
118
+
119
+ function getAuthorInitials(name: string): string {
120
+ const initials = name
121
+ .trim()
122
+ .split(/\s+/)
123
+ .filter(Boolean)
124
+ .slice(0, 2)
125
+ .map((part) => part[0]?.toUpperCase())
126
+ .join("");
127
+
128
+ return initials || "?";
129
+ }
@@ -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
+ }
@@ -0,0 +1,236 @@
1
+ import React from "react";
2
+ import { ChevronsLeft, ChevronsRight } from "lucide-react";
3
+ import {
4
+ Pagination as ShadcnPagination,
5
+ PaginationContent,
6
+ PaginationItem,
7
+ PaginationLink,
8
+ PaginationNext,
9
+ PaginationPrevious,
10
+ PaginationEllipsis,
11
+ } from "./ui/pagination";
12
+
13
+ export interface PaginationProps {
14
+ /** Current page number (1-indexed) */
15
+ currentPage: number;
16
+
17
+ /** Total number of pages */
18
+ totalPages: number;
19
+
20
+ /** Base URL for pagination links (e.g., "/blog") */
21
+ baseUrl: string;
22
+
23
+ /** Additional CSS classes */
24
+ className?: string;
25
+
26
+ /** Maximum number of page links to show around current page */
27
+ maxVisiblePages?: number;
28
+
29
+ /** Show/hide previous and next buttons */
30
+ showPrevNext?: boolean;
31
+
32
+ /** Show/hide first and last page links */
33
+ showFirstLast?: boolean;
34
+
35
+ /** Custom labels for navigation */
36
+ labels?: {
37
+ previous?: string;
38
+ next?: string;
39
+ first?: string;
40
+ last?: string;
41
+ };
42
+ }
43
+
44
+ /**
45
+ * Pagination component for blog articles listing.
46
+ *
47
+ * Provides SEO-friendly pagination with proper URL structure,
48
+ * keyboard navigation, and accessibility features.
49
+ */
50
+ export default function Pagination({
51
+ currentPage,
52
+ totalPages,
53
+ baseUrl,
54
+ className,
55
+ maxVisiblePages = 5,
56
+ showPrevNext = true,
57
+ showFirstLast = true,
58
+ }: PaginationProps) {
59
+ // Don't render pagination if there's only one page or no pages
60
+ if (totalPages <= 1) {
61
+ return null;
62
+ }
63
+
64
+ // Calculate visible page range
65
+ const getVisiblePages = () => {
66
+ const half = Math.floor(maxVisiblePages / 2);
67
+ let start = Math.max(1, currentPage - half);
68
+ const end = Math.min(totalPages, start + maxVisiblePages - 1);
69
+
70
+ // Adjust start if we're near the end
71
+ if (end - start + 1 < maxVisiblePages) {
72
+ start = Math.max(1, end - maxVisiblePages + 1);
73
+ }
74
+
75
+ return { start, end };
76
+ };
77
+
78
+ const { start, end } = getVisiblePages();
79
+ const pages = Array.from({ length: end - start + 1 }, (_, i) => start + i);
80
+
81
+ // Generate URL for a specific page
82
+ const getPageUrl = (page: number) => {
83
+ if (page === 1) {
84
+ return baseUrl;
85
+ }
86
+ return `${baseUrl}?page=${page}`;
87
+ };
88
+
89
+ const hasPrevious = currentPage > 1;
90
+ const hasNext = currentPage < totalPages;
91
+
92
+ return (
93
+ <ShadcnPagination className={className}>
94
+ <PaginationContent>
95
+ {/* First Page Link */}
96
+ {showFirstLast && currentPage > 2 && (
97
+ <>
98
+ <PaginationItem>
99
+ <PaginationLink href={getPageUrl(1)} aria-label="Go to first page">
100
+ <ChevronsLeft className="h-4 w-4" />
101
+ </PaginationLink>
102
+ </PaginationItem>
103
+ {start > 2 && (
104
+ <PaginationItem>
105
+ <PaginationEllipsis />
106
+ </PaginationItem>
107
+ )}
108
+ </>
109
+ )}
110
+
111
+ {/* Previous Page Link */}
112
+ {showPrevNext && (
113
+ <PaginationItem>
114
+ {hasPrevious ? (
115
+ <PaginationPrevious href={getPageUrl(currentPage - 1)} />
116
+ ) : (
117
+ <span className="flex h-9 items-center justify-center gap-1 px-2.5 text-muted-foreground sm:pl-2.5">
118
+ <span className="hidden sm:block">Previous</span>
119
+ </span>
120
+ )}
121
+ </PaginationItem>
122
+ )}
123
+
124
+ {/* Page Number Links */}
125
+ {pages.map((page) => (
126
+ <PaginationItem key={page}>
127
+ <PaginationLink
128
+ href={getPageUrl(page)}
129
+ isActive={page === currentPage}
130
+ >
131
+ {page}
132
+ </PaginationLink>
133
+ </PaginationItem>
134
+ ))}
135
+
136
+ {/* Next Page Link */}
137
+ {showPrevNext && (
138
+ <PaginationItem>
139
+ {hasNext ? (
140
+ <PaginationNext href={getPageUrl(currentPage + 1)} />
141
+ ) : (
142
+ <span className="flex h-9 items-center justify-center gap-1 px-2.5 text-muted-foreground sm:pr-2.5">
143
+ <span className="hidden sm:block">Next</span>
144
+ </span>
145
+ )}
146
+ </PaginationItem>
147
+ )}
148
+
149
+ {/* Last Page Link */}
150
+ {showFirstLast && currentPage < totalPages - 1 && (
151
+ <>
152
+ {end < totalPages - 1 && (
153
+ <PaginationItem>
154
+ <PaginationEllipsis />
155
+ </PaginationItem>
156
+ )}
157
+ <PaginationItem>
158
+ <PaginationLink href={getPageUrl(totalPages)} aria-label="Go to last page">
159
+ <ChevronsRight className="h-4 w-4" />
160
+ </PaginationLink>
161
+ </PaginationItem>
162
+ </>
163
+ )}
164
+ </PaginationContent>
165
+
166
+ {/* Screen Reader Info */}
167
+ <div className="sr-only" aria-live="polite">
168
+ Page {currentPage} of {totalPages}
169
+ </div>
170
+ </ShadcnPagination>
171
+ );
172
+ }
173
+
174
+
175
+ /**
176
+ * Hook to calculate pagination metadata from URL parameters.
177
+ * Useful for extracting current page from Next.js searchParams.
178
+ */
179
+ export function usePagination(
180
+ searchParams: Record<string, string | string[] | undefined>,
181
+ itemsPerPage = 10
182
+ ) {
183
+ const pageParam = searchParams.page;
184
+ const pageString = Array.isArray(pageParam) ? pageParam[0] : pageParam;
185
+ const currentPage = Math.max(1, parseInt(pageString ?? "1", 10));
186
+
187
+ const offset = (currentPage - 1) * itemsPerPage;
188
+
189
+ const calculateTotalPages = (totalItems: number) => {
190
+ return Math.ceil(totalItems / itemsPerPage);
191
+ };
192
+
193
+ return {
194
+ currentPage,
195
+ itemsPerPage,
196
+ offset,
197
+ calculateTotalPages
198
+ };
199
+ }
200
+
201
+ /**
202
+ * Generate pagination metadata for SEO.
203
+ * Provides canonical URLs, next/prev relationships for search engines.
204
+ */
205
+ export function generatePaginationMetadata(
206
+ currentPage: number,
207
+ totalPages: number,
208
+ baseUrl: string,
209
+ siteUrl?: string
210
+ ) {
211
+ const canonicalUrl = siteUrl
212
+ ? `${siteUrl}${currentPage === 1 ? baseUrl : `${baseUrl}?page=${currentPage}`}`
213
+ : undefined;
214
+
215
+ const prevUrl = currentPage > 1
216
+ ? siteUrl
217
+ ? `${siteUrl}${currentPage === 2 ? baseUrl : `${baseUrl}?page=${currentPage - 1}`}`
218
+ : undefined
219
+ : undefined;
220
+
221
+ const nextUrl = currentPage < totalPages
222
+ ? siteUrl
223
+ ? `${siteUrl}${baseUrl}?page=${currentPage + 1}`
224
+ : undefined
225
+ : undefined;
226
+
227
+ return {
228
+ alternates: {
229
+ ...(canonicalUrl && { canonical: canonicalUrl }),
230
+ },
231
+ other: {
232
+ ...(prevUrl && { prev: prevUrl }),
233
+ ...(nextUrl && { next: nextUrl }),
234
+ },
235
+ };
236
+ }
@@ -0,0 +1,204 @@
1
+ import React from "react";
2
+ import Link from "next/link";
3
+ import Image from "next/image";
4
+ import { formatDistanceToNow } from "date-fns";
5
+
6
+ import type { PublishedArticle, PublishedTopic } from "../types/contracts";
7
+ import type { BlogTheme } from "../lib/article-ui";
8
+ import { getBlogThemeStyle } from "../lib/article-ui";
9
+ import { buildBlogPath, normalizeBlogBasePath } from "../lib/urls";
10
+ import { cn } from "../lib/utils";
11
+ import { getAllTopics, getTopicArchive } from "../lib/topics";
12
+
13
+ interface BlogTopicsIndexPageProps {
14
+ articles: PublishedArticle[];
15
+ basePath?: string;
16
+ title?: string;
17
+ description?: string;
18
+ className?: string;
19
+ theme?: BlogTheme;
20
+ }
21
+
22
+ interface BlogTopicArchivePageProps {
23
+ articles: PublishedArticle[];
24
+ topicSlug: string;
25
+ basePath?: string;
26
+ className?: string;
27
+ theme?: BlogTheme;
28
+ }
29
+
30
+ export function BlogTopicsIndexPage({
31
+ articles,
32
+ basePath = "/blog",
33
+ title = "Topics",
34
+ description = "Browse articles by topic.",
35
+ className,
36
+ theme,
37
+ }: BlogTopicsIndexPageProps) {
38
+ const normalizedBasePath = normalizeBlogBasePath(basePath) || "/";
39
+ const topics = getAllTopics(articles);
40
+
41
+ return (
42
+ <main
43
+ style={getBlogThemeStyle(theme)}
44
+ className={cn(
45
+ "blog-topics-page container mx-auto max-w-5xl px-4 py-8 sm:px-6 sm:py-12 lg:px-8 lg:py-16",
46
+ className,
47
+ )}
48
+ >
49
+ <header className="mb-10 space-y-3 text-center">
50
+ <Link
51
+ href={normalizedBasePath}
52
+ className="text-muted-foreground hover:text-[var(--blog-accent)] text-sm font-medium transition-colors"
53
+ >
54
+ Back to all articles
55
+ </Link>
56
+ <h1 className="text-4xl font-semibold tracking-tight text-balance sm:text-5xl">
57
+ {title}
58
+ </h1>
59
+ <p className="text-muted-foreground mx-auto max-w-2xl text-lg text-pretty">
60
+ {description}
61
+ </p>
62
+ </header>
63
+
64
+ {topics.length === 0 ? (
65
+ <p className="text-muted-foreground rounded-2xl border p-8 text-center">
66
+ No topics have published articles yet.
67
+ </p>
68
+ ) : (
69
+ <div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
70
+ {topics.map((topic) => {
71
+ const count = articles.filter((article) =>
72
+ (article.topics ?? []).some(
73
+ (articleTopic) => articleTopic.slug === topic.slug,
74
+ ),
75
+ ).length;
76
+
77
+ return (
78
+ <Link
79
+ key={topic.slug}
80
+ href={buildBlogPath(normalizedBasePath, `topics/${topic.slug}`)}
81
+ className="group rounded-2xl border border-[var(--blog-accent-border)] bg-[var(--blog-card-bg)] p-5 transition hover:border-[var(--blog-accent)] hover:no-underline"
82
+ >
83
+ <h2 className="group-hover:text-[var(--blog-accent)] text-xl font-semibold transition-colors">
84
+ {topic.name}
85
+ </h2>
86
+ {topic.description && (
87
+ <p className="text-muted-foreground mt-2 line-clamp-3 text-sm leading-relaxed">
88
+ {topic.description}
89
+ </p>
90
+ )}
91
+ <p className="text-muted-foreground mt-4 text-sm">
92
+ {count} {count === 1 ? "article" : "articles"}
93
+ </p>
94
+ </Link>
95
+ );
96
+ })}
97
+ </div>
98
+ )}
99
+ </main>
100
+ );
101
+ }
102
+
103
+ export function BlogTopicArchivePage({
104
+ articles,
105
+ topicSlug,
106
+ basePath = "/blog",
107
+ className,
108
+ theme,
109
+ }: BlogTopicArchivePageProps) {
110
+ const normalizedBasePath = normalizeBlogBasePath(basePath) || "/";
111
+ const archive = getTopicArchive(articles, topicSlug);
112
+
113
+ return (
114
+ <main
115
+ style={getBlogThemeStyle(theme)}
116
+ className={cn(
117
+ "blog-topic-archive-page container mx-auto max-w-5xl px-4 py-8 sm:px-6 sm:py-12 lg:px-8 lg:py-16",
118
+ className,
119
+ )}
120
+ >
121
+ <header className="mb-10 space-y-3 text-center">
122
+ <Link
123
+ href={buildBlogPath(normalizedBasePath, "topics")}
124
+ className="text-muted-foreground hover:text-[var(--blog-accent)] text-sm font-medium transition-colors"
125
+ >
126
+ Back to all topics
127
+ </Link>
128
+ <h1 className="text-4xl font-semibold tracking-tight text-balance sm:text-5xl">
129
+ {archive?.topic.name ?? "Topic not found"}
130
+ </h1>
131
+ {archive?.topic.description && (
132
+ <p className="text-muted-foreground mx-auto max-w-2xl text-lg text-pretty">
133
+ {archive.topic.description}
134
+ </p>
135
+ )}
136
+ </header>
137
+
138
+ {!archive ? (
139
+ <p className="text-muted-foreground rounded-2xl border p-8 text-center">
140
+ No published articles were found for this topic.
141
+ </p>
142
+ ) : (
143
+ <div className="grid gap-5 md:grid-cols-2">
144
+ {archive.articles.map((article) => (
145
+ <TopicArticleCard
146
+ key={article.id}
147
+ article={article}
148
+ topic={archive.topic}
149
+ basePath={normalizedBasePath}
150
+ />
151
+ ))}
152
+ </div>
153
+ )}
154
+ </main>
155
+ );
156
+ }
157
+
158
+ function TopicArticleCard({
159
+ article,
160
+ topic,
161
+ basePath,
162
+ }: {
163
+ article: PublishedArticle;
164
+ topic: PublishedTopic;
165
+ basePath: string;
166
+ }) {
167
+ const articleUrl = buildBlogPath(basePath, article.slug);
168
+ const publishedAgo = formatDistanceToNow(new Date(article.publishedAt), {
169
+ addSuffix: true,
170
+ });
171
+
172
+ return (
173
+ <Link
174
+ href={articleUrl}
175
+ className="group overflow-hidden rounded-2xl border border-[var(--blog-accent-border)] bg-[var(--blog-card-bg)] transition hover:border-[var(--blog-accent)] hover:no-underline"
176
+ >
177
+ {article.featuredImage && (
178
+ <div className="relative aspect-[16/9] overflow-hidden">
179
+ <Image
180
+ src={article.featuredImage}
181
+ alt={article.featuredImageAlt ?? article.title}
182
+ width={640}
183
+ height={360}
184
+ className="h-full w-full object-cover transition-transform duration-300 group-hover:scale-105"
185
+ />
186
+ </div>
187
+ )}
188
+ <div className="space-y-3 p-5">
189
+ <span className="inline-flex rounded-full border border-[var(--blog-accent-border)] px-2.5 py-1 text-xs font-medium text-[var(--blog-text-accent)]">
190
+ {topic.name}
191
+ </span>
192
+ <h2 className="group-hover:text-[var(--blog-accent)] text-xl font-semibold text-balance transition-colors">
193
+ {article.title}
194
+ </h2>
195
+ {article.excerpt && (
196
+ <p className="text-muted-foreground line-clamp-3 text-sm leading-relaxed">
197
+ {article.excerpt}
198
+ </p>
199
+ )}
200
+ <p className="text-muted-foreground text-sm">{publishedAgo}</p>
201
+ </div>
202
+ </Link>
203
+ );
204
+ }
@@ -0,0 +1,36 @@
1
+ import * as React from "react"
2
+ import { cva, type VariantProps } from "class-variance-authority"
3
+
4
+ import { cn } from "../../lib/utils"
5
+
6
+ const badgeVariants = cva(
7
+ "inline-flex items-center rounded-md border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2",
8
+ {
9
+ variants: {
10
+ variant: {
11
+ default:
12
+ "border-transparent bg-primary text-primary-foreground shadow hover:bg-primary/80",
13
+ secondary:
14
+ "border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80",
15
+ destructive:
16
+ "border-transparent bg-destructive text-destructive-foreground shadow hover:bg-destructive/80",
17
+ outline: "text-foreground",
18
+ },
19
+ },
20
+ defaultVariants: {
21
+ variant: "default",
22
+ },
23
+ }
24
+ )
25
+
26
+ export interface BadgeProps
27
+ extends React.HTMLAttributes<HTMLDivElement>,
28
+ VariantProps<typeof badgeVariants> {}
29
+
30
+ function Badge({ className, variant, ...props }: BadgeProps) {
31
+ return (
32
+ <div className={cn(badgeVariants({ variant }), className)} {...props} />
33
+ )
34
+ }
35
+
36
+ export { Badge, badgeVariants }