@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,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 "{post.title}"? 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
|
+
}
|