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