@nextsparkjs/theme-blog 0.1.0-beta.19 → 0.1.0-beta.190
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/api/authors/[username]/route.ts +5 -2
- package/api/authors/docs.md +135 -0
- package/api/authors/presets.ts +45 -0
- package/api/authors/route.ts +4 -1
- package/api/posts/public/docs.md +124 -0
- package/api/posts/public/presets.ts +65 -0
- package/api/posts/public/route.ts +4 -1
- package/components/ExportPostsButton.tsx +2 -1
- package/components/ImportPostsDialog.tsx +2 -1
- package/components/editor/FeaturedImageUpload.tsx +2 -1
- package/components/editor/WysiwygEditor.tsx +56 -7
- package/components/public/AuthorBio.tsx +3 -2
- package/components/public/AuthorCard.tsx +3 -2
- package/components/public/BlogFooter.tsx +2 -1
- package/components/public/PostCard.tsx +8 -7
- package/components/public/RelatedPosts.tsx +2 -1
- package/config/app.config.ts +8 -15
- package/config/billing.config.ts +4 -7
- package/config/dashboard.config.ts +13 -0
- package/config/permissions.config.ts +11 -0
- package/entities/categories/api/docs.md +119 -0
- package/entities/categories/api/presets.ts +67 -0
- package/entities/posts/api/docs.md +174 -0
- package/entities/posts/api/presets.ts +137 -0
- package/lib/sanitize-post-html.ts +52 -0
- package/lib/selectors.ts +2 -3
- package/migrations/090_billing_sample_data.sql +99 -0
- package/migrations/999_theme_sample_data.sql +13 -13
- package/nextsparkjs-theme-blog-0.1.0-beta.137.tgz +0 -0
- package/package.json +13 -5
- package/styles/globals.css +58 -0
- package/templates/(public)/author/[username]/page.tsx +6 -5
- package/templates/(public)/authors/page.tsx +3 -2
- package/templates/(public)/page.tsx +2 -1
- package/templates/(public)/posts/[slug]/page.tsx +7 -5
- package/templates/dashboard/(main)/page.tsx +2 -1
- package/templates/dashboard/(main)/posts/[id]/edit/page.tsx +4 -3
- package/templates/dashboard/(main)/posts/create/page.tsx +2 -1
- package/templates/dashboard/(main)/posts/page.tsx +7 -6
- package/tests/cypress/e2e/README.md +170 -0
- package/tests/cypress/e2e/categories/categories-crud.cy.ts +322 -0
- package/tests/cypress/e2e/categories/categories-crud.md +73 -0
- package/tests/cypress/e2e/posts/posts-crud.cy.ts +460 -0
- package/tests/cypress/e2e/posts/posts-crud.md +115 -0
- package/tests/cypress/e2e/posts/posts-editor.cy.ts +290 -0
- package/tests/cypress/e2e/posts/posts-editor.md +139 -0
- package/tests/cypress/e2e/posts/posts-status-workflow.cy.ts +302 -0
- package/tests/cypress/e2e/posts/posts-status-workflow.md +83 -0
- package/tests/cypress/fixtures/blocks.json +9 -0
- package/tests/cypress/fixtures/entities.json +51 -0
- package/tests/cypress/src/FeaturedImageUpload.js +131 -0
- package/tests/cypress/src/PostEditor.js +386 -0
- package/tests/cypress/src/PostsList.js +350 -0
- package/tests/cypress/src/WysiwygEditor.js +373 -0
- package/tests/cypress/src/components/EntityForm.ts +378 -0
- package/tests/cypress/src/components/EntityList.ts +378 -0
- package/tests/cypress/src/components/PostEditorPOM.ts +447 -0
- package/tests/cypress/src/components/PostsPOM.ts +362 -0
- package/tests/cypress/src/components/index.ts +18 -0
- package/tests/cypress/src/index.js +33 -0
- package/tests/cypress/src/selectors.ts +49 -0
- package/tests/cypress/src/session-helpers.ts +151 -0
- package/tests/cypress/support/e2e.ts +90 -0
- package/tests/cypress.config.ts +154 -0
- package/tests/jest/__mocks__/jose.js +22 -0
- package/tests/jest/__mocks__/next-server.js +56 -0
- package/tests/jest/components/WysiwygEditor.test.tsx +117 -0
- package/tests/jest/jest.config.cjs +148 -0
- package/tests/jest/lib/sanitize-post-html.test.ts +47 -0
- package/tests/jest/setup.ts +170 -0
- package/tests/jest/tsconfig.jest.json +6 -0
- package/tests/tsconfig.json +15 -0
|
@@ -9,6 +9,7 @@
|
|
|
9
9
|
|
|
10
10
|
import { NextRequest, NextResponse } from 'next/server'
|
|
11
11
|
import { queryWithRLS } from '@nextsparkjs/core/lib/db'
|
|
12
|
+
import { withRateLimitTier } from '@nextsparkjs/core/lib/api/rate-limit'
|
|
12
13
|
|
|
13
14
|
interface Author {
|
|
14
15
|
id: string
|
|
@@ -39,10 +40,10 @@ interface RouteContext {
|
|
|
39
40
|
}>
|
|
40
41
|
}
|
|
41
42
|
|
|
42
|
-
|
|
43
|
+
const getHandler = async (
|
|
43
44
|
request: NextRequest,
|
|
44
45
|
context: RouteContext
|
|
45
|
-
) {
|
|
46
|
+
) => {
|
|
46
47
|
try {
|
|
47
48
|
const { username } = await context.params
|
|
48
49
|
|
|
@@ -148,3 +149,5 @@ export async function GET(
|
|
|
148
149
|
)
|
|
149
150
|
}
|
|
150
151
|
}
|
|
152
|
+
|
|
153
|
+
export const GET = withRateLimitTier(getHandler, 'read')
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
# Authors API
|
|
2
|
+
|
|
3
|
+
Public author profiles and post listings. No authentication required.
|
|
4
|
+
|
|
5
|
+
## Overview
|
|
6
|
+
|
|
7
|
+
The Authors API provides read-only access to author profiles and their published posts. This endpoint is designed for public consumption and does not require authentication.
|
|
8
|
+
|
|
9
|
+
## Authentication
|
|
10
|
+
|
|
11
|
+
**No authentication required** - This is a public endpoint.
|
|
12
|
+
|
|
13
|
+
## Endpoints
|
|
14
|
+
|
|
15
|
+
### List Authors
|
|
16
|
+
`GET /api/v1/theme/blog/authors`
|
|
17
|
+
|
|
18
|
+
Returns a list of all authors who have published posts.
|
|
19
|
+
|
|
20
|
+
**Example Request:**
|
|
21
|
+
```bash
|
|
22
|
+
curl https://example.com/api/v1/theme/blog/authors
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
**Example Response:**
|
|
26
|
+
```json
|
|
27
|
+
{
|
|
28
|
+
"data": [
|
|
29
|
+
{
|
|
30
|
+
"id": "user_abc123",
|
|
31
|
+
"name": "John Doe",
|
|
32
|
+
"username": "johndoe",
|
|
33
|
+
"bio": "Full-stack developer and tech writer",
|
|
34
|
+
"image": "https://example.com/avatar.jpg",
|
|
35
|
+
"postCount": 15
|
|
36
|
+
},
|
|
37
|
+
{
|
|
38
|
+
"id": "user_def456",
|
|
39
|
+
"name": "Jane Smith",
|
|
40
|
+
"username": "janesmith",
|
|
41
|
+
"bio": "Product designer and UX enthusiast",
|
|
42
|
+
"image": "https://example.com/avatar2.jpg",
|
|
43
|
+
"postCount": 8
|
|
44
|
+
}
|
|
45
|
+
]
|
|
46
|
+
}
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
### Get Author Profile
|
|
50
|
+
`GET /api/v1/theme/blog/authors/[username]`
|
|
51
|
+
|
|
52
|
+
Returns an author's profile with their published posts.
|
|
53
|
+
|
|
54
|
+
**Path Parameters:**
|
|
55
|
+
- `username` (string, required): Author's username
|
|
56
|
+
|
|
57
|
+
**Example Request:**
|
|
58
|
+
```bash
|
|
59
|
+
curl https://example.com/api/v1/theme/blog/authors/johndoe
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
**Example Response:**
|
|
63
|
+
```json
|
|
64
|
+
{
|
|
65
|
+
"author": {
|
|
66
|
+
"id": "user_abc123",
|
|
67
|
+
"name": "John Doe",
|
|
68
|
+
"username": "johndoe",
|
|
69
|
+
"bio": "Full-stack developer and tech writer",
|
|
70
|
+
"image": "https://example.com/avatar.jpg"
|
|
71
|
+
},
|
|
72
|
+
"posts": [
|
|
73
|
+
{
|
|
74
|
+
"id": "post_123",
|
|
75
|
+
"title": "Getting Started with Next.js",
|
|
76
|
+
"slug": "getting-started-with-nextjs",
|
|
77
|
+
"excerpt": "A comprehensive guide...",
|
|
78
|
+
"featuredImage": "https://example.com/image.jpg",
|
|
79
|
+
"publishedAt": "2024-01-15T10:30:00Z",
|
|
80
|
+
"categories": [
|
|
81
|
+
{
|
|
82
|
+
"id": "cat_123",
|
|
83
|
+
"name": "Technology",
|
|
84
|
+
"slug": "technology"
|
|
85
|
+
}
|
|
86
|
+
]
|
|
87
|
+
}
|
|
88
|
+
],
|
|
89
|
+
"stats": {
|
|
90
|
+
"totalPosts": 15
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
## Response Fields
|
|
96
|
+
|
|
97
|
+
### Author Object
|
|
98
|
+
|
|
99
|
+
| Field | Type | Description |
|
|
100
|
+
|-------|------|-------------|
|
|
101
|
+
| id | string | Author user ID |
|
|
102
|
+
| name | string | Author display name |
|
|
103
|
+
| username | string | Author username (URL-friendly) |
|
|
104
|
+
| bio | string | Author biography |
|
|
105
|
+
| image | string | Author avatar URL |
|
|
106
|
+
| postCount | number | Number of published posts (list endpoint only) |
|
|
107
|
+
|
|
108
|
+
### Author Profile Response
|
|
109
|
+
|
|
110
|
+
| Field | Type | Description |
|
|
111
|
+
|-------|------|-------------|
|
|
112
|
+
| author | object | Author profile information |
|
|
113
|
+
| posts | array | Array of published posts |
|
|
114
|
+
| stats.totalPosts | number | Total number of published posts |
|
|
115
|
+
|
|
116
|
+
## Features
|
|
117
|
+
|
|
118
|
+
- **No Auth Required**: Accessible without authentication
|
|
119
|
+
- **Rate Limited**: 100 requests per minute per IP
|
|
120
|
+
- **Cached**: Responses cached for 60 seconds
|
|
121
|
+
- **Sorted by Post Count**: Authors with more posts appear first
|
|
122
|
+
|
|
123
|
+
## Error Responses
|
|
124
|
+
|
|
125
|
+
| Status | Description |
|
|
126
|
+
|--------|-------------|
|
|
127
|
+
| 400 | Bad Request - Invalid parameters |
|
|
128
|
+
| 404 | Not Found - Author not found |
|
|
129
|
+
| 429 | Too Many Requests - Rate limit exceeded |
|
|
130
|
+
| 500 | Internal Server Error |
|
|
131
|
+
|
|
132
|
+
## Related APIs
|
|
133
|
+
|
|
134
|
+
- **[Public Posts](/api/v1/theme/blog/posts/public)** - Public post feed
|
|
135
|
+
- **[Posts](/api/v1/posts)** - Authenticated CRUD operations
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* API Presets for Authors Route
|
|
3
|
+
*
|
|
4
|
+
* Public author profiles and their published posts
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { defineApiEndpoint } from '@nextsparkjs/core/types/api-presets'
|
|
8
|
+
|
|
9
|
+
export default defineApiEndpoint({
|
|
10
|
+
endpoint: '/api/v1/theme/blog/authors',
|
|
11
|
+
summary: 'Public author profiles with published posts',
|
|
12
|
+
presets: [
|
|
13
|
+
{
|
|
14
|
+
id: 'list-all',
|
|
15
|
+
title: 'List All Authors',
|
|
16
|
+
description: 'Get all authors with published posts',
|
|
17
|
+
method: 'GET',
|
|
18
|
+
params: {
|
|
19
|
+
limit: 50
|
|
20
|
+
},
|
|
21
|
+
tags: ['read', 'list', 'public']
|
|
22
|
+
},
|
|
23
|
+
{
|
|
24
|
+
id: 'get-by-username',
|
|
25
|
+
title: 'Get Author by Username',
|
|
26
|
+
description: 'Get author profile with their posts',
|
|
27
|
+
method: 'GET',
|
|
28
|
+
pathParams: {
|
|
29
|
+
username: '{{username}}'
|
|
30
|
+
},
|
|
31
|
+
tags: ['read', 'detail', 'public']
|
|
32
|
+
},
|
|
33
|
+
{
|
|
34
|
+
id: 'list-with-post-count',
|
|
35
|
+
title: 'List with Post Count',
|
|
36
|
+
description: 'Get authors sorted by number of posts',
|
|
37
|
+
method: 'GET',
|
|
38
|
+
params: {
|
|
39
|
+
sortBy: 'postCount',
|
|
40
|
+
sortOrder: 'desc'
|
|
41
|
+
},
|
|
42
|
+
tags: ['read', 'list', 'public']
|
|
43
|
+
}
|
|
44
|
+
]
|
|
45
|
+
})
|
package/api/authors/route.ts
CHANGED
|
@@ -9,6 +9,7 @@
|
|
|
9
9
|
|
|
10
10
|
import { NextRequest, NextResponse } from 'next/server'
|
|
11
11
|
import { queryWithRLS } from '@nextsparkjs/core/lib/db'
|
|
12
|
+
import { withRateLimitTier } from '@nextsparkjs/core/lib/api/rate-limit'
|
|
12
13
|
|
|
13
14
|
interface AuthorWithCount {
|
|
14
15
|
id: string
|
|
@@ -19,7 +20,7 @@ interface AuthorWithCount {
|
|
|
19
20
|
postCount: number
|
|
20
21
|
}
|
|
21
22
|
|
|
22
|
-
|
|
23
|
+
const getHandler = async (request: NextRequest) => {
|
|
23
24
|
try {
|
|
24
25
|
// Get all authors who have at least one published post
|
|
25
26
|
const authorsQuery = `
|
|
@@ -61,3 +62,5 @@ export async function GET(request: NextRequest) {
|
|
|
61
62
|
)
|
|
62
63
|
}
|
|
63
64
|
}
|
|
65
|
+
|
|
66
|
+
export const GET = withRateLimitTier(getHandler, 'read')
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
# Public Posts API
|
|
2
|
+
|
|
3
|
+
Public feed of published blog posts. No authentication required.
|
|
4
|
+
|
|
5
|
+
## Overview
|
|
6
|
+
|
|
7
|
+
The Public Posts API provides read-only access to published blog posts. This endpoint is designed for public consumption by blog visitors and does not require authentication.
|
|
8
|
+
|
|
9
|
+
## Authentication
|
|
10
|
+
|
|
11
|
+
**No authentication required** - This is a public endpoint.
|
|
12
|
+
|
|
13
|
+
## Endpoints
|
|
14
|
+
|
|
15
|
+
### List Published Posts
|
|
16
|
+
`GET /api/v1/theme/blog/posts/public`
|
|
17
|
+
|
|
18
|
+
Returns a paginated list of published posts.
|
|
19
|
+
|
|
20
|
+
**Query Parameters:**
|
|
21
|
+
- `limit` (number, optional): Maximum records to return. Default: 20, Max: 100
|
|
22
|
+
- `offset` (number, optional): Number of records to skip. Default: 0
|
|
23
|
+
- `category` (string, optional): Filter by category slug
|
|
24
|
+
|
|
25
|
+
**Example Request:**
|
|
26
|
+
```bash
|
|
27
|
+
curl https://example.com/api/v1/theme/blog/posts/public?limit=10&category=technology
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
**Example Response:**
|
|
31
|
+
```json
|
|
32
|
+
{
|
|
33
|
+
"data": [
|
|
34
|
+
{
|
|
35
|
+
"id": "post_abc123",
|
|
36
|
+
"title": "Getting Started with Next.js",
|
|
37
|
+
"slug": "getting-started-with-nextjs",
|
|
38
|
+
"excerpt": "A comprehensive guide to building modern web apps",
|
|
39
|
+
"featuredImage": "https://example.com/image.jpg",
|
|
40
|
+
"featured": true,
|
|
41
|
+
"publishedAt": "2024-01-15T10:30:00Z",
|
|
42
|
+
"author": {
|
|
43
|
+
"id": "user_123",
|
|
44
|
+
"name": "John Doe",
|
|
45
|
+
"username": "johndoe",
|
|
46
|
+
"image": "https://example.com/avatar.jpg"
|
|
47
|
+
},
|
|
48
|
+
"categories": [
|
|
49
|
+
{
|
|
50
|
+
"id": "cat_123",
|
|
51
|
+
"name": "Technology",
|
|
52
|
+
"slug": "technology"
|
|
53
|
+
}
|
|
54
|
+
]
|
|
55
|
+
}
|
|
56
|
+
],
|
|
57
|
+
"pagination": {
|
|
58
|
+
"total": 25,
|
|
59
|
+
"limit": 10,
|
|
60
|
+
"offset": 0,
|
|
61
|
+
"hasMore": true
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
## Response Fields
|
|
67
|
+
|
|
68
|
+
| Field | Type | Description |
|
|
69
|
+
|-------|------|-------------|
|
|
70
|
+
| id | string | Post unique identifier |
|
|
71
|
+
| title | string | Post title |
|
|
72
|
+
| slug | string | URL-friendly slug |
|
|
73
|
+
| excerpt | string | Brief description |
|
|
74
|
+
| featuredImage | string | Featured image URL |
|
|
75
|
+
| featured | boolean | Whether post is featured |
|
|
76
|
+
| publishedAt | datetime | Publication timestamp |
|
|
77
|
+
| author | object | Author information |
|
|
78
|
+
| author.id | string | Author user ID |
|
|
79
|
+
| author.name | string | Author display name |
|
|
80
|
+
| author.username | string | Author username |
|
|
81
|
+
| author.image | string | Author avatar URL |
|
|
82
|
+
| categories | array | Associated categories |
|
|
83
|
+
|
|
84
|
+
## Features
|
|
85
|
+
|
|
86
|
+
- **No Auth Required**: Accessible without authentication
|
|
87
|
+
- **Rate Limited**: 100 requests per minute per IP
|
|
88
|
+
- **Cached**: Responses cached for 60 seconds
|
|
89
|
+
- **Category Filter**: Filter posts by category slug
|
|
90
|
+
|
|
91
|
+
## Pagination
|
|
92
|
+
|
|
93
|
+
The API uses offset-based pagination:
|
|
94
|
+
|
|
95
|
+
```json
|
|
96
|
+
{
|
|
97
|
+
"pagination": {
|
|
98
|
+
"total": 100,
|
|
99
|
+
"limit": 20,
|
|
100
|
+
"offset": 0,
|
|
101
|
+
"hasMore": true
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
To get the next page:
|
|
107
|
+
```
|
|
108
|
+
GET /api/v1/theme/blog/posts/public?offset=20&limit=20
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+
## Error Responses
|
|
112
|
+
|
|
113
|
+
| Status | Description |
|
|
114
|
+
|--------|-------------|
|
|
115
|
+
| 400 | Bad Request - Invalid query parameters |
|
|
116
|
+
| 404 | Not Found - Category not found |
|
|
117
|
+
| 429 | Too Many Requests - Rate limit exceeded |
|
|
118
|
+
| 500 | Internal Server Error |
|
|
119
|
+
|
|
120
|
+
## Related APIs
|
|
121
|
+
|
|
122
|
+
- **[Posts](/api/v1/posts)** - Authenticated CRUD operations
|
|
123
|
+
- **[Authors](/api/v1/theme/blog/authors)** - Author profiles
|
|
124
|
+
- **[Categories](/api/v1/categories)** - Category management
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* API Presets for Public Posts
|
|
3
|
+
*
|
|
4
|
+
* Public feed of published blog posts (no authentication required)
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { defineApiEndpoint } from '@nextsparkjs/core/types/api-presets'
|
|
8
|
+
|
|
9
|
+
export default defineApiEndpoint({
|
|
10
|
+
endpoint: '/api/v1/theme/blog/posts/public',
|
|
11
|
+
summary: 'Public feed of published blog posts',
|
|
12
|
+
presets: [
|
|
13
|
+
{
|
|
14
|
+
id: 'list-recent',
|
|
15
|
+
title: 'List Recent Posts',
|
|
16
|
+
description: 'Get recent published posts',
|
|
17
|
+
method: 'GET',
|
|
18
|
+
params: {
|
|
19
|
+
limit: 20
|
|
20
|
+
},
|
|
21
|
+
tags: ['read', 'list', 'public']
|
|
22
|
+
},
|
|
23
|
+
{
|
|
24
|
+
id: 'list-with-limit',
|
|
25
|
+
title: 'List with Custom Limit',
|
|
26
|
+
description: 'Get posts with specific limit',
|
|
27
|
+
method: 'GET',
|
|
28
|
+
params: {
|
|
29
|
+
limit: '{{limit}}'
|
|
30
|
+
},
|
|
31
|
+
tags: ['read', 'list', 'public']
|
|
32
|
+
},
|
|
33
|
+
{
|
|
34
|
+
id: 'list-by-category',
|
|
35
|
+
title: 'List by Category',
|
|
36
|
+
description: 'Get posts filtered by category slug',
|
|
37
|
+
method: 'GET',
|
|
38
|
+
params: {
|
|
39
|
+
category: '{{category}}'
|
|
40
|
+
},
|
|
41
|
+
tags: ['read', 'filter', 'public']
|
|
42
|
+
},
|
|
43
|
+
{
|
|
44
|
+
id: 'list-paginated',
|
|
45
|
+
title: 'List Paginated',
|
|
46
|
+
description: 'Get paginated posts',
|
|
47
|
+
method: 'GET',
|
|
48
|
+
params: {
|
|
49
|
+
limit: '{{limit}}',
|
|
50
|
+
offset: '{{offset}}'
|
|
51
|
+
},
|
|
52
|
+
tags: ['read', 'list', 'public']
|
|
53
|
+
},
|
|
54
|
+
{
|
|
55
|
+
id: 'list-featured',
|
|
56
|
+
title: 'List Featured Posts',
|
|
57
|
+
description: 'Get featured published posts',
|
|
58
|
+
method: 'GET',
|
|
59
|
+
params: {
|
|
60
|
+
featured: 'true'
|
|
61
|
+
},
|
|
62
|
+
tags: ['read', 'filter', 'public']
|
|
63
|
+
}
|
|
64
|
+
]
|
|
65
|
+
})
|
|
@@ -10,6 +10,7 @@
|
|
|
10
10
|
|
|
11
11
|
import { NextRequest, NextResponse } from 'next/server'
|
|
12
12
|
import { queryWithRLS } from '@nextsparkjs/core/lib/db'
|
|
13
|
+
import { withRateLimitTier } from '@nextsparkjs/core/lib/api/rate-limit'
|
|
13
14
|
|
|
14
15
|
interface Post {
|
|
15
16
|
id: string
|
|
@@ -31,7 +32,7 @@ interface Post {
|
|
|
31
32
|
authorImage: string | null
|
|
32
33
|
}
|
|
33
34
|
|
|
34
|
-
|
|
35
|
+
const getHandler = async (request: NextRequest) => {
|
|
35
36
|
try {
|
|
36
37
|
const { searchParams } = new URL(request.url)
|
|
37
38
|
|
|
@@ -149,3 +150,5 @@ export async function GET(request: NextRequest) {
|
|
|
149
150
|
)
|
|
150
151
|
}
|
|
151
152
|
}
|
|
153
|
+
|
|
154
|
+
export const GET = withRateLimitTier(getHandler, 'read')
|
|
@@ -4,6 +4,7 @@ import { useState } from 'react'
|
|
|
4
4
|
import { Button } from '@nextsparkjs/core/components/ui/button'
|
|
5
5
|
import { Download, Loader2 } from 'lucide-react'
|
|
6
6
|
import { useToast } from '@nextsparkjs/core/hooks/useToast'
|
|
7
|
+
import { withBasePath } from '@nextsparkjs/core/lib/base-path'
|
|
7
8
|
|
|
8
9
|
interface ExportPostsButtonProps {
|
|
9
10
|
className?: string
|
|
@@ -17,7 +18,7 @@ export function ExportPostsButton({ className }: ExportPostsButtonProps) {
|
|
|
17
18
|
setIsExporting(true)
|
|
18
19
|
|
|
19
20
|
try {
|
|
20
|
-
const response = await fetch('/api/v1/posts?limit=1000', {
|
|
21
|
+
const response = await fetch(withBasePath('/api/v1/posts?limit=1000'), {
|
|
21
22
|
method: 'GET',
|
|
22
23
|
headers: {
|
|
23
24
|
'Content-Type': 'application/json',
|
|
@@ -16,6 +16,7 @@ import { Badge } from '@nextsparkjs/core/components/ui/badge'
|
|
|
16
16
|
import { ScrollArea } from '@nextsparkjs/core/components/ui/scroll-area'
|
|
17
17
|
import { Upload, FileJson, AlertCircle, CheckCircle2, Loader2 } from 'lucide-react'
|
|
18
18
|
import { useToast } from '@nextsparkjs/core/hooks/useToast'
|
|
19
|
+
import { withBasePath } from '@nextsparkjs/core/lib/base-path'
|
|
19
20
|
|
|
20
21
|
interface PostImport {
|
|
21
22
|
title: string
|
|
@@ -108,7 +109,7 @@ export function ImportPostsDialog({ className, onImportComplete }: ImportPostsDi
|
|
|
108
109
|
const post = posts[i]
|
|
109
110
|
|
|
110
111
|
try {
|
|
111
|
-
const response = await fetch('/api/v1/posts', {
|
|
112
|
+
const response = await fetch(withBasePath('/api/v1/posts'), {
|
|
112
113
|
method: 'POST',
|
|
113
114
|
headers: {
|
|
114
115
|
'Content-Type': 'application/json',
|
|
@@ -12,6 +12,7 @@ import NextImage from 'next/image'
|
|
|
12
12
|
import { X, Upload, Image as ImageIcon } from 'lucide-react'
|
|
13
13
|
import { Button } from '@nextsparkjs/core/components/ui/button'
|
|
14
14
|
import { cn } from '@nextsparkjs/core/lib/utils'
|
|
15
|
+
import { withBasePathIfInApp } from '@nextsparkjs/core/lib/base-path'
|
|
15
16
|
|
|
16
17
|
interface FeaturedImageUploadProps {
|
|
17
18
|
value: string // URL string
|
|
@@ -111,7 +112,7 @@ export function FeaturedImageUpload({
|
|
|
111
112
|
// Image Preview
|
|
112
113
|
<div className="relative aspect-video rounded-lg overflow-hidden border border-border bg-muted" data-cy="featured-image-preview">
|
|
113
114
|
<NextImage
|
|
114
|
-
src={value}
|
|
115
|
+
src={withBasePathIfInApp(value)}
|
|
115
116
|
alt="Featured image preview"
|
|
116
117
|
fill
|
|
117
118
|
className="object-cover"
|
|
@@ -6,12 +6,15 @@
|
|
|
6
6
|
* A rich text editor built with native contentEditable.
|
|
7
7
|
* Provides formatting, markdown shortcuts, and a clean interface.
|
|
8
8
|
*
|
|
9
|
-
*
|
|
9
|
+
* Formatting uses document.execCommand. Stored HTML, and markup pasted or
|
|
10
|
+
* dropped in, is sanitised before it reaches the DOM, in the editable area and
|
|
11
|
+
* in the preview.
|
|
10
12
|
*/
|
|
11
13
|
|
|
12
14
|
import { useRef, useCallback, useEffect, useState } from 'react'
|
|
13
15
|
import { cn } from '@nextsparkjs/core/lib/utils'
|
|
14
16
|
import { Button } from '@nextsparkjs/core/components/ui/button'
|
|
17
|
+
import { postHtmlHasUnsafeMarkup, sanitizePostHtml } from '@/themes/blog/lib/sanitize-post-html'
|
|
15
18
|
import {
|
|
16
19
|
Bold,
|
|
17
20
|
Italic,
|
|
@@ -78,6 +81,22 @@ const TOOLBAR_GROUPS: ToolbarButton[][] = [
|
|
|
78
81
|
],
|
|
79
82
|
]
|
|
80
83
|
|
|
84
|
+
/** The caret position under a point, so a drop lands where the pointer is. */
|
|
85
|
+
function rangeAtPoint(x: number, y: number): Range | null {
|
|
86
|
+
const doc = document as Document & {
|
|
87
|
+
caretPositionFromPoint?: (x: number, y: number) => { offsetNode: Node; offset: number } | null
|
|
88
|
+
caretRangeFromPoint?: (x: number, y: number) => Range | null
|
|
89
|
+
}
|
|
90
|
+
const position = doc.caretPositionFromPoint?.(x, y)
|
|
91
|
+
if (position) {
|
|
92
|
+
const range = document.createRange()
|
|
93
|
+
range.setStart(position.offsetNode, position.offset)
|
|
94
|
+
range.collapse(true)
|
|
95
|
+
return range
|
|
96
|
+
}
|
|
97
|
+
return doc.caretRangeFromPoint?.(x, y) ?? null
|
|
98
|
+
}
|
|
99
|
+
|
|
81
100
|
export function WysiwygEditor({
|
|
82
101
|
value,
|
|
83
102
|
onChange,
|
|
@@ -91,15 +110,18 @@ export function WysiwygEditor({
|
|
|
91
110
|
const [isFocused, setIsFocused] = useState(false)
|
|
92
111
|
const isComposing = useRef(false)
|
|
93
112
|
|
|
94
|
-
//
|
|
113
|
+
// Fill the editable area. A value from outside (a loaded post) always goes in
|
|
114
|
+
// sanitised, and so does the value when leaving the preview mounts a new,
|
|
115
|
+
// empty area. One the area produced itself (typing, a paste, a command) is
|
|
116
|
+
// replaced only when sanitising would change it: rewriting it for a mere
|
|
117
|
+
// re-serialisation would move the caret to the start.
|
|
95
118
|
useEffect(() => {
|
|
96
119
|
if (editorRef.current && !isComposing.current) {
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
editorRef.current.innerHTML = value || ''
|
|
120
|
+
if (editorRef.current.innerHTML !== value || postHtmlHasUnsafeMarkup(value)) {
|
|
121
|
+
editorRef.current.innerHTML = sanitizePostHtml(value)
|
|
100
122
|
}
|
|
101
123
|
}
|
|
102
|
-
}, [value])
|
|
124
|
+
}, [value, isPreview])
|
|
103
125
|
|
|
104
126
|
// Auto focus
|
|
105
127
|
useEffect(() => {
|
|
@@ -115,6 +137,31 @@ export function WysiwygEditor({
|
|
|
115
137
|
}
|
|
116
138
|
}, [onChange])
|
|
117
139
|
|
|
140
|
+
// Markup pasted or dropped in that sanitising would change goes in sanitised,
|
|
141
|
+
// in place of the browser's own insertion. Replacing it once it is in the
|
|
142
|
+
// editable area is too late: an attribute such as onerror fires as soon as
|
|
143
|
+
// the element is in the document. Anything else keeps the browser's paste
|
|
144
|
+
// and drop, which is also how text moves within the editor.
|
|
145
|
+
const handlePaste = useCallback((e: React.ClipboardEvent) => {
|
|
146
|
+
const html = e.clipboardData.getData('text/html')
|
|
147
|
+
if (!postHtmlHasUnsafeMarkup(html)) return
|
|
148
|
+
e.preventDefault()
|
|
149
|
+
document.execCommand('insertHTML', false, sanitizePostHtml(html))
|
|
150
|
+
}, [])
|
|
151
|
+
|
|
152
|
+
const handleDrop = useCallback((e: React.DragEvent) => {
|
|
153
|
+
const html = e.dataTransfer.getData('text/html')
|
|
154
|
+
if (!postHtmlHasUnsafeMarkup(html)) return
|
|
155
|
+
e.preventDefault()
|
|
156
|
+
const range = rangeAtPoint(e.clientX, e.clientY)
|
|
157
|
+
if (range) {
|
|
158
|
+
const selection = window.getSelection()
|
|
159
|
+
selection?.removeAllRanges()
|
|
160
|
+
selection?.addRange(range)
|
|
161
|
+
}
|
|
162
|
+
document.execCommand('insertHTML', false, sanitizePostHtml(html))
|
|
163
|
+
}, [])
|
|
164
|
+
|
|
118
165
|
const execCommand = useCallback((command: string, value?: string) => {
|
|
119
166
|
if (command === 'createLink') {
|
|
120
167
|
const url = prompt('Enter URL:')
|
|
@@ -275,7 +322,7 @@ export function WysiwygEditor({
|
|
|
275
322
|
<div
|
|
276
323
|
className="flex-1 min-h-0 prose prose-sm max-w-none p-4 overflow-auto"
|
|
277
324
|
style={{ minHeight }}
|
|
278
|
-
dangerouslySetInnerHTML={{ __html: value
|
|
325
|
+
dangerouslySetInnerHTML={{ __html: value ? sanitizePostHtml(value) : '<p class="text-muted-foreground">Nothing to preview...</p>' }}
|
|
279
326
|
data-cy="wysiwyg-preview"
|
|
280
327
|
/>
|
|
281
328
|
) : (
|
|
@@ -302,6 +349,8 @@ export function WysiwygEditor({
|
|
|
302
349
|
)}
|
|
303
350
|
style={{ minHeight }}
|
|
304
351
|
onInput={handleInput}
|
|
352
|
+
onPaste={handlePaste}
|
|
353
|
+
onDrop={handleDrop}
|
|
305
354
|
onKeyDown={handleKeyDown}
|
|
306
355
|
onFocus={() => setIsFocused(true)}
|
|
307
356
|
onBlur={() => setIsFocused(false)}
|
|
@@ -9,6 +9,7 @@
|
|
|
9
9
|
import Image from 'next/image'
|
|
10
10
|
import Link from 'next/link'
|
|
11
11
|
import { Twitter, Github, Linkedin, Globe, User } from 'lucide-react'
|
|
12
|
+
import { withBasePathIfInApp } from '@nextsparkjs/core/lib/base-path'
|
|
12
13
|
|
|
13
14
|
interface SocialLink {
|
|
14
15
|
type: 'twitter' | 'github' | 'linkedin' | 'website'
|
|
@@ -43,7 +44,7 @@ export function AuthorBio({
|
|
|
43
44
|
<div data-cy="author-bio-avatar" className="flex-shrink-0">
|
|
44
45
|
{avatar ? (
|
|
45
46
|
<Image
|
|
46
|
-
src={avatar}
|
|
47
|
+
src={withBasePathIfInApp(avatar)}
|
|
47
48
|
alt={name}
|
|
48
49
|
width={64}
|
|
49
50
|
height={64}
|
|
@@ -84,7 +85,7 @@ export function AuthorBio({
|
|
|
84
85
|
return (
|
|
85
86
|
<a
|
|
86
87
|
key={link.type}
|
|
87
|
-
href={link.url}
|
|
88
|
+
href={withBasePathIfInApp(link.url)}
|
|
88
89
|
data-cy={`author-bio-social-${link.type}`}
|
|
89
90
|
target="_blank"
|
|
90
91
|
rel="noopener noreferrer"
|
|
@@ -11,6 +11,7 @@ import Link from 'next/link'
|
|
|
11
11
|
import Image from 'next/image'
|
|
12
12
|
import { User } from 'lucide-react'
|
|
13
13
|
import { cn } from '@nextsparkjs/core/lib/utils'
|
|
14
|
+
import { withBasePathIfInApp } from '@nextsparkjs/core/lib/base-path'
|
|
14
15
|
|
|
15
16
|
interface AuthorCardProps {
|
|
16
17
|
username: string
|
|
@@ -48,7 +49,7 @@ export function AuthorCard({
|
|
|
48
49
|
<div data-cy={`author-card-avatar-${username}`} className="flex-shrink-0">
|
|
49
50
|
{avatar ? (
|
|
50
51
|
<Image
|
|
51
|
-
src={avatar}
|
|
52
|
+
src={withBasePathIfInApp(avatar)}
|
|
52
53
|
alt={name}
|
|
53
54
|
width={48}
|
|
54
55
|
height={48}
|
|
@@ -89,7 +90,7 @@ export function AuthorCard({
|
|
|
89
90
|
<div data-cy={`author-card-avatar-${username}`} className="flex justify-center mb-4">
|
|
90
91
|
{avatar ? (
|
|
91
92
|
<Image
|
|
92
|
-
src={avatar}
|
|
93
|
+
src={withBasePathIfInApp(avatar)}
|
|
93
94
|
alt={name}
|
|
94
95
|
width={96}
|
|
95
96
|
height={96}
|
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
|
|
9
9
|
import Link from 'next/link'
|
|
10
10
|
import { Github, Twitter, Linkedin, Mail, Heart } from 'lucide-react'
|
|
11
|
+
import { withBasePathIfInApp } from '@nextsparkjs/core/lib/base-path'
|
|
11
12
|
|
|
12
13
|
interface SocialLink {
|
|
13
14
|
name: string
|
|
@@ -117,7 +118,7 @@ export function BlogFooter({
|
|
|
117
118
|
return (
|
|
118
119
|
<a
|
|
119
120
|
key={link.name}
|
|
120
|
-
href={link.href}
|
|
121
|
+
href={withBasePathIfInApp(link.href)}
|
|
121
122
|
data-cy={`blog-footer-social-${link.icon}`}
|
|
122
123
|
target="_blank"
|
|
123
124
|
rel="noopener noreferrer"
|