@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,385 @@
1
+ 'use client'
2
+
3
+ /**
4
+ * Blog Theme - Editorial Dashboard Home
5
+ *
6
+ * Simplified dashboard focused on content creation with:
7
+ * - Quick stats (total posts, drafts, published)
8
+ * - Recent posts list
9
+ * - Quick actions (write post, view blog)
10
+ */
11
+
12
+ import { useUserProfile } from '@nextsparkjs/core/hooks/useUserProfile'
13
+ import { useRouter } from 'next/navigation'
14
+ import { useEffect, useState } from 'react'
15
+ import Link from 'next/link'
16
+ import { Button } from '@nextsparkjs/core/components/ui/button'
17
+ import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@nextsparkjs/core/components/ui/card'
18
+ import { cn } from '@nextsparkjs/core/lib/utils'
19
+ import {
20
+ Loader2,
21
+ FileText,
22
+ FilePen,
23
+ CheckCircle,
24
+ Clock,
25
+ Plus,
26
+ ExternalLink,
27
+ ArrowRight,
28
+ PenLine,
29
+ Eye,
30
+ MoreHorizontal
31
+ } from 'lucide-react'
32
+
33
+ interface PostStats {
34
+ total: number
35
+ published: number
36
+ drafts: number
37
+ }
38
+
39
+ interface RecentPost {
40
+ id: string
41
+ title: string
42
+ status: string
43
+ createdAt: string
44
+ updatedAt: string
45
+ }
46
+
47
+ function getActiveTeamId(): string | null {
48
+ if (typeof window === 'undefined') return null
49
+ return localStorage.getItem('activeTeamId')
50
+ }
51
+
52
+ function buildHeaders(): HeadersInit {
53
+ const headers: HeadersInit = {
54
+ 'Content-Type': 'application/json',
55
+ }
56
+ const teamId = getActiveTeamId()
57
+ if (teamId) {
58
+ headers['x-team-id'] = teamId
59
+ }
60
+ return headers
61
+ }
62
+
63
+ async function fetchPosts(): Promise<{ stats: PostStats; recent: RecentPost[] }> {
64
+ try {
65
+ const headers = buildHeaders()
66
+
67
+ // Fetch all posts to get stats
68
+ const response = await fetch('/api/v1/posts?limit=100&sortBy=updatedAt&sortOrder=desc', {
69
+ credentials: 'include',
70
+ headers,
71
+ })
72
+
73
+ if (!response.ok) {
74
+ throw new Error('Failed to fetch posts')
75
+ }
76
+
77
+ const result = await response.json()
78
+ const posts = result.data || []
79
+
80
+ const stats: PostStats = {
81
+ total: posts.length,
82
+ published: posts.filter((p: RecentPost) => p.status === 'published').length,
83
+ drafts: posts.filter((p: RecentPost) => p.status === 'draft').length
84
+ }
85
+
86
+ const recent = posts.slice(0, 5).map((post: RecentPost) => ({
87
+ id: post.id,
88
+ title: post.title,
89
+ status: post.status,
90
+ createdAt: post.createdAt,
91
+ updatedAt: post.updatedAt
92
+ }))
93
+
94
+ return { stats, recent }
95
+ } catch (error) {
96
+ console.error('Error fetching posts:', error)
97
+ return {
98
+ stats: { total: 0, published: 0, drafts: 0 },
99
+ recent: []
100
+ }
101
+ }
102
+ }
103
+
104
+ function formatDate(dateString: string): string {
105
+ const date = new Date(dateString)
106
+ const now = new Date()
107
+ const diffMs = now.getTime() - date.getTime()
108
+ const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24))
109
+
110
+ if (diffDays === 0) {
111
+ const diffHours = Math.floor(diffMs / (1000 * 60 * 60))
112
+ if (diffHours === 0) {
113
+ const diffMins = Math.floor(diffMs / (1000 * 60))
114
+ return `${diffMins} min ago`
115
+ }
116
+ return `${diffHours}h ago`
117
+ }
118
+ if (diffDays === 1) return 'Yesterday'
119
+ if (diffDays < 7) return `${diffDays} days ago`
120
+
121
+ return date.toLocaleDateString('en-US', {
122
+ month: 'short',
123
+ day: 'numeric'
124
+ })
125
+ }
126
+
127
+ export default function BlogDashboardPage() {
128
+ const { user, isLoading: userLoading } = useUserProfile()
129
+ const router = useRouter()
130
+ const [stats, setStats] = useState<PostStats>({ total: 0, published: 0, drafts: 0 })
131
+ const [recentPosts, setRecentPosts] = useState<RecentPost[]>([])
132
+ const [dataLoading, setDataLoading] = useState(true)
133
+
134
+ useEffect(() => {
135
+ if (!userLoading && !user) {
136
+ router.push('/login')
137
+ }
138
+ }, [user, userLoading, router])
139
+
140
+ useEffect(() => {
141
+ async function loadData() {
142
+ setDataLoading(true)
143
+ const { stats, recent } = await fetchPosts()
144
+ setStats(stats)
145
+ setRecentPosts(recent)
146
+ setDataLoading(false)
147
+ }
148
+
149
+ if (user) {
150
+ loadData()
151
+ }
152
+ }, [user])
153
+
154
+ if (userLoading) {
155
+ return (
156
+ <div className="min-h-[60vh] flex items-center justify-center">
157
+ <Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
158
+ </div>
159
+ )
160
+ }
161
+
162
+ if (!user) {
163
+ return null
164
+ }
165
+
166
+ const getGreeting = () => {
167
+ const hour = new Date().getHours()
168
+ if (hour < 12) return 'Good morning'
169
+ if (hour < 18) return 'Good afternoon'
170
+ return 'Good evening'
171
+ }
172
+
173
+ return (
174
+ <div className="py-8 px-4 sm:px-6 lg:px-8">
175
+ <div className="max-w-5xl mx-auto space-y-8">
176
+ {/* Header */}
177
+ <div className="flex flex-col md:flex-row md:items-center md:justify-between gap-4">
178
+ <div>
179
+ <h1 className="text-2xl font-bold">
180
+ {getGreeting()}, {user.firstName || 'Writer'}
181
+ </h1>
182
+ <p className="text-muted-foreground mt-1">
183
+ Ready to write something amazing today?
184
+ </p>
185
+ </div>
186
+ <div className="flex gap-3">
187
+ <Link href="/" target="_blank">
188
+ <Button variant="outline" size="sm">
189
+ <ExternalLink className="h-4 w-4 mr-2" />
190
+ View Blog
191
+ </Button>
192
+ </Link>
193
+ <Link href="/dashboard/posts/create">
194
+ <Button size="sm">
195
+ <Plus className="h-4 w-4 mr-2" />
196
+ New Post
197
+ </Button>
198
+ </Link>
199
+ </div>
200
+ </div>
201
+
202
+ {/* Stats Grid */}
203
+ <div className="grid gap-4 md:grid-cols-3">
204
+ <Card>
205
+ <CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
206
+ <CardTitle className="text-sm font-medium text-muted-foreground">
207
+ Total Posts
208
+ </CardTitle>
209
+ <FileText className="h-4 w-4 text-muted-foreground" />
210
+ </CardHeader>
211
+ <CardContent>
212
+ <div className="text-3xl font-bold">
213
+ {dataLoading ? '-' : stats.total}
214
+ </div>
215
+ <p className="text-xs text-muted-foreground mt-1">
216
+ All time
217
+ </p>
218
+ </CardContent>
219
+ </Card>
220
+
221
+ <Card>
222
+ <CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
223
+ <CardTitle className="text-sm font-medium text-muted-foreground">
224
+ Published
225
+ </CardTitle>
226
+ <CheckCircle className="h-4 w-4 text-green-500" />
227
+ </CardHeader>
228
+ <CardContent>
229
+ <div className="text-3xl font-bold text-green-600">
230
+ {dataLoading ? '-' : stats.published}
231
+ </div>
232
+ <p className="text-xs text-muted-foreground mt-1">
233
+ Live on blog
234
+ </p>
235
+ </CardContent>
236
+ </Card>
237
+
238
+ <Card>
239
+ <CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
240
+ <CardTitle className="text-sm font-medium text-muted-foreground">
241
+ Drafts
242
+ </CardTitle>
243
+ <FilePen className="h-4 w-4 text-amber-500" />
244
+ </CardHeader>
245
+ <CardContent>
246
+ <div className="text-3xl font-bold text-amber-600">
247
+ {dataLoading ? '-' : stats.drafts}
248
+ </div>
249
+ <p className="text-xs text-muted-foreground mt-1">
250
+ In progress
251
+ </p>
252
+ </CardContent>
253
+ </Card>
254
+ </div>
255
+
256
+ {/* Recent Posts */}
257
+ <Card>
258
+ <CardHeader className="flex flex-row items-center justify-between">
259
+ <div>
260
+ <CardTitle>Recent Posts</CardTitle>
261
+ <CardDescription>
262
+ Your latest articles
263
+ </CardDescription>
264
+ </div>
265
+ <Link href="/dashboard/posts">
266
+ <Button variant="ghost" size="sm">
267
+ View all
268
+ <ArrowRight className="h-4 w-4 ml-1" />
269
+ </Button>
270
+ </Link>
271
+ </CardHeader>
272
+ <CardContent>
273
+ {dataLoading ? (
274
+ <div className="flex items-center justify-center py-8">
275
+ <Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
276
+ </div>
277
+ ) : recentPosts.length === 0 ? (
278
+ <div className="text-center py-8">
279
+ <PenLine className="h-12 w-12 text-muted-foreground/50 mx-auto mb-4" />
280
+ <h3 className="font-medium mb-2">No posts yet</h3>
281
+ <p className="text-sm text-muted-foreground mb-4">
282
+ Start writing your first blog post
283
+ </p>
284
+ <Link href="/dashboard/posts/create">
285
+ <Button>
286
+ <Plus className="h-4 w-4 mr-2" />
287
+ Create your first post
288
+ </Button>
289
+ </Link>
290
+ </div>
291
+ ) : (
292
+ <div className="space-y-1">
293
+ {recentPosts.map((post) => (
294
+ <div
295
+ key={post.id}
296
+ className="flex items-center justify-between p-3 rounded-lg hover:bg-muted/50 transition-colors group cursor-pointer"
297
+ onClick={() => router.push(`/dashboard/posts/${post.id}/edit`)}
298
+ >
299
+ <div className="flex-1 min-w-0">
300
+ <h4 className="font-medium text-sm truncate group-hover:text-primary transition-colors">
301
+ {post.title || 'Untitled'}
302
+ </h4>
303
+ <div className="flex items-center gap-2 mt-1">
304
+ <span
305
+ className={cn(
306
+ 'inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium border',
307
+ post.status === 'published'
308
+ ? 'bg-green-500/15 text-green-700 border-green-500/20 dark:bg-green-500/20 dark:text-green-400 dark:border-green-500/30'
309
+ : 'bg-amber-500/15 text-amber-700 border-amber-500/20 dark:bg-amber-500/20 dark:text-amber-400 dark:border-amber-500/30'
310
+ )}
311
+ >
312
+ {post.status === 'published' ? (
313
+ <>
314
+ <CheckCircle className="h-3 w-3 mr-1" />
315
+ Published
316
+ </>
317
+ ) : (
318
+ <>
319
+ <Clock className="h-3 w-3 mr-1" />
320
+ Draft
321
+ </>
322
+ )}
323
+ </span>
324
+ <span className="text-xs text-muted-foreground">
325
+ Updated {formatDate(post.updatedAt)}
326
+ </span>
327
+ </div>
328
+ </div>
329
+ <div className="flex items-center gap-2 opacity-0 group-hover:opacity-100 transition-opacity">
330
+ {post.status === 'published' && (
331
+ <Link
332
+ href={`/posts/${post.id}`}
333
+ target="_blank"
334
+ onClick={(e) => e.stopPropagation()}
335
+ >
336
+ <Button variant="ghost" size="icon" className="h-8 w-8">
337
+ <Eye className="h-4 w-4" />
338
+ </Button>
339
+ </Link>
340
+ )}
341
+ <Button variant="ghost" size="icon" className="h-8 w-8" onClick={(e) => e.stopPropagation()}>
342
+ <MoreHorizontal className="h-4 w-4" />
343
+ </Button>
344
+ </div>
345
+ </div>
346
+ ))}
347
+ </div>
348
+ )}
349
+ </CardContent>
350
+ </Card>
351
+
352
+ {/* Quick Actions */}
353
+ <div className="grid gap-4 md:grid-cols-2">
354
+ <Card className="cursor-pointer hover:shadow-md transition-shadow" onClick={() => router.push('/dashboard/posts/create')}>
355
+ <CardContent className="flex items-center gap-4 p-6">
356
+ <div className="h-12 w-12 rounded-full bg-primary/10 flex items-center justify-center">
357
+ <PenLine className="h-6 w-6 text-primary" />
358
+ </div>
359
+ <div>
360
+ <h3 className="font-semibold">Write a new post</h3>
361
+ <p className="text-sm text-muted-foreground">
362
+ Start creating your next article
363
+ </p>
364
+ </div>
365
+ </CardContent>
366
+ </Card>
367
+
368
+ <Card className="cursor-pointer hover:shadow-md transition-shadow" onClick={() => router.push('/dashboard/posts')}>
369
+ <CardContent className="flex items-center gap-4 p-6">
370
+ <div className="h-12 w-12 rounded-full bg-muted flex items-center justify-center">
371
+ <FileText className="h-6 w-6 text-muted-foreground" />
372
+ </div>
373
+ <div>
374
+ <h3 className="font-semibold">Manage posts</h3>
375
+ <p className="text-sm text-muted-foreground">
376
+ Edit, publish, or delete posts
377
+ </p>
378
+ </div>
379
+ </CardContent>
380
+ </Card>
381
+ </div>
382
+ </div>
383
+ </div>
384
+ )
385
+ }