@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,464 @@
1
+ /**
2
+ * Posts Service
3
+ *
4
+ * Provides data access methods for blog posts.
5
+ * Posts is a private entity - users only see posts in their team.
6
+ *
7
+ * All methods require authentication (use RLS with userId filter).
8
+ *
9
+ * @module PostsService
10
+ */
11
+
12
+ import { queryOneWithRLS, queryWithRLS, mutateWithRLS } from '@nextsparkjs/core/lib/db'
13
+
14
+ // Post status type
15
+ export type PostStatus = 'draft' | 'published' | 'scheduled'
16
+
17
+ // Post interface
18
+ export interface Post {
19
+ id: string
20
+ title: string
21
+ slug: string
22
+ excerpt?: string
23
+ content: string
24
+ featuredImage?: string
25
+ featured?: boolean
26
+ status: PostStatus
27
+ publishedAt?: string
28
+ createdAt: string
29
+ updatedAt: string
30
+ }
31
+
32
+ // List options
33
+ export interface PostListOptions {
34
+ limit?: number
35
+ offset?: number
36
+ status?: PostStatus
37
+ featured?: boolean
38
+ orderBy?: 'title' | 'publishedAt' | 'createdAt' | 'updatedAt'
39
+ orderDir?: 'asc' | 'desc'
40
+ teamId?: string
41
+ }
42
+
43
+ // List result
44
+ export interface PostListResult {
45
+ posts: Post[]
46
+ total: number
47
+ }
48
+
49
+ // Create data
50
+ export interface PostCreateData {
51
+ title: string
52
+ slug: string
53
+ excerpt?: string
54
+ content: string
55
+ featuredImage?: string
56
+ featured?: boolean
57
+ status?: PostStatus
58
+ publishedAt?: string
59
+ teamId: string
60
+ }
61
+
62
+ // Update data
63
+ export interface PostUpdateData {
64
+ title?: string
65
+ slug?: string
66
+ excerpt?: string
67
+ content?: string
68
+ featuredImage?: string
69
+ featured?: boolean
70
+ status?: PostStatus
71
+ publishedAt?: string
72
+ }
73
+
74
+ // Database row type
75
+ interface DbPost {
76
+ id: string
77
+ title: string
78
+ slug: string
79
+ excerpt: string | null
80
+ content: string
81
+ featuredImage: string | null
82
+ featured: boolean | null
83
+ status: PostStatus
84
+ publishedAt: string | null
85
+ createdAt: string
86
+ updatedAt: string
87
+ }
88
+
89
+ export class PostsService {
90
+ // ============================================
91
+ // READ METHODS
92
+ // ============================================
93
+
94
+ /**
95
+ * Get a post by ID
96
+ */
97
+ static async getById(id: string, userId: string): Promise<Post | null> {
98
+ try {
99
+ if (!id?.trim()) throw new Error('Post ID is required')
100
+ if (!userId?.trim()) throw new Error('User ID is required')
101
+
102
+ const post = await queryOneWithRLS<DbPost>(
103
+ `SELECT id, title, slug, excerpt, content, "featuredImage", featured, status, "publishedAt", "createdAt", "updatedAt"
104
+ FROM posts WHERE id = $1`,
105
+ [id],
106
+ userId
107
+ )
108
+
109
+ if (!post) return null
110
+
111
+ return {
112
+ id: post.id,
113
+ title: post.title,
114
+ slug: post.slug,
115
+ excerpt: post.excerpt ?? undefined,
116
+ content: post.content,
117
+ featuredImage: post.featuredImage ?? undefined,
118
+ featured: post.featured ?? undefined,
119
+ status: post.status,
120
+ publishedAt: post.publishedAt ?? undefined,
121
+ createdAt: post.createdAt,
122
+ updatedAt: post.updatedAt,
123
+ }
124
+ } catch (error) {
125
+ console.error('PostsService.getById error:', error)
126
+ throw new Error(error instanceof Error ? error.message : 'Failed to fetch post')
127
+ }
128
+ }
129
+
130
+ /**
131
+ * Get a post by slug
132
+ */
133
+ static async getBySlug(slug: string, userId: string): Promise<Post | null> {
134
+ try {
135
+ if (!slug?.trim()) throw new Error('Slug is required')
136
+ if (!userId?.trim()) throw new Error('User ID is required')
137
+
138
+ const post = await queryOneWithRLS<DbPost>(
139
+ `SELECT id, title, slug, excerpt, content, "featuredImage", featured, status, "publishedAt", "createdAt", "updatedAt"
140
+ FROM posts WHERE slug = $1`,
141
+ [slug],
142
+ userId
143
+ )
144
+
145
+ if (!post) return null
146
+
147
+ return {
148
+ id: post.id,
149
+ title: post.title,
150
+ slug: post.slug,
151
+ excerpt: post.excerpt ?? undefined,
152
+ content: post.content,
153
+ featuredImage: post.featuredImage ?? undefined,
154
+ featured: post.featured ?? undefined,
155
+ status: post.status,
156
+ publishedAt: post.publishedAt ?? undefined,
157
+ createdAt: post.createdAt,
158
+ updatedAt: post.updatedAt,
159
+ }
160
+ } catch (error) {
161
+ console.error('PostsService.getBySlug error:', error)
162
+ throw new Error(error instanceof Error ? error.message : 'Failed to fetch post')
163
+ }
164
+ }
165
+
166
+ /**
167
+ * List posts with pagination and filtering
168
+ */
169
+ static async list(userId: string, options: PostListOptions = {}): Promise<PostListResult> {
170
+ try {
171
+ if (!userId?.trim()) throw new Error('User ID is required')
172
+
173
+ const {
174
+ limit = 10,
175
+ offset = 0,
176
+ status,
177
+ featured,
178
+ orderBy = 'createdAt',
179
+ orderDir = 'desc',
180
+ teamId,
181
+ } = options
182
+
183
+ // Build WHERE clause
184
+ const conditions: string[] = []
185
+ const params: unknown[] = []
186
+ let paramIndex = 1
187
+
188
+ if (status) {
189
+ conditions.push(`status = $${paramIndex++}`)
190
+ params.push(status)
191
+ }
192
+
193
+ if (featured !== undefined) {
194
+ conditions.push(`featured = $${paramIndex++}`)
195
+ params.push(featured)
196
+ }
197
+
198
+ if (teamId) {
199
+ conditions.push(`"teamId" = $${paramIndex++}`)
200
+ params.push(teamId)
201
+ }
202
+
203
+ const whereClause = conditions.length > 0 ? `WHERE ${conditions.join(' AND ')}` : ''
204
+
205
+ // Validate orderBy
206
+ const validOrderBy = ['title', 'publishedAt', 'createdAt', 'updatedAt'].includes(orderBy) ? orderBy : 'createdAt'
207
+ const validOrderDir = orderDir === 'asc' ? 'ASC' : 'DESC'
208
+ const orderColumnMap: Record<string, string> = {
209
+ title: 'title',
210
+ publishedAt: '"publishedAt"',
211
+ createdAt: '"createdAt"',
212
+ updatedAt: '"updatedAt"',
213
+ }
214
+ const orderColumn = orderColumnMap[validOrderBy] || '"createdAt"'
215
+
216
+ // Get total count
217
+ const countResult = await queryWithRLS<{ count: string }>(
218
+ `SELECT COUNT(*)::text as count FROM posts ${whereClause}`,
219
+ params,
220
+ userId
221
+ )
222
+ const total = parseInt(countResult[0]?.count || '0', 10)
223
+
224
+ // Get posts
225
+ params.push(limit, offset)
226
+ const posts = await queryWithRLS<DbPost>(
227
+ `SELECT id, title, slug, excerpt, content, "featuredImage", featured, status, "publishedAt", "createdAt", "updatedAt"
228
+ FROM posts ${whereClause}
229
+ ORDER BY ${orderColumn} ${validOrderDir}
230
+ LIMIT $${paramIndex} OFFSET $${paramIndex + 1}`,
231
+ params,
232
+ userId
233
+ )
234
+
235
+ return {
236
+ posts: posts.map((post) => ({
237
+ id: post.id,
238
+ title: post.title,
239
+ slug: post.slug,
240
+ excerpt: post.excerpt ?? undefined,
241
+ content: post.content,
242
+ featuredImage: post.featuredImage ?? undefined,
243
+ featured: post.featured ?? undefined,
244
+ status: post.status,
245
+ publishedAt: post.publishedAt ?? undefined,
246
+ createdAt: post.createdAt,
247
+ updatedAt: post.updatedAt,
248
+ })),
249
+ total,
250
+ }
251
+ } catch (error) {
252
+ console.error('PostsService.list error:', error)
253
+ throw new Error(error instanceof Error ? error.message : 'Failed to list posts')
254
+ }
255
+ }
256
+
257
+ /**
258
+ * Get published posts (for public display)
259
+ */
260
+ static async getPublished(userId: string, limit = 10, offset = 0): Promise<PostListResult> {
261
+ return this.list(userId, {
262
+ status: 'published',
263
+ limit,
264
+ offset,
265
+ orderBy: 'publishedAt',
266
+ orderDir: 'desc',
267
+ })
268
+ }
269
+
270
+ /**
271
+ * Get featured posts
272
+ */
273
+ static async getFeatured(userId: string, limit = 5): Promise<Post[]> {
274
+ const { posts } = await this.list(userId, {
275
+ status: 'published',
276
+ featured: true,
277
+ limit,
278
+ orderBy: 'publishedAt',
279
+ orderDir: 'desc',
280
+ })
281
+ return posts
282
+ }
283
+
284
+ // ============================================
285
+ // WRITE METHODS
286
+ // ============================================
287
+
288
+ /**
289
+ * Create a new post
290
+ */
291
+ static async create(userId: string, data: PostCreateData): Promise<Post> {
292
+ try {
293
+ if (!userId?.trim()) throw new Error('User ID is required')
294
+ if (!data.title?.trim()) throw new Error('Title is required')
295
+ if (!data.slug?.trim()) throw new Error('Slug is required')
296
+ if (!data.content?.trim()) throw new Error('Content is required')
297
+ if (!data.teamId?.trim()) throw new Error('Team ID is required')
298
+
299
+ const id = crypto.randomUUID()
300
+ const now = new Date().toISOString()
301
+
302
+ const result = await mutateWithRLS<DbPost>(
303
+ `INSERT INTO posts (id, "userId", "teamId", title, slug, excerpt, content, "featuredImage", featured, status, "publishedAt", "createdAt", "updatedAt")
304
+ VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13)
305
+ RETURNING id, title, slug, excerpt, content, "featuredImage", featured, status, "publishedAt", "createdAt", "updatedAt"`,
306
+ [
307
+ id,
308
+ userId,
309
+ data.teamId,
310
+ data.title,
311
+ data.slug,
312
+ data.excerpt || null,
313
+ data.content,
314
+ data.featuredImage || null,
315
+ data.featured || false,
316
+ data.status || 'draft',
317
+ data.publishedAt || null,
318
+ now,
319
+ now,
320
+ ],
321
+ userId
322
+ )
323
+
324
+ if (!result.rows[0]) throw new Error('Failed to create post')
325
+
326
+ const post = result.rows[0]
327
+ return {
328
+ id: post.id,
329
+ title: post.title,
330
+ slug: post.slug,
331
+ excerpt: post.excerpt ?? undefined,
332
+ content: post.content,
333
+ featuredImage: post.featuredImage ?? undefined,
334
+ featured: post.featured ?? undefined,
335
+ status: post.status,
336
+ publishedAt: post.publishedAt ?? undefined,
337
+ createdAt: post.createdAt,
338
+ updatedAt: post.updatedAt,
339
+ }
340
+ } catch (error) {
341
+ console.error('PostsService.create error:', error)
342
+ throw new Error(error instanceof Error ? error.message : 'Failed to create post')
343
+ }
344
+ }
345
+
346
+ /**
347
+ * Update an existing post
348
+ */
349
+ static async update(userId: string, id: string, data: PostUpdateData): Promise<Post> {
350
+ try {
351
+ if (!userId?.trim()) throw new Error('User ID is required')
352
+ if (!id?.trim()) throw new Error('Post ID is required')
353
+
354
+ const updates: string[] = []
355
+ const values: unknown[] = []
356
+ let paramIndex = 1
357
+
358
+ if (data.title !== undefined) {
359
+ updates.push(`title = $${paramIndex++}`)
360
+ values.push(data.title)
361
+ }
362
+ if (data.slug !== undefined) {
363
+ updates.push(`slug = $${paramIndex++}`)
364
+ values.push(data.slug)
365
+ }
366
+ if (data.excerpt !== undefined) {
367
+ updates.push(`excerpt = $${paramIndex++}`)
368
+ values.push(data.excerpt || null)
369
+ }
370
+ if (data.content !== undefined) {
371
+ updates.push(`content = $${paramIndex++}`)
372
+ values.push(data.content)
373
+ }
374
+ if (data.featuredImage !== undefined) {
375
+ updates.push(`"featuredImage" = $${paramIndex++}`)
376
+ values.push(data.featuredImage || null)
377
+ }
378
+ if (data.featured !== undefined) {
379
+ updates.push(`featured = $${paramIndex++}`)
380
+ values.push(data.featured)
381
+ }
382
+ if (data.status !== undefined) {
383
+ updates.push(`status = $${paramIndex++}`)
384
+ values.push(data.status)
385
+ }
386
+ if (data.publishedAt !== undefined) {
387
+ updates.push(`"publishedAt" = $${paramIndex++}`)
388
+ values.push(data.publishedAt || null)
389
+ }
390
+
391
+ if (updates.length === 0) throw new Error('No fields to update')
392
+
393
+ updates.push(`"updatedAt" = $${paramIndex++}`)
394
+ values.push(new Date().toISOString())
395
+ values.push(id)
396
+
397
+ const result = await mutateWithRLS<DbPost>(
398
+ `UPDATE posts SET ${updates.join(', ')} WHERE id = $${paramIndex}
399
+ RETURNING id, title, slug, excerpt, content, "featuredImage", featured, status, "publishedAt", "createdAt", "updatedAt"`,
400
+ values,
401
+ userId
402
+ )
403
+
404
+ if (!result.rows[0]) throw new Error('Post not found or update failed')
405
+
406
+ const post = result.rows[0]
407
+ return {
408
+ id: post.id,
409
+ title: post.title,
410
+ slug: post.slug,
411
+ excerpt: post.excerpt ?? undefined,
412
+ content: post.content,
413
+ featuredImage: post.featuredImage ?? undefined,
414
+ featured: post.featured ?? undefined,
415
+ status: post.status,
416
+ publishedAt: post.publishedAt ?? undefined,
417
+ createdAt: post.createdAt,
418
+ updatedAt: post.updatedAt,
419
+ }
420
+ } catch (error) {
421
+ console.error('PostsService.update error:', error)
422
+ throw new Error(error instanceof Error ? error.message : 'Failed to update post')
423
+ }
424
+ }
425
+
426
+ /**
427
+ * Delete a post
428
+ */
429
+ static async delete(userId: string, id: string): Promise<boolean> {
430
+ try {
431
+ if (!userId?.trim()) throw new Error('User ID is required')
432
+ if (!id?.trim()) throw new Error('Post ID is required')
433
+
434
+ const result = await mutateWithRLS(`DELETE FROM posts WHERE id = $1`, [id], userId)
435
+ return result.rowCount > 0
436
+ } catch (error) {
437
+ console.error('PostsService.delete error:', error)
438
+ throw new Error(error instanceof Error ? error.message : 'Failed to delete post')
439
+ }
440
+ }
441
+
442
+ // ============================================
443
+ // PUBLISHING METHODS
444
+ // ============================================
445
+
446
+ /**
447
+ * Publish a post
448
+ */
449
+ static async publish(userId: string, id: string): Promise<Post> {
450
+ return this.update(userId, id, {
451
+ status: 'published',
452
+ publishedAt: new Date().toISOString(),
453
+ })
454
+ }
455
+
456
+ /**
457
+ * Unpublish a post (revert to draft)
458
+ */
459
+ static async unpublish(userId: string, id: string): Promise<Post> {
460
+ return this.update(userId, id, {
461
+ status: 'draft',
462
+ })
463
+ }
464
+ }
@@ -0,0 +1,80 @@
1
+ /**
2
+ * Posts Service Types
3
+ *
4
+ * Type definitions for the PostsService.
5
+ * Posts is a private entity - users only see posts in their team.
6
+ *
7
+ * @module PostsTypes
8
+ */
9
+
10
+ /**
11
+ * Post status values
12
+ */
13
+ export type PostStatus = 'draft' | 'published' | 'scheduled'
14
+
15
+ /**
16
+ * Post entity
17
+ */
18
+ export interface Post {
19
+ id: string
20
+ title: string
21
+ slug: string
22
+ excerpt?: string
23
+ content: string
24
+ featuredImage?: string
25
+ featured?: boolean
26
+ status: PostStatus
27
+ publishedAt?: string
28
+ createdAt: string
29
+ updatedAt: string
30
+ }
31
+
32
+ /**
33
+ * Options for listing posts
34
+ */
35
+ export interface PostListOptions {
36
+ limit?: number
37
+ offset?: number
38
+ status?: PostStatus
39
+ featured?: boolean
40
+ teamId?: string
41
+ orderBy?: 'title' | 'publishedAt' | 'createdAt' | 'updatedAt'
42
+ orderDir?: 'asc' | 'desc'
43
+ }
44
+
45
+ /**
46
+ * Result of listing posts with pagination
47
+ */
48
+ export interface PostListResult {
49
+ posts: Post[]
50
+ total: number
51
+ }
52
+
53
+ /**
54
+ * Data required to create a new post
55
+ */
56
+ export interface PostCreateData {
57
+ title: string
58
+ slug: string
59
+ content: string
60
+ teamId: string
61
+ excerpt?: string
62
+ featuredImage?: string
63
+ featured?: boolean
64
+ status?: PostStatus
65
+ publishedAt?: string
66
+ }
67
+
68
+ /**
69
+ * Data for updating an existing post
70
+ */
71
+ export interface PostUpdateData {
72
+ title?: string
73
+ slug?: string
74
+ excerpt?: string
75
+ content?: string
76
+ featuredImage?: string
77
+ featured?: boolean
78
+ status?: PostStatus
79
+ publishedAt?: string
80
+ }