@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,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
|
+
}
|