@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,247 @@
1
+ 'use client'
2
+
3
+ /**
4
+ * Author Profile Page
5
+ *
6
+ * Public profile page for a blog author showing their bio, social links,
7
+ * and all their published posts.
8
+ */
9
+
10
+ import { useState, useEffect, use } from 'react'
11
+ import Link from 'next/link'
12
+ import Image from 'next/image'
13
+ import { Twitter, Linkedin, Globe, User } from 'lucide-react'
14
+ import { PostCard } from '@/themes/blog/components/public/PostCard'
15
+ import { cn } from '@nextsparkjs/core/lib/utils'
16
+
17
+ interface Author {
18
+ id: string
19
+ name: string
20
+ username: string
21
+ bio: string | null
22
+ image: string | null
23
+ socialTwitter: string | null
24
+ socialLinkedin: string | null
25
+ socialWebsite: string | null
26
+ }
27
+
28
+ interface Post {
29
+ id: string
30
+ title: string
31
+ slug: string
32
+ excerpt: string | null
33
+ featuredImage: string | null
34
+ category: string | null
35
+ publishedAt: string | null
36
+ createdAt: string
37
+ }
38
+
39
+ interface Stats {
40
+ totalPosts: number
41
+ }
42
+
43
+ interface PageProps {
44
+ params: Promise<{
45
+ username: string
46
+ }>
47
+ }
48
+
49
+ export default function AuthorPage({ params }: PageProps) {
50
+ const { username } = use(params)
51
+ const [author, setAuthor] = useState<Author | null>(null)
52
+ const [posts, setPosts] = useState<Post[]>([])
53
+ const [stats, setStats] = useState<Stats | null>(null)
54
+ const [loading, setLoading] = useState(true)
55
+ const [error, setError] = useState<string | null>(null)
56
+
57
+ useEffect(() => {
58
+ const fetchAuthorData = async () => {
59
+ try {
60
+ setLoading(true)
61
+ setError(null)
62
+
63
+ const response = await fetch(`/api/v1/theme/blog/authors/${username}`, {
64
+ method: 'GET',
65
+ headers: {
66
+ 'Content-Type': 'application/json',
67
+ },
68
+ })
69
+
70
+ if (!response.ok) {
71
+ if (response.status === 404) {
72
+ throw new Error('Author not found')
73
+ }
74
+ throw new Error('Failed to load author profile')
75
+ }
76
+
77
+ const result = await response.json()
78
+ setAuthor(result.data.author)
79
+ setPosts(result.data.posts || [])
80
+ setStats(result.data.stats)
81
+ } catch (err) {
82
+ console.error('Error fetching author:', err)
83
+ setError(err instanceof Error ? err.message : 'Unable to load author profile')
84
+ } finally {
85
+ setLoading(false)
86
+ }
87
+ }
88
+
89
+ fetchAuthorData()
90
+ }, [username])
91
+
92
+ // Calculate reading time
93
+ const calculateReadingTime = (content: string | null): number => {
94
+ if (!content) return 3
95
+ const wordCount = content.split(/\s+/).length
96
+ return Math.max(1, Math.ceil(wordCount / 200))
97
+ }
98
+
99
+ if (loading) {
100
+ return (
101
+ <div className="max-w-4xl mx-auto">
102
+ <div className="flex items-center justify-center py-16">
103
+ <div className="text-center">
104
+ <div className="w-16 h-16 border-4 border-primary border-t-transparent rounded-full animate-spin mx-auto mb-4" />
105
+ <p className="text-muted-foreground">Loading author profile...</p>
106
+ </div>
107
+ </div>
108
+ </div>
109
+ )
110
+ }
111
+
112
+ if (error || !author) {
113
+ return (
114
+ <div className="max-w-4xl mx-auto">
115
+ <div className="text-center py-16">
116
+ <h2 className="text-2xl font-bold mb-2">Author Not Found</h2>
117
+ <p className="text-muted-foreground mb-6">
118
+ {error || 'The author you are looking for does not exist.'}
119
+ </p>
120
+ <Link
121
+ href="/"
122
+ className="inline-block px-6 py-3 bg-primary text-primary-foreground rounded-lg hover:bg-primary/90 transition-colors"
123
+ >
124
+ Back to Home
125
+ </Link>
126
+ </div>
127
+ </div>
128
+ )
129
+ }
130
+
131
+ return (
132
+ <div className="max-w-4xl mx-auto">
133
+ {/* Author Header */}
134
+ <section className="py-12 border-b border-border">
135
+ <div className="flex flex-col md:flex-row gap-8 items-start">
136
+ {/* Avatar */}
137
+ <div className="flex-shrink-0">
138
+ {author.image ? (
139
+ <Image
140
+ src={author.image}
141
+ alt={author.name}
142
+ width={128}
143
+ height={128}
144
+ className="rounded-full"
145
+ />
146
+ ) : (
147
+ <div className="w-32 h-32 rounded-full bg-gradient-to-br from-primary/20 to-primary/5 flex items-center justify-center">
148
+ <User className="w-16 h-16 text-muted-foreground" />
149
+ </div>
150
+ )}
151
+ </div>
152
+
153
+ {/* Info */}
154
+ <div className="flex-1">
155
+ <h1 className="font-serif text-3xl md:text-4xl font-bold mb-2">
156
+ {author.name}
157
+ </h1>
158
+ <p className="text-muted-foreground mb-4">@{author.username}</p>
159
+
160
+ {author.bio && (
161
+ <p className="text-lg mb-6 leading-relaxed">{author.bio}</p>
162
+ )}
163
+
164
+ {/* Social Links */}
165
+ <div className="flex items-center gap-4">
166
+ {author.socialTwitter && (
167
+ <a
168
+ href={author.socialTwitter}
169
+ target="_blank"
170
+ rel="noopener noreferrer"
171
+ className="flex items-center gap-2 text-sm text-muted-foreground hover:text-foreground transition-colors"
172
+ >
173
+ <Twitter className="w-4 h-4" />
174
+ <span className="sr-only">Twitter</span>
175
+ </a>
176
+ )}
177
+ {author.socialLinkedin && (
178
+ <a
179
+ href={author.socialLinkedin}
180
+ target="_blank"
181
+ rel="noopener noreferrer"
182
+ className="flex items-center gap-2 text-sm text-muted-foreground hover:text-foreground transition-colors"
183
+ >
184
+ <Linkedin className="w-4 h-4" />
185
+ <span className="sr-only">LinkedIn</span>
186
+ </a>
187
+ )}
188
+ {author.socialWebsite && (
189
+ <a
190
+ href={author.socialWebsite}
191
+ target="_blank"
192
+ rel="noopener noreferrer"
193
+ className="flex items-center gap-2 text-sm text-muted-foreground hover:text-foreground transition-colors"
194
+ >
195
+ <Globe className="w-4 h-4" />
196
+ <span className="sr-only">Website</span>
197
+ </a>
198
+ )}
199
+ </div>
200
+
201
+ {/* Stats */}
202
+ {stats && (
203
+ <div className="mt-6 inline-flex items-center gap-2 px-4 py-2 bg-muted rounded-full text-sm">
204
+ <span className="font-semibold">{stats.totalPosts}</span>
205
+ <span className="text-muted-foreground">
206
+ {stats.totalPosts === 1 ? 'published post' : 'published posts'}
207
+ </span>
208
+ </div>
209
+ )}
210
+ </div>
211
+ </div>
212
+ </section>
213
+
214
+ {/* Author's Posts */}
215
+ <section className="py-12">
216
+ <h2 className="font-serif text-2xl font-bold mb-8">Posts by {author.name}</h2>
217
+
218
+ {posts.length === 0 ? (
219
+ <div className="text-center py-12 text-muted-foreground">
220
+ <p>No published posts yet.</p>
221
+ </div>
222
+ ) : (
223
+ <div className="grid gap-6 md:grid-cols-2">
224
+ {posts.map((post) => (
225
+ <PostCard
226
+ key={post.id}
227
+ id={post.id}
228
+ title={post.title}
229
+ slug={post.slug}
230
+ excerpt={post.excerpt}
231
+ featuredImage={post.featuredImage}
232
+ category={post.category}
233
+ categorySlug={post.category?.toLowerCase().replace(/\s+/g, '-') || null}
234
+ authorName={author.name}
235
+ authorUsername={author.username}
236
+ authorAvatar={author.image}
237
+ publishedAt={post.publishedAt || post.createdAt}
238
+ readingTime={calculateReadingTime(post.excerpt)}
239
+ variant="default"
240
+ />
241
+ ))}
242
+ </div>
243
+ )}
244
+ </section>
245
+ </div>
246
+ )
247
+ }
@@ -0,0 +1,161 @@
1
+ 'use client'
2
+
3
+ /**
4
+ * Authors List Page
5
+ *
6
+ * Public page listing all blog authors with their profile info.
7
+ */
8
+
9
+ import { useState, useEffect } from 'react'
10
+ import Link from 'next/link'
11
+ import Image from 'next/image'
12
+ import { User } from 'lucide-react'
13
+
14
+ interface Author {
15
+ id: string
16
+ name: string
17
+ username: string
18
+ bio: string | null
19
+ image: string | null
20
+ postCount: number
21
+ }
22
+
23
+ export default function AuthorsPage() {
24
+ const [authors, setAuthors] = useState<Author[]>([])
25
+ const [loading, setLoading] = useState(true)
26
+ const [error, setError] = useState<string | null>(null)
27
+
28
+ useEffect(() => {
29
+ const fetchAuthors = async () => {
30
+ try {
31
+ setLoading(true)
32
+ setError(null)
33
+
34
+ const response = await fetch('/api/v1/theme/blog/authors', {
35
+ method: 'GET',
36
+ headers: {
37
+ 'Content-Type': 'application/json',
38
+ },
39
+ })
40
+
41
+ if (!response.ok) {
42
+ throw new Error('Failed to load authors')
43
+ }
44
+
45
+ const result = await response.json()
46
+ setAuthors(result.data || [])
47
+ } catch (err) {
48
+ console.error('Error fetching authors:', err)
49
+ setError(err instanceof Error ? err.message : 'Unable to load authors')
50
+ } finally {
51
+ setLoading(false)
52
+ }
53
+ }
54
+
55
+ fetchAuthors()
56
+ }, [])
57
+
58
+ if (loading) {
59
+ return (
60
+ <div className="max-w-4xl mx-auto">
61
+ <div className="flex items-center justify-center py-16">
62
+ <div className="text-center">
63
+ <div className="w-16 h-16 border-4 border-primary border-t-transparent rounded-full animate-spin mx-auto mb-4" />
64
+ <p className="text-muted-foreground">Loading authors...</p>
65
+ </div>
66
+ </div>
67
+ </div>
68
+ )
69
+ }
70
+
71
+ if (error) {
72
+ return (
73
+ <div className="max-w-4xl mx-auto">
74
+ <div className="text-center py-16">
75
+ <h2 className="text-2xl font-bold mb-2">Error Loading Authors</h2>
76
+ <p className="text-muted-foreground mb-6">{error}</p>
77
+ <Link
78
+ href="/"
79
+ className="inline-block px-6 py-3 bg-primary text-primary-foreground rounded-lg hover:bg-primary/90 transition-colors"
80
+ >
81
+ Back to Home
82
+ </Link>
83
+ </div>
84
+ </div>
85
+ )
86
+ }
87
+
88
+ return (
89
+ <div className="max-w-4xl mx-auto">
90
+ {/* Page Header */}
91
+ <section className="py-12 border-b border-border">
92
+ <h1 className="font-serif text-3xl md:text-4xl font-bold mb-4">
93
+ Our Authors
94
+ </h1>
95
+ <p className="text-lg text-muted-foreground">
96
+ Meet the writers who share their stories, ideas, and expertise on our platform.
97
+ </p>
98
+ </section>
99
+
100
+ {/* Authors Grid */}
101
+ <section className="py-12">
102
+ {authors.length === 0 ? (
103
+ <div className="text-center py-12 text-muted-foreground">
104
+ <p>No authors found.</p>
105
+ </div>
106
+ ) : (
107
+ <div className="grid gap-6 md:grid-cols-2 lg:grid-cols-3">
108
+ {authors.map((author) => (
109
+ <Link
110
+ key={author.id}
111
+ href={`/author/${author.username}`}
112
+ className="group block p-6 bg-card border border-border rounded-xl hover:border-primary/50 hover:shadow-lg transition-all"
113
+ >
114
+ {/* Avatar */}
115
+ <div className="flex justify-center mb-4">
116
+ {author.image ? (
117
+ <Image
118
+ src={author.image}
119
+ alt={author.name}
120
+ width={80}
121
+ height={80}
122
+ className="rounded-full"
123
+ />
124
+ ) : (
125
+ <div className="w-20 h-20 rounded-full bg-gradient-to-br from-primary/20 to-primary/5 flex items-center justify-center">
126
+ <User className="w-10 h-10 text-muted-foreground" />
127
+ </div>
128
+ )}
129
+ </div>
130
+
131
+ {/* Info */}
132
+ <div className="text-center">
133
+ <h2 className="font-serif text-xl font-bold group-hover:text-primary transition-colors">
134
+ {author.name}
135
+ </h2>
136
+ <p className="text-sm text-muted-foreground mb-3">
137
+ @{author.username}
138
+ </p>
139
+
140
+ {author.bio && (
141
+ <p className="text-sm text-muted-foreground line-clamp-2 mb-4">
142
+ {author.bio}
143
+ </p>
144
+ )}
145
+
146
+ {/* Post Count */}
147
+ <div className="inline-flex items-center gap-2 px-3 py-1 bg-muted rounded-full text-xs">
148
+ <span className="font-semibold">{author.postCount}</span>
149
+ <span className="text-muted-foreground">
150
+ {author.postCount === 1 ? 'post' : 'posts'}
151
+ </span>
152
+ </div>
153
+ </div>
154
+ </Link>
155
+ ))}
156
+ </div>
157
+ )}
158
+ </section>
159
+ </div>
160
+ )
161
+ }
@@ -0,0 +1,44 @@
1
+ 'use client'
2
+
3
+ /**
4
+ * Blog Public Layout
5
+ *
6
+ * Clean, minimal layout for the public-facing blog.
7
+ * Uses custom BlogNavbar and BlogFooter components.
8
+ */
9
+
10
+ import { BlogNavbar } from '@/themes/blog/components/public/BlogNavbar'
11
+ import { BlogFooter } from '@/themes/blog/components/public/BlogFooter'
12
+
13
+ interface BlogLayoutProps {
14
+ children: React.ReactNode
15
+ }
16
+
17
+ // Navigation links for the public blog
18
+ const NAV_LINKS = [
19
+ { name: 'Authors', href: '/authors' }
20
+ ]
21
+
22
+ export default function BlogLayout({ children }: BlogLayoutProps) {
23
+ return (
24
+ <div className="min-h-screen flex flex-col bg-background">
25
+ {/* Navigation */}
26
+ <BlogNavbar
27
+ blogTitle="My Blog"
28
+ navLinks={NAV_LINKS}
29
+ />
30
+
31
+ {/* Main Content */}
32
+ <main className="flex-1 container mx-auto px-4 py-8">
33
+ {children}
34
+ </main>
35
+
36
+ {/* Footer */}
37
+ <BlogFooter
38
+ blogTitle="My Blog"
39
+ authorName="Your Name"
40
+ showNewsletter={false}
41
+ />
42
+ </div>
43
+ )
44
+ }