@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,529 @@
1
+ 'use client'
2
+
3
+ /**
4
+ * Blog Theme - Edit Post Page
5
+ *
6
+ * Full-width WYSIWYG editor with side panel for metadata.
7
+ * Loads existing post data and supports auto-save.
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, use } from 'react'
14
+ import { useRouter } from 'next/navigation'
15
+ import Link from 'next/link'
16
+ import { ArrowLeft, Save, Eye, Loader2, Check, X, Trash2, ExternalLink } 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 {
30
+ Dialog,
31
+ DialogContent,
32
+ DialogDescription,
33
+ DialogFooter,
34
+ DialogHeader,
35
+ DialogTitle,
36
+ DialogTrigger,
37
+ DialogClose,
38
+ } from '@nextsparkjs/core/components/ui/dialog'
39
+ import { Switch } from '@nextsparkjs/core/components/ui/switch'
40
+ import { WysiwygEditor } from '@/themes/blog/components/editor/WysiwygEditor'
41
+ import { FeaturedImageUpload } from '@/themes/blog/components/editor/FeaturedImageUpload'
42
+
43
+ interface PostData {
44
+ id: string
45
+ title: string
46
+ slug: string
47
+ content: string
48
+ excerpt: string
49
+ status: 'draft' | 'published'
50
+ featuredImage: string
51
+ featured: boolean
52
+ publishedAt: string | null
53
+ createdAt: string
54
+ updatedAt: string
55
+ }
56
+
57
+ interface PageProps {
58
+ params: Promise<{ id: string }>
59
+ }
60
+
61
+ function getActiveTeamId(): string | null {
62
+ if (typeof window === 'undefined') return null
63
+ return localStorage.getItem('activeTeamId')
64
+ }
65
+
66
+ function buildHeaders(): HeadersInit {
67
+ const headers: HeadersInit = {
68
+ 'Content-Type': 'application/json',
69
+ }
70
+ const teamId = getActiveTeamId()
71
+ if (teamId) {
72
+ headers['x-team-id'] = teamId
73
+ }
74
+ return headers
75
+ }
76
+
77
+ function generateSlug(title: string): string {
78
+ return title
79
+ .toLowerCase()
80
+ .normalize('NFD')
81
+ .replace(/[\u0300-\u036f]/g, '')
82
+ .replace(/[^a-z0-9\s-]/g, '')
83
+ .replace(/\s+/g, '-')
84
+ .replace(/-+/g, '-')
85
+ .trim()
86
+ }
87
+
88
+ export default function EditPostPage({ params }: PageProps) {
89
+ const { id } = use(params)
90
+ const router = useRouter()
91
+ const [post, setPost] = useState<PostData | null>(null)
92
+ const [loading, setLoading] = useState(true)
93
+ const [saving, setSaving] = useState(false)
94
+ const [deleting, setDeleting] = useState(false)
95
+ const [autoSaved, setAutoSaved] = useState(false)
96
+ const [error, setError] = useState<string | null>(null)
97
+ const [showSidebar, setShowSidebar] = useState(true)
98
+ const [hasChanges, setHasChanges] = useState(false)
99
+
100
+ // Fetch post data
101
+ useEffect(() => {
102
+ async function fetchPost() {
103
+ try {
104
+ const headers = buildHeaders()
105
+ const response = await fetch(`/api/v1/posts/${id}`, {
106
+ credentials: 'include',
107
+ headers,
108
+ })
109
+
110
+ if (!response.ok) {
111
+ throw new Error('Post not found')
112
+ }
113
+
114
+ const result = await response.json()
115
+ setPost({
116
+ ...result.data,
117
+ content: result.data.content || '',
118
+ excerpt: result.data.excerpt || '',
119
+ featuredImage: result.data.featuredImage || '',
120
+ featured: result.data.featured ?? false
121
+ })
122
+ } catch (err) {
123
+ setError(err instanceof Error ? err.message : 'Failed to load post')
124
+ } finally {
125
+ setLoading(false)
126
+ }
127
+ }
128
+
129
+ fetchPost()
130
+ }, [id])
131
+
132
+ // Track changes
133
+ const updatePost = useCallback((updates: Partial<PostData>) => {
134
+ setPost(prev => prev ? { ...prev, ...updates } : null)
135
+ setHasChanges(true)
136
+ }, [])
137
+
138
+ // Auto-generate slug from title
139
+ const handleTitleChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
140
+ const title = e.target.value
141
+ updatePost({
142
+ title,
143
+ slug: generateSlug(title)
144
+ })
145
+ }, [updatePost])
146
+
147
+ // Auto-save draft every 30 seconds
148
+ useEffect(() => {
149
+ if (!post || !hasChanges || post.status !== 'draft') return
150
+
151
+ const timer = setTimeout(async () => {
152
+ await handleSave(false)
153
+ setAutoSaved(true)
154
+ setTimeout(() => setAutoSaved(false), 2000)
155
+ }, 30000)
156
+
157
+ return () => clearTimeout(timer)
158
+ }, [post, hasChanges])
159
+
160
+ const handleSave = async (showFeedback = true, statusOverride?: 'draft' | 'published') => {
161
+ if (!post) return
162
+
163
+ if (!post.title.trim()) {
164
+ setError('Title is required')
165
+ return
166
+ }
167
+
168
+ setSaving(true)
169
+ setError(null)
170
+
171
+ // Use status override if provided (for publish/unpublish), otherwise use current post status
172
+ const statusToSave = statusOverride ?? post.status
173
+ const publishedAtToSave = statusToSave === 'published' && !post.publishedAt
174
+ ? new Date().toISOString()
175
+ : statusToSave === 'draft'
176
+ ? null
177
+ : post.publishedAt
178
+
179
+ try {
180
+ const headers = buildHeaders()
181
+ const response = await fetch(`/api/v1/posts/${id}`, {
182
+ method: 'PATCH',
183
+ headers,
184
+ credentials: 'include',
185
+ body: JSON.stringify({
186
+ title: post.title,
187
+ slug: post.slug || generateSlug(post.title),
188
+ content: post.content,
189
+ excerpt: post.excerpt,
190
+ status: statusToSave,
191
+ featuredImage: post.featuredImage || null,
192
+ featured: post.featured,
193
+ publishedAt: publishedAtToSave
194
+ })
195
+ })
196
+
197
+ if (!response.ok) {
198
+ const result = await response.json()
199
+ throw new Error(result.error || 'Failed to save post')
200
+ }
201
+
202
+ // Update local state to match what was saved
203
+ setPost(prev => prev ? { ...prev, status: statusToSave, publishedAt: publishedAtToSave } : null)
204
+ setHasChanges(false)
205
+
206
+ if (showFeedback) {
207
+ setAutoSaved(true)
208
+ setTimeout(() => setAutoSaved(false), 2000)
209
+ }
210
+ } catch (err) {
211
+ setError(err instanceof Error ? err.message : 'Failed to save post')
212
+ } finally {
213
+ setSaving(false)
214
+ }
215
+ }
216
+
217
+ const handlePublish = async () => {
218
+ if (!post) return
219
+ await handleSave(true, 'published')
220
+ }
221
+
222
+ const handleUnpublish = async () => {
223
+ if (!post) return
224
+ await handleSave(true, 'draft')
225
+ }
226
+
227
+ const handleDelete = async () => {
228
+ if (!post) return
229
+
230
+ setDeleting(true)
231
+ setError(null)
232
+
233
+ try {
234
+ const headers = buildHeaders()
235
+ const response = await fetch(`/api/v1/posts/${id}`, {
236
+ method: 'DELETE',
237
+ headers,
238
+ credentials: 'include',
239
+ })
240
+
241
+ if (!response.ok) {
242
+ throw new Error('Failed to delete post')
243
+ }
244
+
245
+ router.push('/dashboard/posts')
246
+ } catch (err) {
247
+ setError(err instanceof Error ? err.message : 'Failed to delete post')
248
+ setDeleting(false)
249
+ }
250
+ }
251
+
252
+ if (loading) {
253
+ return (
254
+ <div className="h-[calc(100vh-7rem)] flex items-center justify-center" data-cy="post-edit-loading">
255
+ <Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
256
+ </div>
257
+ )
258
+ }
259
+
260
+ if (!post) {
261
+ return (
262
+ <div className="h-[calc(100vh-7rem)] flex flex-col items-center justify-center gap-4" data-cy="post-edit-not-found">
263
+ <p className="text-muted-foreground">Post not found</p>
264
+ <Link href="/dashboard/posts">
265
+ <Button variant="outline">Back to Posts</Button>
266
+ </Link>
267
+ </div>
268
+ )
269
+ }
270
+
271
+ return (
272
+ <div className="relative h-[calc(100vh-7rem)] flex flex-col" data-cy="post-edit-container">
273
+ {/* Sub-topbar */}
274
+ <header className="shrink-0 border-b border-border bg-background" data-cy="post-edit-header">
275
+ <div className="flex items-center justify-between h-12 px-4">
276
+ <div className="flex items-center gap-4">
277
+ <Link href="/dashboard/posts">
278
+ <Button variant="ghost" size="sm" data-cy="post-edit-back">
279
+ <ArrowLeft className="h-4 w-4 mr-2" />
280
+ Posts
281
+ </Button>
282
+ </Link>
283
+ <span
284
+ className="text-sm text-muted-foreground"
285
+ data-cy="post-edit-status"
286
+ data-cy-status={post.status}
287
+ >
288
+ {post.status === 'published' ? 'Published' : 'Draft'}
289
+ {hasChanges && (
290
+ <span data-cy="post-unsaved-indicator"> (unsaved changes)</span>
291
+ )}
292
+ </span>
293
+ {autoSaved && (
294
+ <span className="text-xs text-green-600 flex items-center gap-1" data-cy="post-edit-autosaved">
295
+ <Check className="h-3 w-3" />
296
+ Saved
297
+ </span>
298
+ )}
299
+ </div>
300
+
301
+ <div className="flex items-center gap-2">
302
+ {post.status === 'published' && (
303
+ <Link href={`/posts/${post.slug || post.id}`} target="_blank">
304
+ <Button variant="ghost" size="sm" data-cy="post-edit-view-live">
305
+ <ExternalLink className="h-4 w-4 mr-2" />
306
+ View
307
+ </Button>
308
+ </Link>
309
+ )}
310
+ <Button
311
+ variant="outline"
312
+ size="sm"
313
+ onClick={() => setShowSidebar(!showSidebar)}
314
+ data-cy="post-edit-settings-toggle"
315
+ >
316
+ {showSidebar ? 'Hide' : 'Show'} Settings
317
+ </Button>
318
+ <Button
319
+ variant="outline"
320
+ size="sm"
321
+ onClick={() => handleSave(true)}
322
+ disabled={saving}
323
+ data-cy="post-edit-save"
324
+ >
325
+ {saving ? (
326
+ <Loader2 className="h-4 w-4 animate-spin" />
327
+ ) : (
328
+ <>
329
+ <Save className="h-4 w-4 mr-2" />
330
+ Save
331
+ </>
332
+ )}
333
+ </Button>
334
+ {post.status === 'draft' ? (
335
+ <Button size="sm" onClick={handlePublish} disabled={saving} data-cy="post-edit-publish">
336
+ <Eye className="h-4 w-4 mr-2" />
337
+ Publish
338
+ </Button>
339
+ ) : (
340
+ <Button
341
+ variant="outline"
342
+ size="sm"
343
+ onClick={handleUnpublish}
344
+ disabled={saving}
345
+ data-cy="post-edit-unpublish"
346
+ >
347
+ Unpublish
348
+ </Button>
349
+ )}
350
+ </div>
351
+ </div>
352
+ </header>
353
+
354
+ {/* Error Banner */}
355
+ {error && (
356
+ <div className="shrink-0 bg-destructive/10 border-b border-destructive/20 px-4 py-2 flex items-center justify-between" data-cy="post-edit-error">
357
+ <span className="text-sm text-destructive">{error}</span>
358
+ <Button variant="ghost" size="sm" onClick={() => setError(null)} data-cy="post-edit-error-dismiss">
359
+ <X className="h-4 w-4" />
360
+ </Button>
361
+ </div>
362
+ )}
363
+
364
+ {/* Main Content Area */}
365
+ <div className="flex-1 flex min-h-0">
366
+ {/* Editor Column */}
367
+ <div className="flex-1 flex flex-col min-h-0">
368
+ {/* Fixed Title */}
369
+ <div className="shrink-0 px-6 pt-4 pb-4 bg-background">
370
+ <Input
371
+ value={post.title}
372
+ onChange={handleTitleChange}
373
+ placeholder="Post title..."
374
+ className="text-3xl font-bold border-0 border-b rounded-none px-0 focus-visible:ring-0 focus-visible:border-primary"
375
+ data-cy="post-edit-title"
376
+ />
377
+ </div>
378
+
379
+ {/* Content Editor - fills remaining space */}
380
+ <div className="flex-1 min-h-0 px-6 pb-6" data-cy="post-edit-content">
381
+ <WysiwygEditor
382
+ value={post.content}
383
+ onChange={(content) => updatePost({ content })}
384
+ placeholder="Start writing your post..."
385
+ className="h-full flex flex-col"
386
+ />
387
+ </div>
388
+ </div>
389
+
390
+ {/* Right Sidebar */}
391
+ {showSidebar && (
392
+ <div className="w-72 border-l border-border bg-background overflow-y-auto shrink-0" data-cy="post-edit-settings">
393
+ <div className="p-4 space-y-4">
394
+ <Card>
395
+ <CardHeader className="py-3">
396
+ <CardTitle className="text-sm">Post Settings</CardTitle>
397
+ </CardHeader>
398
+ <CardContent className="space-y-4">
399
+ {/* Status */}
400
+ <div className="space-y-2">
401
+ <Label>Status</Label>
402
+ <Select
403
+ value={post.status}
404
+ onValueChange={(value: 'draft' | 'published') =>
405
+ updatePost({ status: value })
406
+ }
407
+ >
408
+ <SelectTrigger data-cy="post-edit-status-select">
409
+ <SelectValue />
410
+ </SelectTrigger>
411
+ <SelectContent>
412
+ <SelectItem value="draft">Draft</SelectItem>
413
+ <SelectItem value="published">Published</SelectItem>
414
+ </SelectContent>
415
+ </Select>
416
+ </div>
417
+
418
+ {/* Slug */}
419
+ <div className="space-y-2">
420
+ <Label>URL Slug</Label>
421
+ <Input
422
+ value={post.slug}
423
+ onChange={(e) => updatePost({ slug: e.target.value })}
424
+ placeholder="post-url-slug"
425
+ data-cy="post-edit-slug"
426
+ />
427
+ </div>
428
+
429
+ {/* Excerpt */}
430
+ <div className="space-y-2">
431
+ <Label>Excerpt</Label>
432
+ <Textarea
433
+ value={post.excerpt}
434
+ onChange={(e) => updatePost({ excerpt: e.target.value })}
435
+ placeholder="Brief description for previews..."
436
+ rows={3}
437
+ data-cy="post-edit-excerpt"
438
+ />
439
+ </div>
440
+
441
+ {/* Featured Image */}
442
+ <div className="space-y-2" data-cy="post-edit-featured-image">
443
+ <Label>Featured Image</Label>
444
+ <FeaturedImageUpload
445
+ value={post.featuredImage}
446
+ onChange={(url) => updatePost({ featuredImage: url })}
447
+ />
448
+ </div>
449
+
450
+ {/* Featured Toggle */}
451
+ <div className="flex items-center justify-between">
452
+ <div className="space-y-0.5">
453
+ <Label htmlFor="featured">Featured Post</Label>
454
+ <p className="text-xs text-muted-foreground">
455
+ Show on homepage
456
+ </p>
457
+ </div>
458
+ <Switch
459
+ id="featured"
460
+ checked={post.featured}
461
+ onCheckedChange={(checked: boolean) => updatePost({ featured: checked })}
462
+ data-cy="post-edit-featured-toggle"
463
+ />
464
+ </div>
465
+ </CardContent>
466
+ </Card>
467
+
468
+ {/* Danger Zone */}
469
+ <Card className="border-destructive/50">
470
+ <CardHeader className="py-3">
471
+ <CardTitle className="text-sm text-destructive">Danger Zone</CardTitle>
472
+ </CardHeader>
473
+ <CardContent>
474
+ <Dialog>
475
+ <DialogTrigger asChild>
476
+ <Button
477
+ variant="outline"
478
+ size="sm"
479
+ className="w-full text-destructive border-destructive/50 hover:bg-destructive/10"
480
+ disabled={deleting}
481
+ data-cy="post-edit-delete"
482
+ >
483
+ {deleting ? (
484
+ <Loader2 className="h-4 w-4 animate-spin mr-2" />
485
+ ) : (
486
+ <Trash2 className="h-4 w-4 mr-2" />
487
+ )}
488
+ Delete Post
489
+ </Button>
490
+ </DialogTrigger>
491
+ <DialogContent data-cy="post-edit-delete-dialog">
492
+ <DialogHeader>
493
+ <DialogTitle>Delete Post</DialogTitle>
494
+ <DialogDescription>
495
+ Are you sure you want to delete &quot;{post.title}&quot;? This action cannot be undone.
496
+ </DialogDescription>
497
+ </DialogHeader>
498
+ <DialogFooter>
499
+ <DialogClose asChild>
500
+ <Button variant="outline" data-cy="post-edit-delete-cancel">Cancel</Button>
501
+ </DialogClose>
502
+ <Button
503
+ variant="destructive"
504
+ onClick={handleDelete}
505
+ data-cy="post-edit-delete-confirm"
506
+ >
507
+ Delete
508
+ </Button>
509
+ </DialogFooter>
510
+ </DialogContent>
511
+ </Dialog>
512
+ </CardContent>
513
+ </Card>
514
+
515
+ {/* Meta Info */}
516
+ <div className="text-xs text-muted-foreground space-y-1 pt-4">
517
+ <p>Created: {new Date(post.createdAt).toLocaleDateString()}</p>
518
+ <p>Updated: {new Date(post.updatedAt).toLocaleDateString()}</p>
519
+ {post.publishedAt && (
520
+ <p>Published: {new Date(post.publishedAt).toLocaleDateString()}</p>
521
+ )}
522
+ </div>
523
+ </div>
524
+ </div>
525
+ )}
526
+ </div>
527
+ </div>
528
+ )
529
+ }
@@ -0,0 +1,33 @@
1
+ 'use client'
2
+
3
+ /**
4
+ * Blog Theme - Post View Page
5
+ *
6
+ * Redirects directly to the edit page since the blog theme
7
+ * doesn't need a separate view page for posts.
8
+ */
9
+
10
+ import { use, useEffect } from 'react'
11
+ import { useRouter } from 'next/navigation'
12
+ import { Loader2 } from 'lucide-react'
13
+
14
+ interface PageProps {
15
+ params: Promise<{ id: string }>
16
+ }
17
+
18
+ export default function PostViewPage({ params }: PageProps) {
19
+ const { id } = use(params)
20
+ const router = useRouter()
21
+
22
+ useEffect(() => {
23
+ // Redirect to edit page
24
+ router.replace(`/dashboard/posts/${id}/edit`)
25
+ }, [id, router])
26
+
27
+ // Show loading while redirecting
28
+ return (
29
+ <div className="min-h-screen flex items-center justify-center">
30
+ <Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
31
+ </div>
32
+ )
33
+ }