@nextsparkjs/theme-blog 0.1.0-beta.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +65 -0
- package/about.md +93 -0
- package/api/authors/[username]/route.ts +150 -0
- package/api/authors/route.ts +63 -0
- package/api/posts/public/route.ts +151 -0
- package/components/ExportPostsButton.tsx +102 -0
- package/components/ImportPostsDialog.tsx +284 -0
- package/components/PostsToolbar.tsx +24 -0
- package/components/editor/FeaturedImageUpload.tsx +185 -0
- package/components/editor/WysiwygEditor.tsx +340 -0
- package/components/index.ts +4 -0
- package/components/public/AuthorBio.tsx +105 -0
- package/components/public/AuthorCard.tsx +130 -0
- package/components/public/BlogFooter.tsx +185 -0
- package/components/public/BlogNavbar.tsx +201 -0
- package/components/public/PostCard.tsx +306 -0
- package/components/public/ReadingProgress.tsx +70 -0
- package/components/public/RelatedPosts.tsx +78 -0
- package/config/app.config.ts +200 -0
- package/config/billing.config.ts +146 -0
- package/config/dashboard.config.ts +333 -0
- package/config/dev.config.ts +48 -0
- package/config/features.config.ts +196 -0
- package/config/flows.config.ts +333 -0
- package/config/permissions.config.ts +101 -0
- package/config/theme.config.ts +128 -0
- package/entities/categories/categories.config.ts +60 -0
- package/entities/categories/categories.fields.ts +115 -0
- package/entities/categories/categories.service.ts +333 -0
- package/entities/categories/categories.types.ts +58 -0
- package/entities/categories/messages/en.json +33 -0
- package/entities/categories/messages/es.json +33 -0
- package/entities/posts/messages/en.json +100 -0
- package/entities/posts/messages/es.json +100 -0
- package/entities/posts/migrations/001_posts_table.sql +110 -0
- package/entities/posts/migrations/002_add_featured.sql +19 -0
- package/entities/posts/migrations/003_post_categories_pivot.sql +47 -0
- package/entities/posts/posts.config.ts +61 -0
- package/entities/posts/posts.fields.ts +234 -0
- package/entities/posts/posts.service.ts +464 -0
- package/entities/posts/posts.types.ts +80 -0
- package/lib/selectors.ts +179 -0
- package/messages/en.json +113 -0
- package/messages/es.json +113 -0
- package/migrations/002_author_profile_fields.sql +37 -0
- package/migrations/003_categories_table.sql +90 -0
- package/migrations/999_sample_data.sql +412 -0
- package/migrations/999_theme_sample_data.sql +1070 -0
- package/package.json +18 -0
- package/permissions-matrix.md +63 -0
- package/styles/article.css +333 -0
- package/styles/components.css +204 -0
- package/styles/globals.css +327 -0
- package/styles/theme.css +167 -0
- package/templates/(public)/author/[username]/page.tsx +247 -0
- package/templates/(public)/authors/page.tsx +161 -0
- package/templates/(public)/layout.tsx +44 -0
- package/templates/(public)/page.tsx +276 -0
- package/templates/(public)/posts/[slug]/page.tsx +342 -0
- package/templates/dashboard/(main)/page.tsx +385 -0
- package/templates/dashboard/(main)/posts/[id]/edit/page.tsx +529 -0
- package/templates/dashboard/(main)/posts/[id]/page.tsx +33 -0
- package/templates/dashboard/(main)/posts/create/page.tsx +353 -0
- package/templates/dashboard/(main)/posts/page.tsx +833 -0
|
@@ -0,0 +1,353 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Blog Theme - Create Post Page
|
|
5
|
+
*
|
|
6
|
+
* Full-width WYSIWYG editor with side panel for metadata.
|
|
7
|
+
* Designed for distraction-free writing.
|
|
8
|
+
*
|
|
9
|
+
* Layout: Works within dashboard layout with fixed sub-topbar, fixed title,
|
|
10
|
+
* fixed right sidebar, and scrollable content editor only.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { useState, useCallback, useEffect } from 'react'
|
|
14
|
+
import { useRouter } from 'next/navigation'
|
|
15
|
+
import Link from 'next/link'
|
|
16
|
+
import { ArrowLeft, Save, Eye, Loader2, Check, X } from 'lucide-react'
|
|
17
|
+
import { Button } from '@nextsparkjs/core/components/ui/button'
|
|
18
|
+
import { Input } from '@nextsparkjs/core/components/ui/input'
|
|
19
|
+
import { Label } from '@nextsparkjs/core/components/ui/label'
|
|
20
|
+
import { Textarea } from '@nextsparkjs/core/components/ui/textarea'
|
|
21
|
+
import {
|
|
22
|
+
Select,
|
|
23
|
+
SelectContent,
|
|
24
|
+
SelectItem,
|
|
25
|
+
SelectTrigger,
|
|
26
|
+
SelectValue
|
|
27
|
+
} from '@nextsparkjs/core/components/ui/select'
|
|
28
|
+
import { Card, CardContent, CardHeader, CardTitle } from '@nextsparkjs/core/components/ui/card'
|
|
29
|
+
import { Switch } from '@nextsparkjs/core/components/ui/switch'
|
|
30
|
+
import { WysiwygEditor } from '@/themes/blog/components/editor/WysiwygEditor'
|
|
31
|
+
import { FeaturedImageUpload } from '@/themes/blog/components/editor/FeaturedImageUpload'
|
|
32
|
+
|
|
33
|
+
interface PostData {
|
|
34
|
+
title: string
|
|
35
|
+
slug: string
|
|
36
|
+
content: string
|
|
37
|
+
excerpt: string
|
|
38
|
+
status: 'draft' | 'published'
|
|
39
|
+
featuredImage: string
|
|
40
|
+
featured: boolean
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function getActiveTeamId(): string | null {
|
|
44
|
+
if (typeof window === 'undefined') return null
|
|
45
|
+
return localStorage.getItem('activeTeamId')
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function buildHeaders(): HeadersInit {
|
|
49
|
+
const headers: HeadersInit = {
|
|
50
|
+
'Content-Type': 'application/json',
|
|
51
|
+
}
|
|
52
|
+
const teamId = getActiveTeamId()
|
|
53
|
+
if (teamId) {
|
|
54
|
+
headers['x-team-id'] = teamId
|
|
55
|
+
}
|
|
56
|
+
return headers
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function generateSlug(title: string): string {
|
|
60
|
+
return title
|
|
61
|
+
.toLowerCase()
|
|
62
|
+
.normalize('NFD')
|
|
63
|
+
.replace(/[\u0300-\u036f]/g, '')
|
|
64
|
+
.replace(/[^a-z0-9\s-]/g, '')
|
|
65
|
+
.replace(/\s+/g, '-')
|
|
66
|
+
.replace(/-+/g, '-')
|
|
67
|
+
.trim()
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export default function CreatePostPage() {
|
|
71
|
+
const router = useRouter()
|
|
72
|
+
const [post, setPost] = useState<PostData>({
|
|
73
|
+
title: '',
|
|
74
|
+
slug: '',
|
|
75
|
+
content: '',
|
|
76
|
+
excerpt: '',
|
|
77
|
+
status: 'draft',
|
|
78
|
+
featuredImage: '',
|
|
79
|
+
featured: false
|
|
80
|
+
})
|
|
81
|
+
const [saving, setSaving] = useState(false)
|
|
82
|
+
const [autoSaved, setAutoSaved] = useState(false)
|
|
83
|
+
const [error, setError] = useState<string | null>(null)
|
|
84
|
+
const [showSidebar, setShowSidebar] = useState(true)
|
|
85
|
+
|
|
86
|
+
// Auto-generate slug from title
|
|
87
|
+
const handleTitleChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
|
|
88
|
+
const title = e.target.value
|
|
89
|
+
setPost(prev => ({
|
|
90
|
+
...prev,
|
|
91
|
+
title,
|
|
92
|
+
slug: generateSlug(title)
|
|
93
|
+
}))
|
|
94
|
+
}, [])
|
|
95
|
+
|
|
96
|
+
// Auto-save draft every 30 seconds
|
|
97
|
+
useEffect(() => {
|
|
98
|
+
if (!post.title && !post.content) return
|
|
99
|
+
|
|
100
|
+
const timer = setTimeout(async () => {
|
|
101
|
+
if (post.status === 'draft') {
|
|
102
|
+
await handleSave(false)
|
|
103
|
+
setAutoSaved(true)
|
|
104
|
+
setTimeout(() => setAutoSaved(false), 2000)
|
|
105
|
+
}
|
|
106
|
+
}, 30000)
|
|
107
|
+
|
|
108
|
+
return () => clearTimeout(timer)
|
|
109
|
+
}, [post.title, post.content, post.status])
|
|
110
|
+
|
|
111
|
+
const handleSave = async (redirect = true, statusOverride?: 'draft' | 'published') => {
|
|
112
|
+
if (!post.title.trim()) {
|
|
113
|
+
setError('title-required')
|
|
114
|
+
return
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
setSaving(true)
|
|
118
|
+
setError(null)
|
|
119
|
+
|
|
120
|
+
// Use status override if provided (for publish), otherwise use current post status
|
|
121
|
+
const statusToSave = statusOverride ?? post.status
|
|
122
|
+
|
|
123
|
+
try {
|
|
124
|
+
const headers = buildHeaders()
|
|
125
|
+
const response = await fetch('/api/v1/posts', {
|
|
126
|
+
method: 'POST',
|
|
127
|
+
headers,
|
|
128
|
+
credentials: 'include',
|
|
129
|
+
body: JSON.stringify({
|
|
130
|
+
title: post.title,
|
|
131
|
+
slug: post.slug || generateSlug(post.title),
|
|
132
|
+
content: post.content,
|
|
133
|
+
excerpt: post.excerpt,
|
|
134
|
+
status: statusToSave,
|
|
135
|
+
featuredImage: post.featuredImage || null,
|
|
136
|
+
featured: post.featured,
|
|
137
|
+
publishedAt: statusToSave === 'published' ? new Date().toISOString() : null
|
|
138
|
+
})
|
|
139
|
+
})
|
|
140
|
+
|
|
141
|
+
if (!response.ok) {
|
|
142
|
+
const result = await response.json()
|
|
143
|
+
throw new Error(result.error || 'Failed to save post')
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
const result = await response.json()
|
|
147
|
+
|
|
148
|
+
if (redirect) {
|
|
149
|
+
router.push(`/dashboard/posts/${result.data.id}/edit`)
|
|
150
|
+
}
|
|
151
|
+
} catch (err) {
|
|
152
|
+
setError(err instanceof Error ? err.message : 'Failed to save post')
|
|
153
|
+
} finally {
|
|
154
|
+
setSaving(false)
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
const handlePublish = async () => {
|
|
159
|
+
await handleSave(true, 'published')
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
return (
|
|
163
|
+
<div className="relative h-[calc(100vh-7rem)] flex flex-col" data-cy="post-create-container">
|
|
164
|
+
{/* Sub-topbar */}
|
|
165
|
+
<header className="shrink-0 border-b border-border bg-background" data-cy="post-create-header">
|
|
166
|
+
<div className="flex items-center justify-between h-12 px-4">
|
|
167
|
+
<div className="flex items-center gap-4">
|
|
168
|
+
<Link href="/dashboard/posts">
|
|
169
|
+
<Button variant="ghost" size="sm" data-cy="post-create-back">
|
|
170
|
+
<ArrowLeft className="h-4 w-4 mr-2" />
|
|
171
|
+
Posts
|
|
172
|
+
</Button>
|
|
173
|
+
</Link>
|
|
174
|
+
<span
|
|
175
|
+
className="text-sm text-muted-foreground"
|
|
176
|
+
data-cy="post-create-status"
|
|
177
|
+
data-cy-status={post.status === 'draft' ? 'new-draft' : 'new-post'}
|
|
178
|
+
>
|
|
179
|
+
{post.status === 'draft' ? 'New Draft' : 'New Post'}
|
|
180
|
+
</span>
|
|
181
|
+
{autoSaved && (
|
|
182
|
+
<span className="text-xs text-green-600 flex items-center gap-1" data-cy="post-create-autosaved">
|
|
183
|
+
<Check className="h-3 w-3" />
|
|
184
|
+
Auto-saved
|
|
185
|
+
</span>
|
|
186
|
+
)}
|
|
187
|
+
</div>
|
|
188
|
+
|
|
189
|
+
<div className="flex items-center gap-2">
|
|
190
|
+
<Button
|
|
191
|
+
variant="outline"
|
|
192
|
+
size="sm"
|
|
193
|
+
onClick={() => setShowSidebar(!showSidebar)}
|
|
194
|
+
data-cy="post-create-settings-toggle"
|
|
195
|
+
>
|
|
196
|
+
{showSidebar ? 'Hide' : 'Show'} Settings
|
|
197
|
+
</Button>
|
|
198
|
+
<Button
|
|
199
|
+
variant="outline"
|
|
200
|
+
size="sm"
|
|
201
|
+
onClick={() => handleSave(true)}
|
|
202
|
+
disabled={saving}
|
|
203
|
+
data-cy="post-create-save"
|
|
204
|
+
>
|
|
205
|
+
{saving ? (
|
|
206
|
+
<Loader2 className="h-4 w-4 animate-spin" />
|
|
207
|
+
) : (
|
|
208
|
+
<>
|
|
209
|
+
<Save className="h-4 w-4 mr-2" />
|
|
210
|
+
Save Draft
|
|
211
|
+
</>
|
|
212
|
+
)}
|
|
213
|
+
</Button>
|
|
214
|
+
<Button
|
|
215
|
+
size="sm"
|
|
216
|
+
onClick={handlePublish}
|
|
217
|
+
disabled={saving}
|
|
218
|
+
data-cy="post-create-publish"
|
|
219
|
+
>
|
|
220
|
+
<Eye className="h-4 w-4 mr-2" />
|
|
221
|
+
Publish
|
|
222
|
+
</Button>
|
|
223
|
+
</div>
|
|
224
|
+
</div>
|
|
225
|
+
</header>
|
|
226
|
+
|
|
227
|
+
{/* Error Banner */}
|
|
228
|
+
{error && (
|
|
229
|
+
<div
|
|
230
|
+
className="shrink-0 bg-destructive/10 border-b border-destructive/20 px-4 py-2 flex items-center justify-between"
|
|
231
|
+
data-cy="post-create-error"
|
|
232
|
+
data-cy-error={error}
|
|
233
|
+
>
|
|
234
|
+
<span className="text-sm text-destructive">
|
|
235
|
+
{error === 'title-required' ? 'Title is required' : error}
|
|
236
|
+
</span>
|
|
237
|
+
<Button variant="ghost" size="sm" onClick={() => setError(null)} data-cy="post-create-error-dismiss">
|
|
238
|
+
<X className="h-4 w-4" />
|
|
239
|
+
</Button>
|
|
240
|
+
</div>
|
|
241
|
+
)}
|
|
242
|
+
|
|
243
|
+
{/* Main Content Area */}
|
|
244
|
+
<div className="flex-1 flex min-h-0">
|
|
245
|
+
{/* Editor Column */}
|
|
246
|
+
<div className="flex-1 flex flex-col min-h-0">
|
|
247
|
+
{/* Fixed Title */}
|
|
248
|
+
<div className="shrink-0 px-6 pt-4 pb-4 bg-background">
|
|
249
|
+
<Input
|
|
250
|
+
value={post.title}
|
|
251
|
+
onChange={handleTitleChange}
|
|
252
|
+
placeholder="Post title..."
|
|
253
|
+
className="text-3xl font-bold border-0 border-b rounded-none px-0 focus-visible:ring-0 focus-visible:border-primary"
|
|
254
|
+
data-cy="post-create-title"
|
|
255
|
+
/>
|
|
256
|
+
</div>
|
|
257
|
+
|
|
258
|
+
{/* Content Editor - fills remaining space */}
|
|
259
|
+
<div className="flex-1 min-h-0 px-6 pb-6" data-cy="post-create-content">
|
|
260
|
+
<WysiwygEditor
|
|
261
|
+
value={post.content}
|
|
262
|
+
onChange={(content) => setPost(prev => ({ ...prev, content }))}
|
|
263
|
+
placeholder="Start writing your post..."
|
|
264
|
+
className="h-full flex flex-col"
|
|
265
|
+
autoFocus
|
|
266
|
+
/>
|
|
267
|
+
</div>
|
|
268
|
+
</div>
|
|
269
|
+
|
|
270
|
+
{/* Right Sidebar */}
|
|
271
|
+
{showSidebar && (
|
|
272
|
+
<div className="w-72 border-l border-border bg-background overflow-y-auto shrink-0" data-cy="post-create-settings">
|
|
273
|
+
<div className="p-4 space-y-4">
|
|
274
|
+
<Card>
|
|
275
|
+
<CardHeader className="py-3">
|
|
276
|
+
<CardTitle className="text-sm">Post Settings</CardTitle>
|
|
277
|
+
</CardHeader>
|
|
278
|
+
<CardContent className="space-y-4">
|
|
279
|
+
{/* Status */}
|
|
280
|
+
<div className="space-y-2">
|
|
281
|
+
<Label>Status</Label>
|
|
282
|
+
<Select
|
|
283
|
+
value={post.status}
|
|
284
|
+
onValueChange={(value: 'draft' | 'published') =>
|
|
285
|
+
setPost(prev => ({ ...prev, status: value }))
|
|
286
|
+
}
|
|
287
|
+
>
|
|
288
|
+
<SelectTrigger data-cy="post-create-status-select">
|
|
289
|
+
<SelectValue />
|
|
290
|
+
</SelectTrigger>
|
|
291
|
+
<SelectContent>
|
|
292
|
+
<SelectItem value="draft">Draft</SelectItem>
|
|
293
|
+
<SelectItem value="published">Published</SelectItem>
|
|
294
|
+
</SelectContent>
|
|
295
|
+
</Select>
|
|
296
|
+
</div>
|
|
297
|
+
|
|
298
|
+
{/* Slug */}
|
|
299
|
+
<div className="space-y-2">
|
|
300
|
+
<Label>URL Slug</Label>
|
|
301
|
+
<Input
|
|
302
|
+
value={post.slug}
|
|
303
|
+
onChange={(e) => setPost(prev => ({ ...prev, slug: e.target.value }))}
|
|
304
|
+
placeholder="post-url-slug"
|
|
305
|
+
data-cy="post-create-slug"
|
|
306
|
+
/>
|
|
307
|
+
</div>
|
|
308
|
+
|
|
309
|
+
{/* Excerpt */}
|
|
310
|
+
<div className="space-y-2">
|
|
311
|
+
<Label>Excerpt</Label>
|
|
312
|
+
<Textarea
|
|
313
|
+
value={post.excerpt}
|
|
314
|
+
onChange={(e) => setPost(prev => ({ ...prev, excerpt: e.target.value }))}
|
|
315
|
+
placeholder="Brief description for previews..."
|
|
316
|
+
rows={3}
|
|
317
|
+
data-cy="post-create-excerpt"
|
|
318
|
+
/>
|
|
319
|
+
</div>
|
|
320
|
+
|
|
321
|
+
{/* Featured Image */}
|
|
322
|
+
<div className="space-y-2" data-cy="post-create-featured-image">
|
|
323
|
+
<Label>Featured Image</Label>
|
|
324
|
+
<FeaturedImageUpload
|
|
325
|
+
value={post.featuredImage}
|
|
326
|
+
onChange={(url) => setPost(prev => ({ ...prev, featuredImage: url }))}
|
|
327
|
+
/>
|
|
328
|
+
</div>
|
|
329
|
+
|
|
330
|
+
{/* Featured Toggle */}
|
|
331
|
+
<div className="flex items-center justify-between">
|
|
332
|
+
<div className="space-y-0.5">
|
|
333
|
+
<Label htmlFor="featured">Featured Post</Label>
|
|
334
|
+
<p className="text-xs text-muted-foreground">
|
|
335
|
+
Show on homepage
|
|
336
|
+
</p>
|
|
337
|
+
</div>
|
|
338
|
+
<Switch
|
|
339
|
+
id="featured"
|
|
340
|
+
checked={post.featured}
|
|
341
|
+
onCheckedChange={(checked: boolean) => setPost(prev => ({ ...prev, featured: checked }))}
|
|
342
|
+
data-cy="post-create-featured-toggle"
|
|
343
|
+
/>
|
|
344
|
+
</div>
|
|
345
|
+
</CardContent>
|
|
346
|
+
</Card>
|
|
347
|
+
</div>
|
|
348
|
+
</div>
|
|
349
|
+
)}
|
|
350
|
+
</div>
|
|
351
|
+
</div>
|
|
352
|
+
)
|
|
353
|
+
}
|