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