@xexr/blog-platform 0.2.0
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 +61 -0
- package/LICENSE +21 -0
- package/README.md +1175 -0
- package/dist/app/api/blog/publish/route.ts +360 -0
- package/dist/app/api/blog/slugs/[slug]/route.ts +166 -0
- package/dist/app/api/blog/unpublish/route.ts +301 -0
- package/dist/app/api/blog/version/route.ts +259 -0
- package/dist/app/blog/[slug]/page.tsx +304 -0
- package/dist/app/blog/page.tsx +316 -0
- package/dist/index.d.mts +1799 -0
- package/dist/index.d.ts +1799 -0
- package/dist/index.js +31 -0
- package/dist/index.js.map +1 -0
- package/dist/index.mjs +31 -0
- package/dist/index.mjs.map +1 -0
- package/dist/types/index.d.mts +142 -0
- package/dist/types/index.d.ts +142 -0
- package/dist/types/index.js +2 -0
- package/dist/types/index.js.map +1 -0
- package/dist/types/index.mjs +2 -0
- package/dist/types/index.mjs.map +1 -0
- package/package.json +60 -0
|
@@ -0,0 +1,304 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
import Image from "next/image";
|
|
3
|
+
import Link from "next/link";
|
|
4
|
+
import { MDXRemote } from "next-mdx-remote-client/rsc";
|
|
5
|
+
import { type PublishedArticle } from "../../../types/contracts";
|
|
6
|
+
import { format, formatDistanceToNow } from "date-fns";
|
|
7
|
+
import { cn } from "../../../lib/utils";
|
|
8
|
+
import { ArrowLeft } from "lucide-react";
|
|
9
|
+
import { Badge } from "../../../components/ui/badge";
|
|
10
|
+
import {
|
|
11
|
+
Card,
|
|
12
|
+
CardContent,
|
|
13
|
+
CardHeader,
|
|
14
|
+
CardTitle,
|
|
15
|
+
} from "../../../components/ui/card";
|
|
16
|
+
import { Separator } from "../../../components/ui/separator";
|
|
17
|
+
import {
|
|
18
|
+
generateCompleteArticleStructuredData,
|
|
19
|
+
createStructuredDataScript,
|
|
20
|
+
} from "../../../lib/structured-data";
|
|
21
|
+
|
|
22
|
+
interface ArticlePageProps {
|
|
23
|
+
article: PublishedArticle;
|
|
24
|
+
className?: string;
|
|
25
|
+
/** Optional navigation links */
|
|
26
|
+
navigation?: {
|
|
27
|
+
backToHome?: {
|
|
28
|
+
url: string;
|
|
29
|
+
label?: string;
|
|
30
|
+
};
|
|
31
|
+
previousArticle?: {
|
|
32
|
+
url: string;
|
|
33
|
+
title: string;
|
|
34
|
+
};
|
|
35
|
+
nextArticle?: {
|
|
36
|
+
url: string;
|
|
37
|
+
title: string;
|
|
38
|
+
};
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Individual article page component that displays full article content.
|
|
44
|
+
*
|
|
45
|
+
* This component is designed to be used by customers who install the
|
|
46
|
+
* @xexr/blog-platform package. It provides a complete article view
|
|
47
|
+
* with proper SEO metadata, structured data, and accessibility features.
|
|
48
|
+
*/
|
|
49
|
+
/**
|
|
50
|
+
* Helper function to format reading time in minutes
|
|
51
|
+
*/
|
|
52
|
+
function formatReadingTime(seconds: number): string {
|
|
53
|
+
const minutes = Math.ceil(seconds / 60);
|
|
54
|
+
return `${minutes} min read`;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export default function ArticlePage({
|
|
58
|
+
article,
|
|
59
|
+
className,
|
|
60
|
+
navigation,
|
|
61
|
+
}: ArticlePageProps) {
|
|
62
|
+
const publishedDate = new Date(article.publishedAt);
|
|
63
|
+
const updatedDate = new Date(article.updatedAt);
|
|
64
|
+
const isUpdated = updatedDate.getTime() !== publishedDate.getTime();
|
|
65
|
+
const formattedDate = formatDistanceToNow(publishedDate, { addSuffix: true });
|
|
66
|
+
const readingTime = formatReadingTime(article.readingSeconds);
|
|
67
|
+
|
|
68
|
+
// Use the featured image URL provided by Bloggy (hosted on Bloggy's domain)
|
|
69
|
+
const imageUrl = article.featuredImage;
|
|
70
|
+
|
|
71
|
+
return (
|
|
72
|
+
<article
|
|
73
|
+
className={cn(
|
|
74
|
+
"article-page mx-auto max-w-4xl px-4 py-6 pb-12 sm:px-6 sm:py-8 sm:pb-16 lg:px-8 lg:py-12 lg:pb-24",
|
|
75
|
+
className,
|
|
76
|
+
)}
|
|
77
|
+
>
|
|
78
|
+
{/* Navigation */}
|
|
79
|
+
{navigation?.backToHome && (
|
|
80
|
+
<header className="mb-8 sm:mb-12 lg:mb-16">
|
|
81
|
+
<Link
|
|
82
|
+
href={navigation.backToHome.url}
|
|
83
|
+
className="text-muted-foreground hover:text-foreground group focus-visible:ring-ring inline-flex touch-manipulation items-center space-x-2 rounded-md px-2 py-1 transition-colors focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-none sm:space-x-3"
|
|
84
|
+
aria-label={navigation.backToHome.label ?? "Return to blog"}
|
|
85
|
+
>
|
|
86
|
+
<ArrowLeft
|
|
87
|
+
className="h-4 w-4 transition-transform group-hover:-translate-x-1 sm:h-5 sm:w-5"
|
|
88
|
+
aria-hidden="true"
|
|
89
|
+
/>
|
|
90
|
+
<span className="text-sm font-medium sm:text-base">
|
|
91
|
+
<span className="hidden sm:inline">
|
|
92
|
+
{navigation.backToHome.label ?? "Back to all articles"}
|
|
93
|
+
</span>
|
|
94
|
+
<span className="sm:hidden">Back</span>
|
|
95
|
+
</span>
|
|
96
|
+
</Link>
|
|
97
|
+
</header>
|
|
98
|
+
)}
|
|
99
|
+
|
|
100
|
+
{/* Article Header */}
|
|
101
|
+
<header className="article-header mb-6 space-y-4 text-center sm:mb-8 sm:space-y-6 lg:mb-12 lg:space-y-8">
|
|
102
|
+
{/* Title */}
|
|
103
|
+
<h1 className="article-title text-foreground px-4 text-2xl leading-tight font-light tracking-tight sm:px-0 sm:text-3xl md:text-4xl lg:text-5xl xl:text-6xl">
|
|
104
|
+
{article.title}
|
|
105
|
+
</h1>
|
|
106
|
+
|
|
107
|
+
{/* Excerpt */}
|
|
108
|
+
{article.excerpt && (
|
|
109
|
+
<p className="article-excerpt text-muted-foreground mx-auto mt-6 max-w-xs px-4 text-base leading-relaxed sm:mt-8 sm:max-w-lg sm:px-0 sm:text-lg md:max-w-2xl md:text-xl lg:max-w-4xl lg:text-2xl">
|
|
110
|
+
{article.excerpt}
|
|
111
|
+
</p>
|
|
112
|
+
)}
|
|
113
|
+
|
|
114
|
+
{/* Article Metadata */}
|
|
115
|
+
<div className="article-meta text-muted-foreground flex flex-col items-center gap-2 text-sm sm:gap-3 sm:text-base">
|
|
116
|
+
{article.author && (
|
|
117
|
+
<div className="flex items-center gap-2">
|
|
118
|
+
<span className="sr-only">Author</span>
|
|
119
|
+
<span className="font-medium">{article.author.name}</span>
|
|
120
|
+
{article.author.isGuest && (
|
|
121
|
+
<Badge variant="outline" className="text-xs">
|
|
122
|
+
Guest Author
|
|
123
|
+
</Badge>
|
|
124
|
+
)}
|
|
125
|
+
</div>
|
|
126
|
+
)}
|
|
127
|
+
|
|
128
|
+
<div className="flex flex-wrap items-center justify-center gap-x-3 gap-y-1">
|
|
129
|
+
<time
|
|
130
|
+
dateTime={article.publishedAt}
|
|
131
|
+
className="font-medium"
|
|
132
|
+
aria-label={`Published ${formattedDate}`}
|
|
133
|
+
>
|
|
134
|
+
Published {formattedDate}
|
|
135
|
+
</time>
|
|
136
|
+
<span className="text-border">•</span>
|
|
137
|
+
<span className="font-medium">{readingTime}</span>
|
|
138
|
+
</div>
|
|
139
|
+
|
|
140
|
+
{isUpdated && (
|
|
141
|
+
<time dateTime={article.updatedAt} className="text-xs">
|
|
142
|
+
Updated {format(updatedDate, "MMMM d, yyyy")}
|
|
143
|
+
</time>
|
|
144
|
+
)}
|
|
145
|
+
|
|
146
|
+
<div className="bg-border h-px w-6 sm:w-8" aria-hidden="true"></div>
|
|
147
|
+
|
|
148
|
+
<span
|
|
149
|
+
className="text-xs tracking-wider uppercase sm:text-sm"
|
|
150
|
+
role="text"
|
|
151
|
+
>
|
|
152
|
+
From {article.site.name}
|
|
153
|
+
</span>
|
|
154
|
+
</div>
|
|
155
|
+
</header>
|
|
156
|
+
|
|
157
|
+
{/* Featured Image */}
|
|
158
|
+
{imageUrl && (
|
|
159
|
+
<div className="article-featured-image relative my-8 aspect-[16/9] w-full overflow-hidden rounded-lg sm:my-12 lg:my-16">
|
|
160
|
+
<Image
|
|
161
|
+
src={imageUrl}
|
|
162
|
+
alt={
|
|
163
|
+
article.featuredImageAlt ?? `Featured image for ${article.title}`
|
|
164
|
+
}
|
|
165
|
+
className="h-full w-full object-cover"
|
|
166
|
+
width={800}
|
|
167
|
+
height={450}
|
|
168
|
+
priority
|
|
169
|
+
/>
|
|
170
|
+
</div>
|
|
171
|
+
)}
|
|
172
|
+
|
|
173
|
+
{/* Article Content */}
|
|
174
|
+
<div className="article-content">
|
|
175
|
+
<div className="prose prose-sm sm:prose-base lg:prose-lg xl:prose-xl prose-gray dark:prose-invert prose-headings:scroll-mt-20 prose-headings:font-light prose-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-img:mx-auto prose-img:rounded-lg prose-a:transition-colors max-w-none">
|
|
176
|
+
<MDXRemote source={article.content} />
|
|
177
|
+
</div>
|
|
178
|
+
</div>
|
|
179
|
+
|
|
180
|
+
{/* Author Bio */}
|
|
181
|
+
{article.author?.bio && (
|
|
182
|
+
<div className="author-bio mt-12">
|
|
183
|
+
<Separator className="mb-8" />
|
|
184
|
+
<Card>
|
|
185
|
+
<CardHeader>
|
|
186
|
+
<CardTitle className="flex items-center gap-2">
|
|
187
|
+
About {article.author.name}
|
|
188
|
+
{article.author.isGuest && (
|
|
189
|
+
<Badge variant="outline" className="text-xs">
|
|
190
|
+
Guest Author
|
|
191
|
+
</Badge>
|
|
192
|
+
)}
|
|
193
|
+
</CardTitle>
|
|
194
|
+
</CardHeader>
|
|
195
|
+
<CardContent>
|
|
196
|
+
<div className="prose dark:prose-invert">
|
|
197
|
+
<p className="text-muted-foreground">{article.author.bio}</p>
|
|
198
|
+
</div>
|
|
199
|
+
</CardContent>
|
|
200
|
+
</Card>
|
|
201
|
+
</div>
|
|
202
|
+
)}
|
|
203
|
+
|
|
204
|
+
{/* Article Navigation */}
|
|
205
|
+
{(Boolean(navigation?.previousArticle) ||
|
|
206
|
+
Boolean(navigation?.nextArticle)) && (
|
|
207
|
+
<nav className="article-nav mt-12">
|
|
208
|
+
<Separator className="mb-8" />
|
|
209
|
+
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
|
|
210
|
+
{navigation?.previousArticle && (
|
|
211
|
+
<Card className="group hover:bg-accent/50 cursor-pointer transition-colors">
|
|
212
|
+
<Link href={navigation.previousArticle.url} className="block">
|
|
213
|
+
<CardContent className="p-4">
|
|
214
|
+
<div className="text-muted-foreground mb-2 text-sm font-medium">
|
|
215
|
+
← Previous Article
|
|
216
|
+
</div>
|
|
217
|
+
<div className="text-foreground group-hover:text-primary transition-colors">
|
|
218
|
+
{navigation.previousArticle.title}
|
|
219
|
+
</div>
|
|
220
|
+
</CardContent>
|
|
221
|
+
</Link>
|
|
222
|
+
</Card>
|
|
223
|
+
)}
|
|
224
|
+
{navigation?.nextArticle && (
|
|
225
|
+
<Card
|
|
226
|
+
className={cn(
|
|
227
|
+
"group hover:bg-accent/50 cursor-pointer transition-colors",
|
|
228
|
+
!navigation?.previousArticle && "md:col-start-2",
|
|
229
|
+
)}
|
|
230
|
+
>
|
|
231
|
+
<Link href={navigation.nextArticle.url} className="block">
|
|
232
|
+
<CardContent className="p-4 md:text-right">
|
|
233
|
+
<div className="text-muted-foreground mb-2 text-sm font-medium">
|
|
234
|
+
Next Article →
|
|
235
|
+
</div>
|
|
236
|
+
<div className="text-foreground group-hover:text-primary transition-colors">
|
|
237
|
+
{navigation.nextArticle.title}
|
|
238
|
+
</div>
|
|
239
|
+
</CardContent>
|
|
240
|
+
</Link>
|
|
241
|
+
</Card>
|
|
242
|
+
)}
|
|
243
|
+
</div>
|
|
244
|
+
</nav>
|
|
245
|
+
)}
|
|
246
|
+
|
|
247
|
+
{/* JSON-LD Structured Data */}
|
|
248
|
+
{(() => {
|
|
249
|
+
const structuredDataProps = createStructuredDataScript(
|
|
250
|
+
generateCompleteArticleStructuredData(article),
|
|
251
|
+
);
|
|
252
|
+
return (
|
|
253
|
+
<script
|
|
254
|
+
type={structuredDataProps.type}
|
|
255
|
+
dangerouslySetInnerHTML={
|
|
256
|
+
structuredDataProps.dangerouslySetInnerHTML
|
|
257
|
+
}
|
|
258
|
+
/>
|
|
259
|
+
);
|
|
260
|
+
})()}
|
|
261
|
+
</article>
|
|
262
|
+
);
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
/**
|
|
266
|
+
* Generate metadata for the article page for SEO optimization.
|
|
267
|
+
* This function should be used in the Next.js metadata API.
|
|
268
|
+
*/
|
|
269
|
+
export function generateArticleMetadata(article: PublishedArticle) {
|
|
270
|
+
const title = article.meta.title ?? article.title;
|
|
271
|
+
const description = article.meta.description ?? article.excerpt;
|
|
272
|
+
const canonicalUrl =
|
|
273
|
+
article.meta.canonicalUrl ?? `${article.site.domain}/blog/${article.slug}`;
|
|
274
|
+
const ogImage = article.meta.ogImage ?? article.featuredImage;
|
|
275
|
+
|
|
276
|
+
return {
|
|
277
|
+
title,
|
|
278
|
+
description,
|
|
279
|
+
keywords: article.meta.keywords.join(", "),
|
|
280
|
+
authors: article.author ? [{ name: article.author.name }] : undefined,
|
|
281
|
+
openGraph: {
|
|
282
|
+
title,
|
|
283
|
+
description,
|
|
284
|
+
type: "article",
|
|
285
|
+
url: canonicalUrl,
|
|
286
|
+
images: ogImage
|
|
287
|
+
? [{ url: ogImage, alt: article.featuredImageAlt ?? title }]
|
|
288
|
+
: undefined,
|
|
289
|
+
publishedTime: article.publishedAt,
|
|
290
|
+
modifiedTime: article.updatedAt,
|
|
291
|
+
authors: article.author ? [article.author.name] : undefined,
|
|
292
|
+
siteName: article.site.name,
|
|
293
|
+
},
|
|
294
|
+
twitter: {
|
|
295
|
+
card: "summary_large_image",
|
|
296
|
+
title,
|
|
297
|
+
description,
|
|
298
|
+
images: ogImage ? [ogImage] : undefined,
|
|
299
|
+
},
|
|
300
|
+
alternates: {
|
|
301
|
+
canonical: canonicalUrl,
|
|
302
|
+
},
|
|
303
|
+
};
|
|
304
|
+
}
|
|
@@ -0,0 +1,316 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
import Link from "next/link";
|
|
3
|
+
import Image from "next/image";
|
|
4
|
+
import {
|
|
5
|
+
type PublishedArticle,
|
|
6
|
+
type PublishedSite,
|
|
7
|
+
} from "../../types/contracts";
|
|
8
|
+
import { cn } from "../../lib/utils";
|
|
9
|
+
import Pagination from "../../components/Pagination";
|
|
10
|
+
import {
|
|
11
|
+
generateBlogHomeStructuredData,
|
|
12
|
+
createStructuredDataScript,
|
|
13
|
+
} from "../../lib/structured-data";
|
|
14
|
+
import { formatDistanceToNow } from "date-fns";
|
|
15
|
+
|
|
16
|
+
interface BlogHomePageProps {
|
|
17
|
+
articles: PublishedArticle[];
|
|
18
|
+
siteName?: string;
|
|
19
|
+
siteTagline?: string;
|
|
20
|
+
className?: string;
|
|
21
|
+
/** Site data for structured data generation */
|
|
22
|
+
site?: PublishedSite;
|
|
23
|
+
/** Whether to include structured data */
|
|
24
|
+
includeStructuredData?: boolean;
|
|
25
|
+
/** Pagination configuration */
|
|
26
|
+
pagination?: {
|
|
27
|
+
currentPage: number;
|
|
28
|
+
totalPages: number;
|
|
29
|
+
baseUrl?: string;
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Blog home page component that displays a list of published articles.
|
|
35
|
+
*
|
|
36
|
+
* This component is designed to be used by customers who install the
|
|
37
|
+
* @xexr/blog-platform package. It provides a clean, SEO-friendly
|
|
38
|
+
* article listing with proper metadata.
|
|
39
|
+
*/
|
|
40
|
+
export default function BlogHomePage({
|
|
41
|
+
articles,
|
|
42
|
+
siteName,
|
|
43
|
+
siteTagline,
|
|
44
|
+
className,
|
|
45
|
+
site,
|
|
46
|
+
includeStructuredData = false,
|
|
47
|
+
pagination,
|
|
48
|
+
}: BlogHomePageProps) {
|
|
49
|
+
return (
|
|
50
|
+
<div
|
|
51
|
+
className={cn(
|
|
52
|
+
"blog-home-page container mx-auto max-w-5xl px-4 py-8 sm:px-6 sm:py-12 lg:px-8 lg:py-16",
|
|
53
|
+
className,
|
|
54
|
+
)}
|
|
55
|
+
>
|
|
56
|
+
{/* Site Header */}
|
|
57
|
+
{siteName && (
|
|
58
|
+
<header className="blog-header mb-8 text-center sm:mb-12 lg:mb-16">
|
|
59
|
+
<h1 className="blog-title text-foreground mb-2 text-3xl font-light tracking-tight sm:text-4xl lg:text-5xl">
|
|
60
|
+
{siteName}
|
|
61
|
+
</h1>
|
|
62
|
+
{siteTagline && (
|
|
63
|
+
<p className="blog-tagline text-muted-foreground text-base sm:text-lg">
|
|
64
|
+
{siteTagline}
|
|
65
|
+
</p>
|
|
66
|
+
)}
|
|
67
|
+
</header>
|
|
68
|
+
)}
|
|
69
|
+
|
|
70
|
+
{/* Articles List */}
|
|
71
|
+
<main className="articles-list">
|
|
72
|
+
{articles.length === 0 ? (
|
|
73
|
+
<div className="no-articles text-muted-foreground py-16 text-center sm:py-24 lg:py-32">
|
|
74
|
+
<h2 className="mb-3 text-xl font-light sm:mb-4 sm:text-2xl lg:text-3xl">
|
|
75
|
+
No articles yet
|
|
76
|
+
</h2>
|
|
77
|
+
<p className="text-muted-foreground/70 mx-auto max-w-md text-base sm:text-lg">
|
|
78
|
+
This blog doesn't have any published articles yet.
|
|
79
|
+
</p>
|
|
80
|
+
</div>
|
|
81
|
+
) : (
|
|
82
|
+
<div className="space-y-0">
|
|
83
|
+
{articles.map((article) => (
|
|
84
|
+
<ArticlePreview key={article.id} article={article} />
|
|
85
|
+
))}
|
|
86
|
+
</div>
|
|
87
|
+
)}
|
|
88
|
+
|
|
89
|
+
{/* Pagination */}
|
|
90
|
+
{pagination && pagination.totalPages > 1 && (
|
|
91
|
+
<div className="pagination-wrapper mt-12">
|
|
92
|
+
<Pagination
|
|
93
|
+
currentPage={pagination.currentPage}
|
|
94
|
+
totalPages={pagination.totalPages}
|
|
95
|
+
baseUrl={pagination.baseUrl ?? "/blog"}
|
|
96
|
+
className="justify-center"
|
|
97
|
+
/>
|
|
98
|
+
</div>
|
|
99
|
+
)}
|
|
100
|
+
</main>
|
|
101
|
+
|
|
102
|
+
{/* JSON-LD Structured Data */}
|
|
103
|
+
{includeStructuredData &&
|
|
104
|
+
site &&
|
|
105
|
+
(() => {
|
|
106
|
+
const structuredDataProps = createStructuredDataScript(
|
|
107
|
+
generateBlogHomeStructuredData(site, articles.slice(0, 5)),
|
|
108
|
+
);
|
|
109
|
+
return (
|
|
110
|
+
<script
|
|
111
|
+
type={structuredDataProps.type}
|
|
112
|
+
dangerouslySetInnerHTML={
|
|
113
|
+
structuredDataProps.dangerouslySetInnerHTML
|
|
114
|
+
}
|
|
115
|
+
/>
|
|
116
|
+
);
|
|
117
|
+
})()}
|
|
118
|
+
</div>
|
|
119
|
+
);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Helper function to format reading time in minutes
|
|
124
|
+
*/
|
|
125
|
+
function formatReadingTime(seconds: number): string {
|
|
126
|
+
const minutes = Math.ceil(seconds / 60);
|
|
127
|
+
return `${minutes} min read`;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* Individual article preview component for the blog listing.
|
|
132
|
+
*/
|
|
133
|
+
function ArticlePreview({ article }: { article: PublishedArticle }) {
|
|
134
|
+
const articleUrl = `/blog/${article.slug}`;
|
|
135
|
+
const formattedDate = formatDistanceToNow(new Date(article.publishedAt), {
|
|
136
|
+
addSuffix: true,
|
|
137
|
+
});
|
|
138
|
+
const readingTime = formatReadingTime(article.readingSeconds);
|
|
139
|
+
|
|
140
|
+
// Use the featured image URL provided by Bloggy (hosted on Bloggy's domain)
|
|
141
|
+
const imageUrl = article.featuredImage;
|
|
142
|
+
|
|
143
|
+
return (
|
|
144
|
+
<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">
|
|
145
|
+
<Link
|
|
146
|
+
href={articleUrl}
|
|
147
|
+
className="focus-visible:ring-ring block rounded-lg hover:no-underline focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-none"
|
|
148
|
+
aria-label={`Read article: ${article.title}`}
|
|
149
|
+
>
|
|
150
|
+
{/* Mobile/Small Tablet: Vertical layout */}
|
|
151
|
+
<div className="space-y-4 lg:hidden">
|
|
152
|
+
{imageUrl && (
|
|
153
|
+
<div className="relative aspect-[16/10] w-full overflow-hidden rounded-lg">
|
|
154
|
+
<Image
|
|
155
|
+
src={imageUrl}
|
|
156
|
+
alt={
|
|
157
|
+
article.featuredImageAlt ??
|
|
158
|
+
`Featured image for ${article.title}`
|
|
159
|
+
}
|
|
160
|
+
width={400}
|
|
161
|
+
height={250}
|
|
162
|
+
className="h-full w-full object-cover transition-transform duration-300 group-hover:scale-105"
|
|
163
|
+
/>
|
|
164
|
+
</div>
|
|
165
|
+
)}
|
|
166
|
+
|
|
167
|
+
<div className="space-y-3">
|
|
168
|
+
<h2 className="group-hover:text-primary text-lg leading-tight font-bold transition-colors sm:text-xl md:text-2xl">
|
|
169
|
+
{article.title}
|
|
170
|
+
</h2>
|
|
171
|
+
|
|
172
|
+
<div className="flex flex-wrap items-center gap-x-3 gap-y-1 text-sm text-muted-foreground">
|
|
173
|
+
{article.author && (
|
|
174
|
+
<>
|
|
175
|
+
<span className="font-medium">{article.author.name}</span>
|
|
176
|
+
<span className="text-border">•</span>
|
|
177
|
+
</>
|
|
178
|
+
)}
|
|
179
|
+
<time
|
|
180
|
+
dateTime={article.publishedAt}
|
|
181
|
+
className="font-medium"
|
|
182
|
+
aria-label={`Published ${formattedDate}`}
|
|
183
|
+
>
|
|
184
|
+
{formattedDate}
|
|
185
|
+
</time>
|
|
186
|
+
<span className="text-border">•</span>
|
|
187
|
+
<span className="font-medium">{readingTime}</span>
|
|
188
|
+
</div>
|
|
189
|
+
|
|
190
|
+
{article.excerpt && (
|
|
191
|
+
<p className="text-muted-foreground line-clamp-3 text-sm leading-relaxed sm:text-base">
|
|
192
|
+
{article.excerpt}
|
|
193
|
+
</p>
|
|
194
|
+
)}
|
|
195
|
+
</div>
|
|
196
|
+
</div>
|
|
197
|
+
|
|
198
|
+
{/* Large Tablet/Desktop: Horizontal layout */}
|
|
199
|
+
<div className="hidden items-start gap-6 lg:flex xl:gap-8">
|
|
200
|
+
{imageUrl && (
|
|
201
|
+
<div className="relative aspect-[16/10] w-64 shrink-0 overflow-hidden rounded-lg xl:w-72">
|
|
202
|
+
<Image
|
|
203
|
+
src={imageUrl}
|
|
204
|
+
alt={
|
|
205
|
+
article.featuredImageAlt ??
|
|
206
|
+
`Featured image for ${article.title}`
|
|
207
|
+
}
|
|
208
|
+
width={288}
|
|
209
|
+
height={180}
|
|
210
|
+
className="h-full w-full object-cover transition-transform duration-300 group-hover:scale-105"
|
|
211
|
+
/>
|
|
212
|
+
</div>
|
|
213
|
+
)}
|
|
214
|
+
|
|
215
|
+
<div className="flex min-h-[180px] flex-1 flex-col justify-between space-y-4 xl:min-h-[200px]">
|
|
216
|
+
<div className="space-y-4">
|
|
217
|
+
<h2 className="group-hover:text-primary text-2xl leading-tight font-bold transition-colors xl:text-3xl">
|
|
218
|
+
{article.title}
|
|
219
|
+
</h2>
|
|
220
|
+
|
|
221
|
+
{article.excerpt && (
|
|
222
|
+
<p className="text-muted-foreground line-clamp-3 text-base leading-relaxed xl:text-lg">
|
|
223
|
+
{article.excerpt}
|
|
224
|
+
</p>
|
|
225
|
+
)}
|
|
226
|
+
</div>
|
|
227
|
+
|
|
228
|
+
<div className="flex flex-wrap items-center gap-x-3 gap-y-1 text-sm text-muted-foreground">
|
|
229
|
+
{article.author && (
|
|
230
|
+
<>
|
|
231
|
+
<span className="font-medium">{article.author.name}</span>
|
|
232
|
+
<span className="text-border">•</span>
|
|
233
|
+
</>
|
|
234
|
+
)}
|
|
235
|
+
<time
|
|
236
|
+
dateTime={article.publishedAt}
|
|
237
|
+
className="font-medium"
|
|
238
|
+
aria-label={`Published ${formattedDate}`}
|
|
239
|
+
>
|
|
240
|
+
{formattedDate}
|
|
241
|
+
</time>
|
|
242
|
+
<span className="text-border">•</span>
|
|
243
|
+
<span className="font-medium">{readingTime}</span>
|
|
244
|
+
</div>
|
|
245
|
+
</div>
|
|
246
|
+
</div>
|
|
247
|
+
</Link>
|
|
248
|
+
</article>
|
|
249
|
+
);
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
/**
|
|
253
|
+
* Generate metadata for the blog home page for SEO optimization.
|
|
254
|
+
* This function should be used in the Next.js metadata API.
|
|
255
|
+
*/
|
|
256
|
+
export function generateBlogHomeMetadata(
|
|
257
|
+
siteName?: string,
|
|
258
|
+
siteDescription?: string,
|
|
259
|
+
siteUrl?: string,
|
|
260
|
+
pagination?: {
|
|
261
|
+
currentPage: number;
|
|
262
|
+
totalPages: number;
|
|
263
|
+
},
|
|
264
|
+
) {
|
|
265
|
+
const baseTitle = siteName ? `${siteName} - Blog` : "Blog";
|
|
266
|
+
const title =
|
|
267
|
+
pagination && pagination.currentPage > 1
|
|
268
|
+
? `${baseTitle} - Page ${pagination.currentPage}`
|
|
269
|
+
: baseTitle;
|
|
270
|
+
|
|
271
|
+
const description =
|
|
272
|
+
siteDescription ?? "Read our latest articles and insights.";
|
|
273
|
+
|
|
274
|
+
// Generate pagination URLs for SEO
|
|
275
|
+
const baseUrl = "/blog";
|
|
276
|
+
const canonicalUrl = siteUrl
|
|
277
|
+
? `${siteUrl}${pagination && pagination.currentPage > 1 ? `${baseUrl}?page=${pagination.currentPage}` : baseUrl}`
|
|
278
|
+
: undefined;
|
|
279
|
+
|
|
280
|
+
const prevUrl =
|
|
281
|
+
pagination && pagination.currentPage > 1
|
|
282
|
+
? siteUrl
|
|
283
|
+
? `${siteUrl}${pagination.currentPage === 2 ? baseUrl : `${baseUrl}?page=${pagination.currentPage - 1}`}`
|
|
284
|
+
: undefined
|
|
285
|
+
: undefined;
|
|
286
|
+
|
|
287
|
+
const nextUrl =
|
|
288
|
+
pagination && pagination.currentPage < pagination.totalPages
|
|
289
|
+
? siteUrl
|
|
290
|
+
? `${siteUrl}${baseUrl}?page=${pagination.currentPage + 1}`
|
|
291
|
+
: undefined
|
|
292
|
+
: undefined;
|
|
293
|
+
|
|
294
|
+
return {
|
|
295
|
+
title,
|
|
296
|
+
description,
|
|
297
|
+
openGraph: {
|
|
298
|
+
title,
|
|
299
|
+
description,
|
|
300
|
+
type: "website",
|
|
301
|
+
...(siteUrl && { url: canonicalUrl }),
|
|
302
|
+
},
|
|
303
|
+
twitter: {
|
|
304
|
+
card: "summary_large_image",
|
|
305
|
+
title,
|
|
306
|
+
description,
|
|
307
|
+
},
|
|
308
|
+
alternates: {
|
|
309
|
+
...(canonicalUrl && { canonical: canonicalUrl }),
|
|
310
|
+
},
|
|
311
|
+
other: {
|
|
312
|
+
...(prevUrl && { prev: prevUrl }),
|
|
313
|
+
...(nextUrl && { next: nextUrl }),
|
|
314
|
+
},
|
|
315
|
+
};
|
|
316
|
+
}
|