@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.
Files changed (64) hide show
  1. package/README.md +65 -0
  2. package/about.md +93 -0
  3. package/api/authors/[username]/route.ts +150 -0
  4. package/api/authors/route.ts +63 -0
  5. package/api/posts/public/route.ts +151 -0
  6. package/components/ExportPostsButton.tsx +102 -0
  7. package/components/ImportPostsDialog.tsx +284 -0
  8. package/components/PostsToolbar.tsx +24 -0
  9. package/components/editor/FeaturedImageUpload.tsx +185 -0
  10. package/components/editor/WysiwygEditor.tsx +340 -0
  11. package/components/index.ts +4 -0
  12. package/components/public/AuthorBio.tsx +105 -0
  13. package/components/public/AuthorCard.tsx +130 -0
  14. package/components/public/BlogFooter.tsx +185 -0
  15. package/components/public/BlogNavbar.tsx +201 -0
  16. package/components/public/PostCard.tsx +306 -0
  17. package/components/public/ReadingProgress.tsx +70 -0
  18. package/components/public/RelatedPosts.tsx +78 -0
  19. package/config/app.config.ts +200 -0
  20. package/config/billing.config.ts +146 -0
  21. package/config/dashboard.config.ts +333 -0
  22. package/config/dev.config.ts +48 -0
  23. package/config/features.config.ts +196 -0
  24. package/config/flows.config.ts +333 -0
  25. package/config/permissions.config.ts +101 -0
  26. package/config/theme.config.ts +128 -0
  27. package/entities/categories/categories.config.ts +60 -0
  28. package/entities/categories/categories.fields.ts +115 -0
  29. package/entities/categories/categories.service.ts +333 -0
  30. package/entities/categories/categories.types.ts +58 -0
  31. package/entities/categories/messages/en.json +33 -0
  32. package/entities/categories/messages/es.json +33 -0
  33. package/entities/posts/messages/en.json +100 -0
  34. package/entities/posts/messages/es.json +100 -0
  35. package/entities/posts/migrations/001_posts_table.sql +110 -0
  36. package/entities/posts/migrations/002_add_featured.sql +19 -0
  37. package/entities/posts/migrations/003_post_categories_pivot.sql +47 -0
  38. package/entities/posts/posts.config.ts +61 -0
  39. package/entities/posts/posts.fields.ts +234 -0
  40. package/entities/posts/posts.service.ts +464 -0
  41. package/entities/posts/posts.types.ts +80 -0
  42. package/lib/selectors.ts +179 -0
  43. package/messages/en.json +113 -0
  44. package/messages/es.json +113 -0
  45. package/migrations/002_author_profile_fields.sql +37 -0
  46. package/migrations/003_categories_table.sql +90 -0
  47. package/migrations/999_sample_data.sql +412 -0
  48. package/migrations/999_theme_sample_data.sql +1070 -0
  49. package/package.json +18 -0
  50. package/permissions-matrix.md +63 -0
  51. package/styles/article.css +333 -0
  52. package/styles/components.css +204 -0
  53. package/styles/globals.css +327 -0
  54. package/styles/theme.css +167 -0
  55. package/templates/(public)/author/[username]/page.tsx +247 -0
  56. package/templates/(public)/authors/page.tsx +161 -0
  57. package/templates/(public)/layout.tsx +44 -0
  58. package/templates/(public)/page.tsx +276 -0
  59. package/templates/(public)/posts/[slug]/page.tsx +342 -0
  60. package/templates/dashboard/(main)/page.tsx +385 -0
  61. package/templates/dashboard/(main)/posts/[id]/edit/page.tsx +529 -0
  62. package/templates/dashboard/(main)/posts/[id]/page.tsx +33 -0
  63. package/templates/dashboard/(main)/posts/create/page.tsx +353 -0
  64. 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
+ }