@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,284 @@
1
+ 'use client'
2
+
3
+ import { useState, useRef } from 'react'
4
+ import { Button } from '@nextsparkjs/core/components/ui/button'
5
+ import {
6
+ Dialog,
7
+ DialogContent,
8
+ DialogDescription,
9
+ DialogFooter,
10
+ DialogHeader,
11
+ DialogTitle,
12
+ DialogTrigger,
13
+ } from '@nextsparkjs/core/components/ui/dialog'
14
+ import { Progress } from '@nextsparkjs/core/components/ui/progress'
15
+ import { Badge } from '@nextsparkjs/core/components/ui/badge'
16
+ import { ScrollArea } from '@nextsparkjs/core/components/ui/scroll-area'
17
+ import { Upload, FileJson, AlertCircle, CheckCircle2, Loader2 } from 'lucide-react'
18
+ import { useToast } from '@nextsparkjs/core/hooks/useToast'
19
+
20
+ interface PostImport {
21
+ title: string
22
+ slug?: string
23
+ content?: string
24
+ excerpt?: string
25
+ featuredImage?: string
26
+ status?: string
27
+ publishedAt?: string
28
+ category?: string
29
+ tags?: string[]
30
+ }
31
+
32
+ interface ImportPostsDialogProps {
33
+ className?: string
34
+ onImportComplete?: () => void
35
+ }
36
+
37
+ export function ImportPostsDialog({ className, onImportComplete }: ImportPostsDialogProps) {
38
+ const [open, setOpen] = useState(false)
39
+ const [posts, setPosts] = useState<PostImport[]>([])
40
+ const [isImporting, setIsImporting] = useState(false)
41
+ const [progress, setProgress] = useState(0)
42
+ const [importResults, setImportResults] = useState<{ success: number; failed: number }>({ success: 0, failed: 0 })
43
+ const fileInputRef = useRef<HTMLInputElement>(null)
44
+ const { toast } = useToast()
45
+
46
+ const handleFileSelect = async (event: React.ChangeEvent<HTMLInputElement>) => {
47
+ const file = event.target.files?.[0]
48
+ if (!file) return
49
+
50
+ if (!file.name.endsWith('.json')) {
51
+ toast({
52
+ title: 'Invalid file type',
53
+ description: 'Please select a JSON file.',
54
+ variant: 'destructive',
55
+ })
56
+ return
57
+ }
58
+
59
+ try {
60
+ const text = await file.text()
61
+ const data = JSON.parse(text)
62
+
63
+ // Validate structure
64
+ const postsArray = Array.isArray(data) ? data : [data]
65
+ const validPosts = postsArray.filter((p: unknown) => {
66
+ if (typeof p !== 'object' || p === null) return false
67
+ const post = p as Record<string, unknown>
68
+ return typeof post.title === 'string' && post.title.length > 0
69
+ })
70
+
71
+ if (validPosts.length === 0) {
72
+ toast({
73
+ title: 'No valid posts found',
74
+ description: 'The JSON file must contain posts with at least a title.',
75
+ variant: 'destructive',
76
+ })
77
+ return
78
+ }
79
+
80
+ setPosts(validPosts)
81
+ setImportResults({ success: 0, failed: 0 })
82
+ } catch (error) {
83
+ console.error('Parse error:', error)
84
+ toast({
85
+ title: 'Invalid JSON',
86
+ description: 'Could not parse the JSON file.',
87
+ variant: 'destructive',
88
+ })
89
+ }
90
+ }
91
+
92
+ const generateSlug = (title: string): string => {
93
+ return title
94
+ .toLowerCase()
95
+ .replace(/[^a-z0-9]+/g, '-')
96
+ .replace(/^-|-$/g, '')
97
+ }
98
+
99
+ const handleImport = async () => {
100
+ if (posts.length === 0) return
101
+
102
+ setIsImporting(true)
103
+ setProgress(0)
104
+ let success = 0
105
+ let failed = 0
106
+
107
+ for (let i = 0; i < posts.length; i++) {
108
+ const post = posts[i]
109
+
110
+ try {
111
+ const response = await fetch('/api/v1/posts', {
112
+ method: 'POST',
113
+ headers: {
114
+ 'Content-Type': 'application/json',
115
+ },
116
+ body: JSON.stringify({
117
+ title: post.title,
118
+ slug: post.slug || generateSlug(post.title),
119
+ content: post.content || '',
120
+ excerpt: post.excerpt || '',
121
+ featuredImage: post.featuredImage || null,
122
+ status: post.status || 'draft',
123
+ publishedAt: post.publishedAt || null,
124
+ category: post.category || null,
125
+ tags: post.tags || [],
126
+ }),
127
+ })
128
+
129
+ if (response.ok) {
130
+ success++
131
+ } else {
132
+ failed++
133
+ }
134
+ } catch (error) {
135
+ console.error('Import error for post:', post.title, error)
136
+ failed++
137
+ }
138
+
139
+ setProgress(Math.round(((i + 1) / posts.length) * 100))
140
+ }
141
+
142
+ setImportResults({ success, failed })
143
+ setIsImporting(false)
144
+
145
+ if (success > 0) {
146
+ toast({
147
+ title: 'Import complete',
148
+ description: `${success} posts imported successfully${failed > 0 ? `, ${failed} failed` : ''}.`,
149
+ })
150
+ onImportComplete?.()
151
+ } else {
152
+ toast({
153
+ title: 'Import failed',
154
+ description: 'No posts were imported.',
155
+ variant: 'destructive',
156
+ })
157
+ }
158
+ }
159
+
160
+ const handleClose = () => {
161
+ setOpen(false)
162
+ setPosts([])
163
+ setProgress(0)
164
+ setImportResults({ success: 0, failed: 0 })
165
+ if (fileInputRef.current) {
166
+ fileInputRef.current.value = ''
167
+ }
168
+ }
169
+
170
+ return (
171
+ <Dialog open={open} onOpenChange={(isOpen: boolean) => isOpen ? setOpen(true) : handleClose()}>
172
+ <DialogTrigger asChild>
173
+ <Button variant="outline" size="sm" className={className}>
174
+ <Upload className="h-4 w-4 mr-2" />
175
+ Import JSON
176
+ </Button>
177
+ </DialogTrigger>
178
+ <DialogContent className="sm:max-w-[500px]">
179
+ <DialogHeader>
180
+ <DialogTitle>Import Posts from JSON</DialogTitle>
181
+ <DialogDescription>
182
+ Upload a JSON file containing posts to import. Each post must have at least a title.
183
+ </DialogDescription>
184
+ </DialogHeader>
185
+
186
+ <div className="space-y-4 py-4">
187
+ {/* File Input */}
188
+ <div
189
+ className="border-2 border-dashed rounded-lg p-8 text-center cursor-pointer hover:border-primary/50 transition-colors"
190
+ onClick={() => fileInputRef.current?.click()}
191
+ >
192
+ <input
193
+ ref={fileInputRef}
194
+ type="file"
195
+ accept=".json"
196
+ onChange={handleFileSelect}
197
+ className="hidden"
198
+ />
199
+ <FileJson className="h-10 w-10 mx-auto mb-3 text-muted-foreground" />
200
+ <p className="text-sm text-muted-foreground">
201
+ Click to select a JSON file
202
+ </p>
203
+ <p className="text-xs text-muted-foreground mt-1">
204
+ or drag and drop
205
+ </p>
206
+ </div>
207
+
208
+ {/* Preview */}
209
+ {posts.length > 0 && (
210
+ <div className="space-y-3">
211
+ <div className="flex items-center justify-between">
212
+ <span className="text-sm font-medium">
213
+ {posts.length} posts found
214
+ </span>
215
+ {importResults.success > 0 && (
216
+ <div className="flex gap-2">
217
+ <Badge variant="default" className="bg-green-500">
218
+ <CheckCircle2 className="h-3 w-3 mr-1" />
219
+ {importResults.success}
220
+ </Badge>
221
+ {importResults.failed > 0 && (
222
+ <Badge variant="destructive">
223
+ <AlertCircle className="h-3 w-3 mr-1" />
224
+ {importResults.failed}
225
+ </Badge>
226
+ )}
227
+ </div>
228
+ )}
229
+ </div>
230
+
231
+ <ScrollArea className="h-[150px] rounded-md border p-3">
232
+ <div className="space-y-2">
233
+ {posts.map((post, index) => (
234
+ <div
235
+ key={index}
236
+ className="flex items-center justify-between text-sm py-1"
237
+ >
238
+ <span className="truncate flex-1 mr-2">{post.title}</span>
239
+ <Badge variant="outline" className="text-xs">
240
+ {post.status || 'draft'}
241
+ </Badge>
242
+ </div>
243
+ ))}
244
+ </div>
245
+ </ScrollArea>
246
+
247
+ {isImporting && (
248
+ <div className="space-y-2">
249
+ <Progress value={progress} className="h-2" />
250
+ <p className="text-xs text-muted-foreground text-center">
251
+ Importing... {progress}%
252
+ </p>
253
+ </div>
254
+ )}
255
+ </div>
256
+ )}
257
+ </div>
258
+
259
+ <DialogFooter>
260
+ <Button variant="outline" onClick={handleClose}>
261
+ Cancel
262
+ </Button>
263
+ <Button
264
+ onClick={handleImport}
265
+ disabled={posts.length === 0 || isImporting}
266
+ >
267
+ {isImporting ? (
268
+ <>
269
+ <Loader2 className="h-4 w-4 mr-2 animate-spin" />
270
+ Importing...
271
+ </>
272
+ ) : (
273
+ <>
274
+ <Upload className="h-4 w-4 mr-2" />
275
+ Import {posts.length} Posts
276
+ </>
277
+ )}
278
+ </Button>
279
+ </DialogFooter>
280
+ </DialogContent>
281
+ </Dialog>
282
+ )
283
+ }
284
+
@@ -0,0 +1,24 @@
1
+ 'use client'
2
+
3
+ import { PermissionGate } from '@nextsparkjs/core/components/permissions/PermissionGate'
4
+ import { ExportPostsButton } from './ExportPostsButton'
5
+ import { ImportPostsDialog } from './ImportPostsDialog'
6
+
7
+ interface PostsToolbarProps {
8
+ onRefresh?: () => void
9
+ }
10
+
11
+ export function PostsToolbar({ onRefresh }: PostsToolbarProps) {
12
+ return (
13
+ <div className="flex items-center gap-2">
14
+ <PermissionGate permission="posts.export_json">
15
+ <ExportPostsButton />
16
+ </PermissionGate>
17
+
18
+ <PermissionGate permission="posts.import_json">
19
+ <ImportPostsDialog onImportComplete={onRefresh} />
20
+ </PermissionGate>
21
+ </div>
22
+ )
23
+ }
24
+
@@ -0,0 +1,185 @@
1
+ 'use client'
2
+
3
+ /**
4
+ * Featured Image Upload Component
5
+ *
6
+ * A simple single-image upload component for blog post featured images.
7
+ * Supports drag & drop, click to upload, and preview.
8
+ */
9
+
10
+ import * as React from 'react'
11
+ import NextImage from 'next/image'
12
+ import { X, Upload, Image as ImageIcon } from 'lucide-react'
13
+ import { Button } from '@nextsparkjs/core/components/ui/button'
14
+ import { cn } from '@nextsparkjs/core/lib/utils'
15
+
16
+ interface FeaturedImageUploadProps {
17
+ value: string // URL string
18
+ onChange: (url: string) => void
19
+ disabled?: boolean
20
+ className?: string
21
+ }
22
+
23
+ export function FeaturedImageUpload({
24
+ value,
25
+ onChange,
26
+ disabled = false,
27
+ className,
28
+ }: FeaturedImageUploadProps) {
29
+ const [isDragOver, setIsDragOver] = React.useState(false)
30
+ const [isLoading, setIsLoading] = React.useState(false)
31
+ const [error, setError] = React.useState<string | null>(null)
32
+ const inputRef = React.useRef<HTMLInputElement>(null)
33
+
34
+ const handleFile = async (file: File) => {
35
+ if (disabled) return
36
+
37
+ // Validate file type
38
+ if (!file.type.startsWith('image/')) {
39
+ setError('Please select an image file')
40
+ return
41
+ }
42
+
43
+ // Validate file size (max 5MB)
44
+ if (file.size > 5 * 1024 * 1024) {
45
+ setError('Image must be less than 5MB')
46
+ return
47
+ }
48
+
49
+ setIsLoading(true)
50
+ setError(null)
51
+
52
+ try {
53
+ // For now, create a local object URL
54
+ // In production, you would upload to a storage service
55
+ const objectUrl = URL.createObjectURL(file)
56
+ onChange(objectUrl)
57
+ } catch (err) {
58
+ setError('Failed to process image')
59
+ console.error('Image upload error:', err)
60
+ } finally {
61
+ setIsLoading(false)
62
+ }
63
+ }
64
+
65
+ const handleDragOver = (e: React.DragEvent) => {
66
+ e.preventDefault()
67
+ if (!disabled) {
68
+ setIsDragOver(true)
69
+ }
70
+ }
71
+
72
+ const handleDragLeave = (e: React.DragEvent) => {
73
+ e.preventDefault()
74
+ setIsDragOver(false)
75
+ }
76
+
77
+ const handleDrop = (e: React.DragEvent) => {
78
+ e.preventDefault()
79
+ setIsDragOver(false)
80
+
81
+ if (!disabled && e.dataTransfer.files?.[0]) {
82
+ handleFile(e.dataTransfer.files[0])
83
+ }
84
+ }
85
+
86
+ const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
87
+ if (e.target.files?.[0]) {
88
+ handleFile(e.target.files[0])
89
+ }
90
+ }
91
+
92
+ const openFileDialog = () => {
93
+ if (!disabled) {
94
+ inputRef.current?.click()
95
+ }
96
+ }
97
+
98
+ const removeImage = () => {
99
+ if (!disabled) {
100
+ onChange('')
101
+ setError(null)
102
+ }
103
+ }
104
+
105
+ // Check if value is a valid URL or blob URL
106
+ const hasImage = value && (value.startsWith('http') || value.startsWith('blob:'))
107
+
108
+ return (
109
+ <div className={cn('w-full space-y-2', className)} data-cy="featured-image-container">
110
+ {hasImage ? (
111
+ // Image Preview
112
+ <div className="relative aspect-video rounded-lg overflow-hidden border border-border bg-muted" data-cy="featured-image-preview">
113
+ <NextImage
114
+ src={value}
115
+ alt="Featured image preview"
116
+ fill
117
+ className="object-cover"
118
+ unoptimized={value.startsWith('blob:')}
119
+ />
120
+ {/* Remove button */}
121
+ <Button
122
+ variant="destructive"
123
+ size="icon"
124
+ className="absolute top-2 right-2 h-8 w-8"
125
+ onClick={removeImage}
126
+ disabled={disabled}
127
+ data-cy="featured-image-remove"
128
+ >
129
+ <X className="h-4 w-4" />
130
+ </Button>
131
+ </div>
132
+ ) : (
133
+ // Upload Area
134
+ <div
135
+ className={cn(
136
+ 'relative aspect-video border-2 border-dashed rounded-lg transition-colors cursor-pointer',
137
+ isDragOver && 'border-primary bg-primary/5',
138
+ !isDragOver && 'border-muted-foreground/25 hover:border-primary/50',
139
+ disabled && 'opacity-50 cursor-not-allowed',
140
+ isLoading && 'animate-pulse'
141
+ )}
142
+ onDragOver={handleDragOver}
143
+ onDragLeave={handleDragLeave}
144
+ onDrop={handleDrop}
145
+ onClick={openFileDialog}
146
+ data-cy="featured-image-dropzone"
147
+ >
148
+ <div className="absolute inset-0 flex flex-col items-center justify-center">
149
+ {isLoading ? (
150
+ <div className="h-8 w-8 border-2 border-primary border-t-transparent rounded-full animate-spin" data-cy="featured-image-loading" />
151
+ ) : (
152
+ <>
153
+ <ImageIcon className="h-8 w-8 text-muted-foreground mb-2" />
154
+ <p className="text-sm text-muted-foreground text-center px-4">
155
+ <span className="font-medium text-primary">Click to upload</span>
156
+ {' '}or drag and drop
157
+ </p>
158
+ <p className="text-xs text-muted-foreground mt-1">
159
+ PNG, JPG, GIF up to 5MB
160
+ </p>
161
+ </>
162
+ )}
163
+ </div>
164
+
165
+ <input
166
+ ref={inputRef}
167
+ type="file"
168
+ accept="image/*"
169
+ onChange={handleInputChange}
170
+ className="sr-only"
171
+ disabled={disabled || isLoading}
172
+ data-cy="featured-image-input"
173
+ />
174
+ </div>
175
+ )}
176
+
177
+ {/* Error message */}
178
+ {error && (
179
+ <p className="text-xs text-destructive" data-cy="featured-image-error">{error}</p>
180
+ )}
181
+ </div>
182
+ )
183
+ }
184
+
185
+ export default FeaturedImageUpload