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