@nextsparkjs/theme-blog 0.1.0-beta.1
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/README.md +65 -0
- package/about.md +93 -0
- package/api/authors/[username]/route.ts +150 -0
- package/api/authors/route.ts +63 -0
- package/api/posts/public/route.ts +151 -0
- package/components/ExportPostsButton.tsx +102 -0
- package/components/ImportPostsDialog.tsx +284 -0
- package/components/PostsToolbar.tsx +24 -0
- package/components/editor/FeaturedImageUpload.tsx +185 -0
- package/components/editor/WysiwygEditor.tsx +340 -0
- package/components/index.ts +4 -0
- package/components/public/AuthorBio.tsx +105 -0
- package/components/public/AuthorCard.tsx +130 -0
- package/components/public/BlogFooter.tsx +185 -0
- package/components/public/BlogNavbar.tsx +201 -0
- package/components/public/PostCard.tsx +306 -0
- package/components/public/ReadingProgress.tsx +70 -0
- package/components/public/RelatedPosts.tsx +78 -0
- package/config/app.config.ts +200 -0
- package/config/billing.config.ts +146 -0
- package/config/dashboard.config.ts +333 -0
- package/config/dev.config.ts +48 -0
- package/config/features.config.ts +196 -0
- package/config/flows.config.ts +333 -0
- package/config/permissions.config.ts +101 -0
- package/config/theme.config.ts +128 -0
- package/entities/categories/categories.config.ts +60 -0
- package/entities/categories/categories.fields.ts +115 -0
- package/entities/categories/categories.service.ts +333 -0
- package/entities/categories/categories.types.ts +58 -0
- package/entities/categories/messages/en.json +33 -0
- package/entities/categories/messages/es.json +33 -0
- package/entities/posts/messages/en.json +100 -0
- package/entities/posts/messages/es.json +100 -0
- package/entities/posts/migrations/001_posts_table.sql +110 -0
- package/entities/posts/migrations/002_add_featured.sql +19 -0
- package/entities/posts/migrations/003_post_categories_pivot.sql +47 -0
- package/entities/posts/posts.config.ts +61 -0
- package/entities/posts/posts.fields.ts +234 -0
- package/entities/posts/posts.service.ts +464 -0
- package/entities/posts/posts.types.ts +80 -0
- package/lib/selectors.ts +179 -0
- package/messages/en.json +113 -0
- package/messages/es.json +113 -0
- package/migrations/002_author_profile_fields.sql +37 -0
- package/migrations/003_categories_table.sql +90 -0
- package/migrations/999_sample_data.sql +412 -0
- package/migrations/999_theme_sample_data.sql +1070 -0
- package/package.json +18 -0
- package/permissions-matrix.md +63 -0
- package/styles/article.css +333 -0
- package/styles/components.css +204 -0
- package/styles/globals.css +327 -0
- package/styles/theme.css +167 -0
- package/templates/(public)/author/[username]/page.tsx +247 -0
- package/templates/(public)/authors/page.tsx +161 -0
- package/templates/(public)/layout.tsx +44 -0
- package/templates/(public)/page.tsx +276 -0
- package/templates/(public)/posts/[slug]/page.tsx +342 -0
- package/templates/dashboard/(main)/page.tsx +385 -0
- package/templates/dashboard/(main)/posts/[id]/edit/page.tsx +529 -0
- package/templates/dashboard/(main)/posts/[id]/page.tsx +33 -0
- package/templates/dashboard/(main)/posts/create/page.tsx +353 -0
- package/templates/dashboard/(main)/posts/page.tsx +833 -0
|
@@ -0,0 +1,276 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Blog Home Page
|
|
5
|
+
*
|
|
6
|
+
* Modern blog homepage with featured post hero, posts grid,
|
|
7
|
+
* and category filter tabs.
|
|
8
|
+
*
|
|
9
|
+
* Fetches real posts from the API - featured posts for the homepage display.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { useState, useEffect, useCallback } from 'react'
|
|
13
|
+
import { useTranslations } from 'next-intl'
|
|
14
|
+
import { PostCard } from '@/themes/blog/components/public/PostCard'
|
|
15
|
+
import { cn } from '@nextsparkjs/core/lib/utils'
|
|
16
|
+
import { Loader2 } from 'lucide-react'
|
|
17
|
+
|
|
18
|
+
interface Post {
|
|
19
|
+
id: string
|
|
20
|
+
title: string
|
|
21
|
+
slug: string
|
|
22
|
+
excerpt: string | null
|
|
23
|
+
featuredImage: string | null
|
|
24
|
+
category: string | null
|
|
25
|
+
status: string
|
|
26
|
+
featured: boolean
|
|
27
|
+
publishedAt: string | null
|
|
28
|
+
createdAt: string
|
|
29
|
+
// Author data (flat fields from API)
|
|
30
|
+
authorName: string | null
|
|
31
|
+
authorUsername: string | null
|
|
32
|
+
authorImage: string | null
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// Categories matching sample data (from 999_sample_data.sql)
|
|
36
|
+
const CATEGORIES = [
|
|
37
|
+
{ name: 'All', slug: 'all' },
|
|
38
|
+
// Marcos's categories
|
|
39
|
+
{ name: 'AI', slug: 'ai' },
|
|
40
|
+
{ name: 'SaaS', slug: 'saas' },
|
|
41
|
+
{ name: 'Startups', slug: 'startups' },
|
|
42
|
+
// Lucia's categories
|
|
43
|
+
{ name: 'Travel', slug: 'travel' },
|
|
44
|
+
{ name: 'Remote Work', slug: 'remote-work' },
|
|
45
|
+
{ name: 'Lifestyle', slug: 'lifestyle' },
|
|
46
|
+
// Carlos's categories
|
|
47
|
+
{ name: 'Investing', slug: 'investing' },
|
|
48
|
+
{ name: 'Personal Finance', slug: 'personal-finance' },
|
|
49
|
+
{ name: 'Entrepreneurship', slug: 'entrepreneurship' },
|
|
50
|
+
]
|
|
51
|
+
|
|
52
|
+
export default function BlogHomePage() {
|
|
53
|
+
const t = useTranslations('blog')
|
|
54
|
+
const [posts, setPosts] = useState<Post[]>([])
|
|
55
|
+
const [loading, setLoading] = useState(true)
|
|
56
|
+
const [error, setError] = useState<string | null>(null)
|
|
57
|
+
const [activeCategory, setActiveCategory] = useState('all')
|
|
58
|
+
const [visibleCount, setVisibleCount] = useState(6)
|
|
59
|
+
|
|
60
|
+
// Fetch public posts from API
|
|
61
|
+
const fetchFeaturedPosts = useCallback(async () => {
|
|
62
|
+
try {
|
|
63
|
+
setLoading(true)
|
|
64
|
+
setError(null)
|
|
65
|
+
|
|
66
|
+
// Fetch published posts from ALL authors via public endpoint
|
|
67
|
+
// This endpoint does not require authentication and aggregates posts cross-team
|
|
68
|
+
const response = await fetch('/api/v1/theme/blog/posts/public?limit=20', {
|
|
69
|
+
method: 'GET',
|
|
70
|
+
headers: {
|
|
71
|
+
'Content-Type': 'application/json',
|
|
72
|
+
},
|
|
73
|
+
})
|
|
74
|
+
|
|
75
|
+
if (!response.ok) {
|
|
76
|
+
throw new Error('Failed to fetch posts')
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const result = await response.json()
|
|
80
|
+
setPosts(result.data || [])
|
|
81
|
+
} catch (err) {
|
|
82
|
+
console.error('Error fetching posts:', err)
|
|
83
|
+
setError('Unable to load posts. Please try again later.')
|
|
84
|
+
} finally {
|
|
85
|
+
setLoading(false)
|
|
86
|
+
}
|
|
87
|
+
}, [])
|
|
88
|
+
|
|
89
|
+
useEffect(() => {
|
|
90
|
+
fetchFeaturedPosts()
|
|
91
|
+
}, [fetchFeaturedPosts])
|
|
92
|
+
|
|
93
|
+
// Filter posts by category
|
|
94
|
+
const filteredPosts = activeCategory === 'all'
|
|
95
|
+
? posts
|
|
96
|
+
: posts.filter(post => post.category === activeCategory)
|
|
97
|
+
|
|
98
|
+
const featuredPost = filteredPosts[0]
|
|
99
|
+
const remainingPosts = filteredPosts.slice(1, visibleCount)
|
|
100
|
+
const hasMore = filteredPosts.length > visibleCount
|
|
101
|
+
|
|
102
|
+
const loadMore = () => {
|
|
103
|
+
setVisibleCount(prev => prev + 6)
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// Calculate reading time (rough estimate: 200 words per minute)
|
|
107
|
+
const calculateReadingTime = (content: string | null): number => {
|
|
108
|
+
if (!content) return 3
|
|
109
|
+
const wordCount = content.split(/\s+/).length
|
|
110
|
+
return Math.max(1, Math.ceil(wordCount / 200))
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
return (
|
|
114
|
+
<div className="max-w-6xl mx-auto">
|
|
115
|
+
{/* Hero Section */}
|
|
116
|
+
<section className="text-center py-12 mb-8">
|
|
117
|
+
<h1 className="font-serif text-4xl md:text-5xl font-bold mb-4 tracking-tight">
|
|
118
|
+
{t('publicFeed.title')}
|
|
119
|
+
</h1>
|
|
120
|
+
<p className="text-xl text-muted-foreground max-w-2xl mx-auto">
|
|
121
|
+
{t('publicFeed.subtitle')}
|
|
122
|
+
</p>
|
|
123
|
+
</section>
|
|
124
|
+
|
|
125
|
+
{/* Category Filter */}
|
|
126
|
+
<section className="mb-8">
|
|
127
|
+
<div className="flex flex-wrap gap-2 justify-center">
|
|
128
|
+
{CATEGORIES.map((category) => (
|
|
129
|
+
<button
|
|
130
|
+
key={category.slug}
|
|
131
|
+
onClick={() => {
|
|
132
|
+
setActiveCategory(category.slug)
|
|
133
|
+
setVisibleCount(6)
|
|
134
|
+
}}
|
|
135
|
+
className={cn(
|
|
136
|
+
'px-4 py-2 text-sm font-medium rounded-full transition-all duration-200',
|
|
137
|
+
activeCategory === category.slug
|
|
138
|
+
? 'bg-primary text-primary-foreground'
|
|
139
|
+
: 'bg-muted text-muted-foreground hover:bg-muted/80 hover:text-foreground'
|
|
140
|
+
)}
|
|
141
|
+
>
|
|
142
|
+
{category.name}
|
|
143
|
+
</button>
|
|
144
|
+
))}
|
|
145
|
+
</div>
|
|
146
|
+
</section>
|
|
147
|
+
|
|
148
|
+
{/* Loading State */}
|
|
149
|
+
{loading && (
|
|
150
|
+
<div className="flex items-center justify-center py-16">
|
|
151
|
+
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
|
|
152
|
+
</div>
|
|
153
|
+
)}
|
|
154
|
+
|
|
155
|
+
{/* Error State */}
|
|
156
|
+
{error && !loading && (
|
|
157
|
+
<div className="text-center py-16">
|
|
158
|
+
<h3 className="text-xl font-medium text-muted-foreground mb-2">
|
|
159
|
+
{error}
|
|
160
|
+
</h3>
|
|
161
|
+
<button
|
|
162
|
+
onClick={fetchFeaturedPosts}
|
|
163
|
+
className="mt-4 px-6 py-2 text-sm font-medium bg-primary text-primary-foreground rounded-lg hover:bg-primary/90 transition-colors"
|
|
164
|
+
>
|
|
165
|
+
Try Again
|
|
166
|
+
</button>
|
|
167
|
+
</div>
|
|
168
|
+
)}
|
|
169
|
+
|
|
170
|
+
{/* No Posts State */}
|
|
171
|
+
{!loading && !error && filteredPosts.length === 0 && (
|
|
172
|
+
<div className="text-center py-16">
|
|
173
|
+
<h3 className="text-xl font-medium text-muted-foreground mb-2">
|
|
174
|
+
{t('publicFeed.noResults')}
|
|
175
|
+
</h3>
|
|
176
|
+
<p className="text-muted-foreground">
|
|
177
|
+
{activeCategory === 'all'
|
|
178
|
+
? t('noPostsDescription')
|
|
179
|
+
: t('category.noResults')}
|
|
180
|
+
</p>
|
|
181
|
+
</div>
|
|
182
|
+
)}
|
|
183
|
+
|
|
184
|
+
{/* Posts Content */}
|
|
185
|
+
{!loading && !error && filteredPosts.length > 0 && (
|
|
186
|
+
<>
|
|
187
|
+
{/* Featured Post */}
|
|
188
|
+
{featuredPost && (
|
|
189
|
+
<section className="mb-12">
|
|
190
|
+
<PostCard
|
|
191
|
+
id={featuredPost.id}
|
|
192
|
+
title={featuredPost.title}
|
|
193
|
+
slug={featuredPost.slug}
|
|
194
|
+
excerpt={featuredPost.excerpt}
|
|
195
|
+
featuredImage={featuredPost.featuredImage}
|
|
196
|
+
category={featuredPost.category}
|
|
197
|
+
categorySlug={featuredPost.category?.toLowerCase().replace(/\s+/g, '-') || null}
|
|
198
|
+
authorName={featuredPost.authorName || 'Anonymous'}
|
|
199
|
+
authorUsername={featuredPost.authorUsername || undefined}
|
|
200
|
+
authorAvatar={featuredPost.authorImage || undefined}
|
|
201
|
+
publishedAt={featuredPost.publishedAt || featuredPost.createdAt}
|
|
202
|
+
readingTime={calculateReadingTime(featuredPost.excerpt)}
|
|
203
|
+
variant="featured"
|
|
204
|
+
/>
|
|
205
|
+
</section>
|
|
206
|
+
)}
|
|
207
|
+
|
|
208
|
+
{/* Posts Grid */}
|
|
209
|
+
{remainingPosts.length > 0 && (
|
|
210
|
+
<section className="mb-12">
|
|
211
|
+
<h2 className="font-serif text-2xl font-bold mb-6">{t('publicFeed.recentPosts')}</h2>
|
|
212
|
+
<div className="grid gap-6 md:grid-cols-2 lg:grid-cols-3">
|
|
213
|
+
{remainingPosts.map((post) => (
|
|
214
|
+
<PostCard
|
|
215
|
+
key={post.id}
|
|
216
|
+
id={post.id}
|
|
217
|
+
title={post.title}
|
|
218
|
+
slug={post.slug}
|
|
219
|
+
excerpt={post.excerpt}
|
|
220
|
+
featuredImage={post.featuredImage}
|
|
221
|
+
category={post.category}
|
|
222
|
+
categorySlug={post.category?.toLowerCase().replace(/\s+/g, '-') || null}
|
|
223
|
+
authorName={post.authorName || 'Anonymous'}
|
|
224
|
+
authorUsername={post.authorUsername || undefined}
|
|
225
|
+
authorAvatar={post.authorImage || undefined}
|
|
226
|
+
publishedAt={post.publishedAt || post.createdAt}
|
|
227
|
+
readingTime={calculateReadingTime(post.excerpt)}
|
|
228
|
+
variant="default"
|
|
229
|
+
/>
|
|
230
|
+
))}
|
|
231
|
+
</div>
|
|
232
|
+
</section>
|
|
233
|
+
)}
|
|
234
|
+
|
|
235
|
+
{/* Load More */}
|
|
236
|
+
{hasMore && (
|
|
237
|
+
<div className="text-center py-8">
|
|
238
|
+
<button
|
|
239
|
+
onClick={loadMore}
|
|
240
|
+
className="px-8 py-3 text-sm font-medium bg-muted hover:bg-muted/80 text-foreground rounded-full transition-colors"
|
|
241
|
+
>
|
|
242
|
+
{t('publicFeed.loadMore')}
|
|
243
|
+
</button>
|
|
244
|
+
</div>
|
|
245
|
+
)}
|
|
246
|
+
</>
|
|
247
|
+
)}
|
|
248
|
+
|
|
249
|
+
{/* Newsletter CTA */}
|
|
250
|
+
<section className="my-16 p-8 md:p-12 rounded-2xl bg-muted/50 border border-border text-center">
|
|
251
|
+
<h2 className="font-serif text-2xl md:text-3xl font-bold mb-3">
|
|
252
|
+
{t('newsletter.title')}
|
|
253
|
+
</h2>
|
|
254
|
+
<p className="text-muted-foreground mb-6 max-w-md mx-auto">
|
|
255
|
+
{t('newsletter.description')}
|
|
256
|
+
</p>
|
|
257
|
+
<form
|
|
258
|
+
className="flex flex-col sm:flex-row gap-3 max-w-md mx-auto"
|
|
259
|
+
onSubmit={(e) => e.preventDefault()}
|
|
260
|
+
>
|
|
261
|
+
<input
|
|
262
|
+
type="email"
|
|
263
|
+
placeholder={t('newsletter.placeholder')}
|
|
264
|
+
className="flex-1 px-4 py-3 text-sm bg-background border border-input rounded-lg focus:outline-none focus:ring-2 focus:ring-ring"
|
|
265
|
+
/>
|
|
266
|
+
<button
|
|
267
|
+
type="submit"
|
|
268
|
+
className="px-6 py-3 text-sm font-medium bg-primary text-primary-foreground rounded-lg hover:bg-primary/90 transition-colors"
|
|
269
|
+
>
|
|
270
|
+
{t('newsletter.subscribe')}
|
|
271
|
+
</button>
|
|
272
|
+
</form>
|
|
273
|
+
</section>
|
|
274
|
+
</div>
|
|
275
|
+
)
|
|
276
|
+
}
|
|
@@ -0,0 +1,342 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Blog Post Page
|
|
5
|
+
*
|
|
6
|
+
* Clean, minimal single post view with reading progress bar,
|
|
7
|
+
* centered content, and optimized typography.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { notFound } from 'next/navigation'
|
|
11
|
+
import Link from 'next/link'
|
|
12
|
+
import Image from 'next/image'
|
|
13
|
+
import { ArrowLeft, Calendar, Clock, Loader2, Share2, Bookmark } from 'lucide-react'
|
|
14
|
+
import { use, useEffect, useState } from 'react'
|
|
15
|
+
import { ReadingProgress } from '@/themes/blog/components/public/ReadingProgress'
|
|
16
|
+
import { AuthorBio } from '@/themes/blog/components/public/AuthorBio'
|
|
17
|
+
import { RelatedPosts } from '@/themes/blog/components/public/RelatedPosts'
|
|
18
|
+
import { Button } from '@nextsparkjs/core/components/ui/button'
|
|
19
|
+
|
|
20
|
+
interface Post {
|
|
21
|
+
id: string
|
|
22
|
+
title: string
|
|
23
|
+
slug: string
|
|
24
|
+
content: string
|
|
25
|
+
excerpt: string | null
|
|
26
|
+
featuredImage: string | null
|
|
27
|
+
status: string
|
|
28
|
+
publishedAt: string | null
|
|
29
|
+
createdAt: string
|
|
30
|
+
updatedAt: string
|
|
31
|
+
category?: string | null
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
interface PageProps {
|
|
35
|
+
params: Promise<{
|
|
36
|
+
slug: string
|
|
37
|
+
}>
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function getActiveTeamId(): string | null {
|
|
41
|
+
if (typeof window === 'undefined') return null
|
|
42
|
+
return localStorage.getItem('activeTeamId')
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function buildHeaders(): HeadersInit {
|
|
46
|
+
const headers: HeadersInit = {
|
|
47
|
+
'Content-Type': 'application/json',
|
|
48
|
+
}
|
|
49
|
+
const teamId = getActiveTeamId()
|
|
50
|
+
if (teamId) {
|
|
51
|
+
headers['x-team-id'] = teamId
|
|
52
|
+
}
|
|
53
|
+
return headers
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
async function fetchPost(identifier: string): Promise<Post | null> {
|
|
57
|
+
try {
|
|
58
|
+
const headers = buildHeaders()
|
|
59
|
+
|
|
60
|
+
const byIdResponse = await fetch(`/api/v1/posts?ids=${encodeURIComponent(identifier)}&status=published&limit=1`, {
|
|
61
|
+
credentials: 'include',
|
|
62
|
+
headers,
|
|
63
|
+
})
|
|
64
|
+
|
|
65
|
+
if (byIdResponse.ok) {
|
|
66
|
+
const result = await byIdResponse.json()
|
|
67
|
+
if (result.data && result.data.length > 0) {
|
|
68
|
+
return result.data[0] as Post
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const bySlugResponse = await fetch(`/api/v1/posts?slug=${encodeURIComponent(identifier)}&status=published&limit=1`, {
|
|
73
|
+
credentials: 'include',
|
|
74
|
+
headers,
|
|
75
|
+
})
|
|
76
|
+
|
|
77
|
+
if (bySlugResponse.ok) {
|
|
78
|
+
const result = await bySlugResponse.json()
|
|
79
|
+
if (result.data && result.data.length > 0) {
|
|
80
|
+
return result.data[0] as Post
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
return null
|
|
85
|
+
} catch (error) {
|
|
86
|
+
console.error('Error fetching post:', error)
|
|
87
|
+
return null
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function formatDate(dateString: string | null): string {
|
|
92
|
+
if (!dateString) return 'Not published'
|
|
93
|
+
const date = new Date(dateString)
|
|
94
|
+
return date.toLocaleDateString('en-US', {
|
|
95
|
+
year: 'numeric',
|
|
96
|
+
month: 'long',
|
|
97
|
+
day: 'numeric',
|
|
98
|
+
timeZone: 'UTC'
|
|
99
|
+
})
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function toISODateString(dateString: string | null): string {
|
|
103
|
+
if (!dateString) return ''
|
|
104
|
+
return new Date(dateString).toISOString()
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function calculateReadTime(content: string): number {
|
|
108
|
+
const wordsPerMinute = 200
|
|
109
|
+
const text = content.replace(/<[^>]*>/g, '')
|
|
110
|
+
const words = text.split(/\s+/).filter(Boolean).length
|
|
111
|
+
return Math.max(1, Math.ceil(words / wordsPerMinute))
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function renderContent(content: string): string {
|
|
115
|
+
if (!content) return ''
|
|
116
|
+
|
|
117
|
+
if (content.includes('<h') || content.includes('<p>')) {
|
|
118
|
+
return content
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
return content
|
|
122
|
+
.replace(/^### (.*$)/gm, '<h3>$1</h3>')
|
|
123
|
+
.replace(/^## (.*$)/gm, '<h2>$1</h2>')
|
|
124
|
+
.replace(/^# (.*$)/gm, '<h1>$1</h1>')
|
|
125
|
+
.replace(/\*\*(.*?)\*\*/g, '<strong>$1</strong>')
|
|
126
|
+
.replace(/\*(.*?)\*/g, '<em>$1</em>')
|
|
127
|
+
.replace(/^- (.*$)/gm, '<li>$1</li>')
|
|
128
|
+
.replace(/```([\s\S]*?)```/g, '<pre><code>$1</code></pre>')
|
|
129
|
+
.replace(/`([^`]+)`/g, '<code>$1</code>')
|
|
130
|
+
.replace(/\n\n/g, '</p><p>')
|
|
131
|
+
.replace(/^(?!<)/, '<p>')
|
|
132
|
+
.replace(/(?!>)$/, '</p>')
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
interface RelatedPost {
|
|
136
|
+
id: string
|
|
137
|
+
title: string
|
|
138
|
+
slug: string
|
|
139
|
+
featuredImage?: string | null
|
|
140
|
+
content?: string | null
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
async function fetchRelatedPosts(currentPostId: string): Promise<RelatedPost[]> {
|
|
144
|
+
try {
|
|
145
|
+
const headers = buildHeaders()
|
|
146
|
+
|
|
147
|
+
// Fetch latest published posts
|
|
148
|
+
const response = await fetch('/api/v1/posts?status=published&limit=4&sortBy=publishedAt&sortOrder=desc', {
|
|
149
|
+
credentials: 'include',
|
|
150
|
+
headers,
|
|
151
|
+
})
|
|
152
|
+
|
|
153
|
+
if (!response.ok) return []
|
|
154
|
+
|
|
155
|
+
const result = await response.json()
|
|
156
|
+
const posts = (result.data || []) as RelatedPost[]
|
|
157
|
+
|
|
158
|
+
// Filter out the current post and take first 3
|
|
159
|
+
return posts
|
|
160
|
+
.filter((p: RelatedPost) => p.id !== currentPostId)
|
|
161
|
+
.slice(0, 3)
|
|
162
|
+
} catch (error) {
|
|
163
|
+
console.error('Error fetching related posts:', error)
|
|
164
|
+
return []
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
export default function PostPage({ params }: PageProps) {
|
|
169
|
+
const { slug } = use(params)
|
|
170
|
+
const [post, setPost] = useState<Post | null>(null)
|
|
171
|
+
const [relatedPosts, setRelatedPosts] = useState<RelatedPost[]>([])
|
|
172
|
+
const [loading, setLoading] = useState(true)
|
|
173
|
+
const [error, setError] = useState(false)
|
|
174
|
+
|
|
175
|
+
useEffect(() => {
|
|
176
|
+
async function loadPost() {
|
|
177
|
+
setLoading(true)
|
|
178
|
+
setError(false)
|
|
179
|
+
|
|
180
|
+
const fetchedPost = await fetchPost(slug)
|
|
181
|
+
|
|
182
|
+
if (!fetchedPost) {
|
|
183
|
+
setError(true)
|
|
184
|
+
} else {
|
|
185
|
+
setPost(fetchedPost)
|
|
186
|
+
|
|
187
|
+
// Fetch related posts after getting the current post
|
|
188
|
+
const related = await fetchRelatedPosts(fetchedPost.id)
|
|
189
|
+
setRelatedPosts(related)
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
setLoading(false)
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
loadPost()
|
|
196
|
+
}, [slug])
|
|
197
|
+
|
|
198
|
+
if (loading) {
|
|
199
|
+
return (
|
|
200
|
+
<div className="flex items-center justify-center min-h-[60vh]">
|
|
201
|
+
<Loader2 className="w-8 h-8 animate-spin text-muted-foreground" />
|
|
202
|
+
</div>
|
|
203
|
+
)
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
if (error || !post) {
|
|
207
|
+
notFound()
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
const readTime = calculateReadTime(post.content)
|
|
211
|
+
|
|
212
|
+
return (
|
|
213
|
+
<>
|
|
214
|
+
{/* Reading Progress Bar */}
|
|
215
|
+
<ReadingProgress />
|
|
216
|
+
|
|
217
|
+
<article className="max-w-4xl mx-auto">
|
|
218
|
+
{/* Back Link */}
|
|
219
|
+
<Link
|
|
220
|
+
href="/"
|
|
221
|
+
className="inline-flex items-center gap-2 text-sm text-muted-foreground hover:text-foreground mb-8 transition-colors"
|
|
222
|
+
>
|
|
223
|
+
<ArrowLeft className="w-4 h-4" />
|
|
224
|
+
Back to Blog
|
|
225
|
+
</Link>
|
|
226
|
+
|
|
227
|
+
{/* Article Header */}
|
|
228
|
+
<header className="mb-8 text-center">
|
|
229
|
+
{/* Category */}
|
|
230
|
+
{post.category && (
|
|
231
|
+
<Link
|
|
232
|
+
href={`/category/${post.category.toLowerCase()}`}
|
|
233
|
+
className="inline-block px-3 py-1 text-xs font-semibold rounded-full bg-primary/10 text-primary mb-4 hover:bg-primary/20 transition-colors"
|
|
234
|
+
>
|
|
235
|
+
{post.category}
|
|
236
|
+
</Link>
|
|
237
|
+
)}
|
|
238
|
+
|
|
239
|
+
{/* Title */}
|
|
240
|
+
<h1 className="font-serif text-3xl md:text-4xl lg:text-5xl font-bold tracking-tight mb-6 leading-tight">
|
|
241
|
+
{post.title}
|
|
242
|
+
</h1>
|
|
243
|
+
|
|
244
|
+
{/* Excerpt */}
|
|
245
|
+
{post.excerpt && (
|
|
246
|
+
<p className="text-lg md:text-xl text-muted-foreground mb-6 max-w-2xl mx-auto">
|
|
247
|
+
{post.excerpt}
|
|
248
|
+
</p>
|
|
249
|
+
)}
|
|
250
|
+
|
|
251
|
+
{/* Meta */}
|
|
252
|
+
<div className="flex items-center justify-center gap-4 text-sm text-muted-foreground">
|
|
253
|
+
<span className="flex items-center gap-1.5">
|
|
254
|
+
<Calendar className="w-4 h-4" />
|
|
255
|
+
<time dateTime={toISODateString(post.publishedAt)}>
|
|
256
|
+
{formatDate(post.publishedAt)}
|
|
257
|
+
</time>
|
|
258
|
+
</span>
|
|
259
|
+
<span className="text-border">|</span>
|
|
260
|
+
<span className="flex items-center gap-1.5">
|
|
261
|
+
<Clock className="w-4 h-4" />
|
|
262
|
+
{readTime} min read
|
|
263
|
+
</span>
|
|
264
|
+
</div>
|
|
265
|
+
|
|
266
|
+
{/* Actions */}
|
|
267
|
+
<div className="flex items-center justify-center gap-2 mt-6">
|
|
268
|
+
<Button variant="ghost" size="sm" className="text-muted-foreground">
|
|
269
|
+
<Share2 className="w-4 h-4 mr-1.5" />
|
|
270
|
+
Share
|
|
271
|
+
</Button>
|
|
272
|
+
<Button variant="ghost" size="sm" className="text-muted-foreground">
|
|
273
|
+
<Bookmark className="w-4 h-4 mr-1.5" />
|
|
274
|
+
Save
|
|
275
|
+
</Button>
|
|
276
|
+
</div>
|
|
277
|
+
</header>
|
|
278
|
+
|
|
279
|
+
{/* Featured Image */}
|
|
280
|
+
{post.featuredImage && (
|
|
281
|
+
<figure className="mb-12">
|
|
282
|
+
<div className="aspect-[21/9] relative overflow-hidden rounded-xl">
|
|
283
|
+
<Image
|
|
284
|
+
src={post.featuredImage}
|
|
285
|
+
alt={post.title}
|
|
286
|
+
fill
|
|
287
|
+
className="object-cover"
|
|
288
|
+
priority
|
|
289
|
+
sizes="(max-width: 768px) 100vw, 896px"
|
|
290
|
+
/>
|
|
291
|
+
</div>
|
|
292
|
+
</figure>
|
|
293
|
+
)}
|
|
294
|
+
|
|
295
|
+
{/* Article Content */}
|
|
296
|
+
<div className="article-content">
|
|
297
|
+
<div dangerouslySetInnerHTML={{ __html: renderContent(post.content) }} />
|
|
298
|
+
</div>
|
|
299
|
+
|
|
300
|
+
{/* Divider */}
|
|
301
|
+
<hr className="my-12 border-border" />
|
|
302
|
+
|
|
303
|
+
{/* Author Bio */}
|
|
304
|
+
<AuthorBio
|
|
305
|
+
name="John Writer"
|
|
306
|
+
bio="Full-stack developer and writer. Passionate about web technologies, open source, and sharing knowledge with the community."
|
|
307
|
+
socialLinks={[
|
|
308
|
+
{ type: 'twitter', url: 'https://twitter.com' },
|
|
309
|
+
{ type: 'github', url: 'https://github.com' }
|
|
310
|
+
]}
|
|
311
|
+
/>
|
|
312
|
+
|
|
313
|
+
{/* Related Posts */}
|
|
314
|
+
{relatedPosts.length > 0 && (
|
|
315
|
+
<div className="mt-12">
|
|
316
|
+
<RelatedPosts
|
|
317
|
+
posts={relatedPosts.map(p => ({
|
|
318
|
+
id: p.id,
|
|
319
|
+
title: p.title,
|
|
320
|
+
slug: p.slug,
|
|
321
|
+
featuredImage: p.featuredImage,
|
|
322
|
+
readingTime: p.content ? calculateReadTime(p.content) : 5,
|
|
323
|
+
}))}
|
|
324
|
+
title="You might also like"
|
|
325
|
+
/>
|
|
326
|
+
</div>
|
|
327
|
+
)}
|
|
328
|
+
|
|
329
|
+
{/* Bottom Navigation */}
|
|
330
|
+
<nav className="mt-12 pt-8 border-t border-border flex justify-between">
|
|
331
|
+
<Link
|
|
332
|
+
href="/"
|
|
333
|
+
className="inline-flex items-center gap-2 text-sm text-primary hover:underline"
|
|
334
|
+
>
|
|
335
|
+
<ArrowLeft className="w-4 h-4" />
|
|
336
|
+
Back to all posts
|
|
337
|
+
</Link>
|
|
338
|
+
</nav>
|
|
339
|
+
</article>
|
|
340
|
+
</>
|
|
341
|
+
)
|
|
342
|
+
}
|