@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
package/README.md ADDED
@@ -0,0 +1,65 @@
1
+ # Blog Theme
2
+
3
+ ## Activación
4
+
5
+ Para usar este theme, configura en `.env`:
6
+
7
+ ```bash
8
+ NEXT_PUBLIC_ACTIVE_THEME=blog
9
+ ```
10
+
11
+ Luego regenera el registry y reinicia el servidor:
12
+
13
+ ```bash
14
+ npx tsx scripts/build-registry.mjs --build
15
+ pnpm dev
16
+ ```
17
+
18
+ ## Funcionalidades
19
+
20
+ ### Export/Import JSON
21
+
22
+ El theme incluye funcionalidad de exportación e importación de posts en formato JSON:
23
+
24
+ - **Export**: Botón en la lista de posts que descarga todos los posts como `posts-{fecha}.json`
25
+ - **Import**: Dialog que permite cargar un archivo JSON con posts
26
+
27
+ ### Permisos
28
+
29
+ Los permisos están configurados en `permissions.config.ts`:
30
+
31
+ | Feature | Descripción | Roles |
32
+ |---------|-------------|-------|
33
+ | `posts.export_json` | Exportar posts a JSON | owner |
34
+ | `posts.import_json` | Importar posts desde JSON | owner |
35
+
36
+ ### Componentes
37
+
38
+ ```
39
+ components/
40
+ ├── ExportPostsButton.tsx # Botón de exportación
41
+ ├── ImportPostsDialog.tsx # Modal de importación
42
+ ├── PostsToolbar.tsx # Toolbar con ambos botones
43
+ └── index.ts # Exports
44
+ ```
45
+
46
+ ### Templates Override
47
+
48
+ ```
49
+ templates/
50
+ └── app/dashboard/(main)/posts/
51
+ └── page.tsx # Lista de posts con toolbar
52
+ ```
53
+
54
+ ## Usuario de Prueba
55
+
56
+ | Email | Password | Rol |
57
+ |-------|----------|-----|
58
+ | blogger@nextspark.dev | Testing1234 | owner |
59
+
60
+ ## Modo
61
+
62
+ - **Teams Mode**: `single-user`
63
+ - Sin colaboración
64
+ - Sin team switcher
65
+
package/about.md ADDED
@@ -0,0 +1,93 @@
1
+ # Blog Theme
2
+
3
+ ## Objetivo
4
+
5
+ Demostrar el modo `single-user` donde un único usuario tiene control total de su contenido sin colaboración ni equipos de trabajo.
6
+
7
+ ## Producto
8
+
9
+ **BlogSpace** - Plataforma de blogging personal para creadores de contenido independientes.
10
+
11
+ ## Empresa
12
+
13
+ **ContentFirst Inc.** - Startup enfocada en herramientas para creadores individuales que valoran la simplicidad y el control total sobre su contenido.
14
+
15
+ ## Teams Mode
16
+
17
+ ```
18
+ single-user
19
+ ```
20
+
21
+ - Personal team automático al registrarse
22
+ - Sin invitaciones
23
+ - Sin team switcher
24
+ - Sin creación de equipos adicionales
25
+
26
+ ## Entidades
27
+
28
+ | Entidad | Descripción |
29
+ |---------|-------------|
30
+ | posts | Artículos del blog con título, contenido, slug, imagen destacada, estado y categorías |
31
+
32
+ ## Features
33
+
34
+ | Feature | Descripción | Roles |
35
+ |---------|-------------|-------|
36
+ | posts.export_json | Exportar posts en formato JSON | owner |
37
+ | posts.import_json | Importar posts desde JSON | owner |
38
+
39
+ ## Permisos
40
+
41
+ | Entidad | create | read | update | delete |
42
+ |---------|:------:|:----:|:------:|:------:|
43
+ | posts | ✅ | ✅ | ✅ | ✅ |
44
+
45
+ ## Usuario de Prueba
46
+
47
+ | Email | Password | Rol |
48
+ |-------|----------|-----|
49
+ | blog_owner_alex@nextspark.dev | Test1234 | owner |
50
+
51
+ ## Billing
52
+
53
+ ### Modelo de Pricing: Suscripción Simple
54
+
55
+ > **Los planes y facturas siempre están asociados al team (que en single-user = 1 usuario).**
56
+
57
+ ### Planes Disponibles
58
+
59
+ | Plan | Mensual | Anual | Descuento |
60
+ |------|---------|-------|-----------|
61
+ | Free | $0 | $0 | - |
62
+ | Creator | $9/mes | $86/año | ~20% off |
63
+ | Pro | $19/mes | $182/año | ~20% off |
64
+
65
+ ### Características por Plan
66
+
67
+ | Feature | Free | Creator | Pro |
68
+ |---------|:----:|:-------:|:---:|
69
+ | Posts | 3 | Ilimitados | Ilimitados |
70
+ | Dominio custom | ❌ | ✅ | ✅ |
71
+ | Analytics | ❌ | Básicas | Avanzadas |
72
+ | SEO tools | ❌ | ❌ | ✅ |
73
+ | Branding plataforma | ✅ | ✅ | ❌ |
74
+ | Export JSON | ❌ | ✅ | ✅ |
75
+ | Import JSON | ❌ | ❌ | ✅ |
76
+
77
+ ### Facturación
78
+
79
+ - **Unidad de cobro:** Por team (1 team = 1 usuario)
80
+ - **Ciclos:** Mensual o anual (20% descuento)
81
+
82
+ ### Sample Invoices
83
+
84
+ | Team | Plan | Invoices | Status | Total |
85
+ |------|------|----------|--------|-------|
86
+ | Alex's Blog | Creator | 6 | 5 paid + 1 pending | $54 |
87
+
88
+ ## Casos de Uso
89
+
90
+ 1. Blogger independiente que publica artículos
91
+ 2. Portafolio personal con blog integrado
92
+ 3. Newsletter con archivo de contenido
93
+
@@ -0,0 +1,150 @@
1
+ /**
2
+ * Author Profile API Endpoint (Blog Theme)
3
+ *
4
+ * Returns public author profile with published posts.
5
+ * NO authentication required.
6
+ *
7
+ * URL: /api/v1/theme/blog/authors/{username}
8
+ */
9
+
10
+ import { NextRequest, NextResponse } from 'next/server'
11
+ import { queryWithRLS } from '@nextsparkjs/core/lib/db'
12
+
13
+ interface Author {
14
+ id: string
15
+ name: string | null
16
+ username: string | null
17
+ bio: string | null
18
+ image: string | null
19
+ socialTwitter: string | null
20
+ socialLinkedin: string | null
21
+ socialWebsite: string | null
22
+ }
23
+
24
+ interface Post {
25
+ id: string
26
+ title: string
27
+ slug: string
28
+ excerpt: string | null
29
+ featuredImage: string | null
30
+ status: string
31
+ publishedAt: string | null
32
+ featured: boolean
33
+ createdAt: string
34
+ }
35
+
36
+ interface RouteContext {
37
+ params: Promise<{
38
+ username: string
39
+ }>
40
+ }
41
+
42
+ export async function GET(
43
+ request: NextRequest,
44
+ context: RouteContext
45
+ ) {
46
+ try {
47
+ const { username } = await context.params
48
+
49
+ if (!username) {
50
+ return NextResponse.json(
51
+ { success: false, error: 'Username is required' },
52
+ { status: 400 }
53
+ )
54
+ }
55
+
56
+ // Get author profile
57
+ const authorQuery = `
58
+ SELECT
59
+ id,
60
+ name,
61
+ username,
62
+ bio,
63
+ image,
64
+ "social_twitter" as "socialTwitter",
65
+ "social_linkedin" as "socialLinkedin",
66
+ "social_website" as "socialWebsite"
67
+ FROM users
68
+ WHERE username = $1
69
+ `
70
+
71
+ const authors = await queryWithRLS<Author>(authorQuery, [username])
72
+
73
+ if (authors.length === 0) {
74
+ return NextResponse.json(
75
+ { success: false, error: 'Author not found' },
76
+ { status: 404 }
77
+ )
78
+ }
79
+
80
+ const author = authors[0]
81
+
82
+ // Get author's published posts
83
+ const postsQuery = `
84
+ SELECT
85
+ id,
86
+ title,
87
+ slug,
88
+ excerpt,
89
+ "featuredImage",
90
+ status,
91
+ "publishedAt",
92
+ featured,
93
+ "createdAt"
94
+ FROM posts
95
+ WHERE "userId" = $1
96
+ AND status = 'published'
97
+ AND "publishedAt" <= NOW()
98
+ ORDER BY "publishedAt" DESC
99
+ LIMIT 50
100
+ `
101
+
102
+ const posts = await queryWithRLS<Post>(postsQuery, [author.id])
103
+
104
+ // Get post count
105
+ const countQuery = `
106
+ SELECT COUNT(*) as total
107
+ FROM posts
108
+ WHERE "userId" = $1
109
+ AND status = 'published'
110
+ AND "publishedAt" <= NOW()
111
+ `
112
+
113
+ const countResult = await queryWithRLS<{ total: string }>(countQuery, [author.id])
114
+ const totalPosts = parseInt(countResult[0]?.total || '0')
115
+
116
+ // Return author profile with posts and stats
117
+ return NextResponse.json({
118
+ success: true,
119
+ data: {
120
+ author: {
121
+ id: author.id,
122
+ name: author.name,
123
+ username: author.username,
124
+ bio: author.bio,
125
+ image: author.image,
126
+ socialTwitter: author.socialTwitter,
127
+ socialLinkedin: author.socialLinkedin,
128
+ socialWebsite: author.socialWebsite
129
+ },
130
+ posts,
131
+ stats: {
132
+ totalPosts
133
+ }
134
+ }
135
+ })
136
+
137
+ } catch (error) {
138
+ console.error(`[API] /api/v1/theme/blog/authors/[username] - Error:`, error)
139
+
140
+ return NextResponse.json(
141
+ {
142
+ success: false,
143
+ error: process.env.NODE_ENV === 'production'
144
+ ? 'An error occurred while fetching author profile'
145
+ : (error as Error).message
146
+ },
147
+ { status: 500 }
148
+ )
149
+ }
150
+ }
@@ -0,0 +1,63 @@
1
+ /**
2
+ * Authors List API Endpoint (Blog Theme)
3
+ *
4
+ * Returns list of all authors with published posts.
5
+ * NO authentication required.
6
+ *
7
+ * URL: /api/v1/theme/blog/authors
8
+ */
9
+
10
+ import { NextRequest, NextResponse } from 'next/server'
11
+ import { queryWithRLS } from '@nextsparkjs/core/lib/db'
12
+
13
+ interface AuthorWithCount {
14
+ id: string
15
+ name: string | null
16
+ username: string | null
17
+ bio: string | null
18
+ image: string | null
19
+ postCount: number
20
+ }
21
+
22
+ export async function GET(request: NextRequest) {
23
+ try {
24
+ // Get all authors who have at least one published post
25
+ const authorsQuery = `
26
+ SELECT
27
+ u.id,
28
+ u.name,
29
+ u.username,
30
+ u.bio,
31
+ u.image,
32
+ COUNT(p.id)::integer as "postCount"
33
+ FROM users u
34
+ INNER JOIN posts p ON p."userId" = u.id
35
+ WHERE u.username IS NOT NULL
36
+ AND p.status = 'published'
37
+ AND p."publishedAt" <= NOW()
38
+ GROUP BY u.id, u.name, u.username, u.bio, u.image
39
+ HAVING COUNT(p.id) > 0
40
+ ORDER BY COUNT(p.id) DESC, u.name ASC
41
+ `
42
+
43
+ const authors = await queryWithRLS<AuthorWithCount>(authorsQuery, [])
44
+
45
+ return NextResponse.json({
46
+ success: true,
47
+ data: authors
48
+ })
49
+
50
+ } catch (error) {
51
+ console.error(`[API] /api/v1/theme/blog/authors - Error:`, error)
52
+
53
+ return NextResponse.json(
54
+ {
55
+ success: false,
56
+ error: process.env.NODE_ENV === 'production'
57
+ ? 'An error occurred while fetching authors'
58
+ : (error as Error).message
59
+ },
60
+ { status: 500 }
61
+ )
62
+ }
63
+ }
@@ -0,0 +1,151 @@
1
+ /**
2
+ * Public Posts API Endpoint (Blog Theme)
3
+ *
4
+ * Returns published posts from ALL authors for the public feed.
5
+ * NO authentication required.
6
+ * NO team_id filtering - shows posts cross-team.
7
+ *
8
+ * URL: /api/v1/theme/blog/posts/public
9
+ */
10
+
11
+ import { NextRequest, NextResponse } from 'next/server'
12
+ import { queryWithRLS } from '@nextsparkjs/core/lib/db'
13
+
14
+ interface Post {
15
+ id: string
16
+ title: string
17
+ slug: string
18
+ excerpt: string | null
19
+ content: string
20
+ featuredImage: string | null
21
+ status: string
22
+ publishedAt: string | null
23
+ featured: boolean
24
+ createdAt: string
25
+ updatedAt: string
26
+ userId: string
27
+ teamId: string
28
+ // Author data
29
+ authorName: string | null
30
+ authorUsername: string | null
31
+ authorImage: string | null
32
+ }
33
+
34
+ export async function GET(request: NextRequest) {
35
+ try {
36
+ const { searchParams } = new URL(request.url)
37
+
38
+ // Pagination parameters
39
+ const limit = Math.min(parseInt(searchParams.get('limit') || '10'), 100) // Max 100
40
+ const offset = parseInt(searchParams.get('offset') || '0')
41
+
42
+ // Filter parameters
43
+ const category = searchParams.get('category') // Optional category filter
44
+
45
+ // Build query
46
+ let query = `
47
+ SELECT
48
+ p.id,
49
+ p.title,
50
+ p.slug,
51
+ p.excerpt,
52
+ p.content,
53
+ p."featuredImage",
54
+ p.status,
55
+ p."publishedAt",
56
+ p.featured,
57
+ p."createdAt",
58
+ p."updatedAt",
59
+ p."userId",
60
+ p."teamId",
61
+ u.name as "authorName",
62
+ u.username as "authorUsername",
63
+ u.image as "authorImage"
64
+ FROM posts p
65
+ INNER JOIN users u ON p."userId" = u.id
66
+ WHERE p.status = 'published'
67
+ AND p."publishedAt" <= NOW()
68
+ `
69
+
70
+ const params: unknown[] = []
71
+ let paramCount = 0
72
+
73
+ // Add category filter if provided
74
+ if (category) {
75
+ paramCount++
76
+ query += `
77
+ AND EXISTS (
78
+ SELECT 1 FROM post_categories pc
79
+ INNER JOIN categories c ON pc."categoryId" = c.id
80
+ WHERE pc."postId" = p.id
81
+ AND c.slug = $${paramCount}
82
+ )
83
+ `
84
+ params.push(category)
85
+ }
86
+
87
+ // Order by published date (newest first)
88
+ query += ` ORDER BY p."publishedAt" DESC`
89
+
90
+ // Add pagination
91
+ paramCount++
92
+ query += ` LIMIT $${paramCount}`
93
+ params.push(limit)
94
+
95
+ paramCount++
96
+ query += ` OFFSET $${paramCount}`
97
+ params.push(offset)
98
+
99
+ // Execute query WITHOUT RLS context (public access)
100
+ const posts = await queryWithRLS<Post>(query, params)
101
+
102
+ // Get total count for pagination metadata
103
+ let countQuery = `
104
+ SELECT COUNT(*) as total
105
+ FROM posts p
106
+ WHERE p.status = 'published'
107
+ AND p."publishedAt" <= NOW()
108
+ `
109
+
110
+ const countParams: unknown[] = []
111
+ if (category) {
112
+ countQuery += `
113
+ AND EXISTS (
114
+ SELECT 1 FROM post_categories pc
115
+ INNER JOIN categories c ON pc."categoryId" = c.id
116
+ WHERE pc."postId" = p.id
117
+ AND c.slug = $1
118
+ )
119
+ `
120
+ countParams.push(category)
121
+ }
122
+
123
+ const countResult = await queryWithRLS<{ total: string }>(countQuery, countParams)
124
+ const total = parseInt(countResult[0]?.total || '0')
125
+
126
+ // Return response with pagination metadata
127
+ return NextResponse.json({
128
+ success: true,
129
+ data: posts,
130
+ pagination: {
131
+ total,
132
+ limit,
133
+ offset,
134
+ hasMore: offset + posts.length < total
135
+ }
136
+ })
137
+
138
+ } catch (error) {
139
+ console.error('[API] /api/v1/theme/blog/posts/public - Error:', error)
140
+
141
+ return NextResponse.json(
142
+ {
143
+ success: false,
144
+ error: process.env.NODE_ENV === 'production'
145
+ ? 'An error occurred while fetching posts'
146
+ : (error as Error).message
147
+ },
148
+ { status: 500 }
149
+ )
150
+ }
151
+ }
@@ -0,0 +1,102 @@
1
+ 'use client'
2
+
3
+ import { useState } from 'react'
4
+ import { Button } from '@nextsparkjs/core/components/ui/button'
5
+ import { Download, Loader2 } from 'lucide-react'
6
+ import { useToast } from '@nextsparkjs/core/hooks/useToast'
7
+
8
+ interface ExportPostsButtonProps {
9
+ className?: string
10
+ }
11
+
12
+ export function ExportPostsButton({ className }: ExportPostsButtonProps) {
13
+ const [isExporting, setIsExporting] = useState(false)
14
+ const { toast } = useToast()
15
+
16
+ const handleExport = async () => {
17
+ setIsExporting(true)
18
+
19
+ try {
20
+ const response = await fetch('/api/v1/posts?limit=1000', {
21
+ method: 'GET',
22
+ headers: {
23
+ 'Content-Type': 'application/json',
24
+ },
25
+ })
26
+
27
+ if (!response.ok) {
28
+ throw new Error('Failed to fetch posts')
29
+ }
30
+
31
+ const data = await response.json()
32
+ const posts = data.data || []
33
+
34
+ if (posts.length === 0) {
35
+ toast({
36
+ title: 'No posts to export',
37
+ description: 'Create some posts first before exporting.',
38
+ variant: 'default',
39
+ })
40
+ return
41
+ }
42
+
43
+ // Prepare export data (remove internal fields)
44
+ const exportData = posts.map((post: Record<string, unknown>) => ({
45
+ title: post.title,
46
+ slug: post.slug,
47
+ content: post.content,
48
+ excerpt: post.excerpt,
49
+ featuredImage: post.featuredImage,
50
+ status: post.status,
51
+ publishedAt: post.publishedAt,
52
+ category: post.category,
53
+ tags: post.tags,
54
+ }))
55
+
56
+ // Create and download file
57
+ const blob = new Blob([JSON.stringify(exportData, null, 2)], {
58
+ type: 'application/json',
59
+ })
60
+ const url = URL.createObjectURL(blob)
61
+ const link = document.createElement('a')
62
+ link.href = url
63
+ link.download = `posts-${new Date().toISOString().split('T')[0]}.json`
64
+ document.body.appendChild(link)
65
+ link.click()
66
+ document.body.removeChild(link)
67
+ URL.revokeObjectURL(url)
68
+
69
+ toast({
70
+ title: 'Export successful',
71
+ description: `${posts.length} posts exported to JSON.`,
72
+ })
73
+ } catch (error) {
74
+ console.error('Export error:', error)
75
+ toast({
76
+ title: 'Export failed',
77
+ description: 'Could not export posts. Please try again.',
78
+ variant: 'destructive',
79
+ })
80
+ } finally {
81
+ setIsExporting(false)
82
+ }
83
+ }
84
+
85
+ return (
86
+ <Button
87
+ variant="outline"
88
+ size="sm"
89
+ onClick={handleExport}
90
+ disabled={isExporting}
91
+ className={className}
92
+ >
93
+ {isExporting ? (
94
+ <Loader2 className="h-4 w-4 mr-2 animate-spin" />
95
+ ) : (
96
+ <Download className="h-4 w-4 mr-2" />
97
+ )}
98
+ Export JSON
99
+ </Button>
100
+ )
101
+ }
102
+