@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,833 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Blog Theme - Custom Posts List Page
|
|
5
|
+
*
|
|
6
|
+
* Modern, responsive posts management with:
|
|
7
|
+
* - Grid/Table view toggle
|
|
8
|
+
* - Status filters
|
|
9
|
+
* - Bulk actions
|
|
10
|
+
* - Inline actions (edit, view, publish, delete)
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { useState, useEffect, useCallback } from 'react'
|
|
14
|
+
import { useRouter } from 'next/navigation'
|
|
15
|
+
import Link from 'next/link'
|
|
16
|
+
import Image from 'next/image'
|
|
17
|
+
import { Button } from '@nextsparkjs/core/components/ui/button'
|
|
18
|
+
import { Input } from '@nextsparkjs/core/components/ui/input'
|
|
19
|
+
import { Badge } from '@nextsparkjs/core/components/ui/badge'
|
|
20
|
+
import { Checkbox } from '@nextsparkjs/core/components/ui/checkbox'
|
|
21
|
+
import {
|
|
22
|
+
DropdownMenu,
|
|
23
|
+
DropdownMenuContent,
|
|
24
|
+
DropdownMenuItem,
|
|
25
|
+
DropdownMenuSeparator,
|
|
26
|
+
DropdownMenuTrigger,
|
|
27
|
+
} from '@nextsparkjs/core/components/ui/dropdown-menu'
|
|
28
|
+
import {
|
|
29
|
+
Dialog,
|
|
30
|
+
DialogContent,
|
|
31
|
+
DialogDescription,
|
|
32
|
+
DialogFooter,
|
|
33
|
+
DialogHeader,
|
|
34
|
+
DialogTitle,
|
|
35
|
+
} from '@nextsparkjs/core/components/ui/dialog'
|
|
36
|
+
import {
|
|
37
|
+
Select,
|
|
38
|
+
SelectContent,
|
|
39
|
+
SelectItem,
|
|
40
|
+
SelectTrigger,
|
|
41
|
+
SelectValue,
|
|
42
|
+
} from '@nextsparkjs/core/components/ui/select'
|
|
43
|
+
import { Card, CardContent } from '@nextsparkjs/core/components/ui/card'
|
|
44
|
+
import { cn } from '@nextsparkjs/core/lib/utils'
|
|
45
|
+
import {
|
|
46
|
+
Plus,
|
|
47
|
+
Search,
|
|
48
|
+
MoreHorizontal,
|
|
49
|
+
Pencil,
|
|
50
|
+
Eye,
|
|
51
|
+
Trash2,
|
|
52
|
+
CheckCircle,
|
|
53
|
+
Clock,
|
|
54
|
+
Calendar,
|
|
55
|
+
FileText,
|
|
56
|
+
LayoutGrid,
|
|
57
|
+
LayoutList,
|
|
58
|
+
Filter,
|
|
59
|
+
ArrowUpDown,
|
|
60
|
+
Loader2,
|
|
61
|
+
ExternalLink,
|
|
62
|
+
Archive,
|
|
63
|
+
Send,
|
|
64
|
+
ImageIcon,
|
|
65
|
+
} from 'lucide-react'
|
|
66
|
+
|
|
67
|
+
interface Post {
|
|
68
|
+
id: string
|
|
69
|
+
title: string
|
|
70
|
+
slug: string
|
|
71
|
+
excerpt: string | null
|
|
72
|
+
content: string | null
|
|
73
|
+
featuredImage: string | null
|
|
74
|
+
status: 'draft' | 'published' | 'scheduled'
|
|
75
|
+
publishedAt: string | null
|
|
76
|
+
createdAt: string
|
|
77
|
+
updatedAt: string
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
type ViewMode = 'table' | 'grid'
|
|
81
|
+
type StatusFilter = 'all' | 'published' | 'draft' | 'scheduled'
|
|
82
|
+
type SortOption = 'updatedAt' | 'createdAt' | 'title' | 'publishedAt'
|
|
83
|
+
|
|
84
|
+
function getActiveTeamId(): string | null {
|
|
85
|
+
if (typeof window === 'undefined') return null
|
|
86
|
+
return localStorage.getItem('activeTeamId')
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function buildHeaders(): HeadersInit {
|
|
90
|
+
const headers: HeadersInit = {
|
|
91
|
+
'Content-Type': 'application/json',
|
|
92
|
+
}
|
|
93
|
+
const teamId = getActiveTeamId()
|
|
94
|
+
if (teamId) {
|
|
95
|
+
headers['x-team-id'] = teamId
|
|
96
|
+
}
|
|
97
|
+
return headers
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function formatDate(dateString: string | null | undefined): string {
|
|
101
|
+
if (!dateString) return '-'
|
|
102
|
+
const date = new Date(dateString)
|
|
103
|
+
const now = new Date()
|
|
104
|
+
const diffMs = now.getTime() - date.getTime()
|
|
105
|
+
const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24))
|
|
106
|
+
|
|
107
|
+
if (diffDays === 0) {
|
|
108
|
+
const diffHours = Math.floor(diffMs / (1000 * 60 * 60))
|
|
109
|
+
if (diffHours === 0) {
|
|
110
|
+
const diffMins = Math.floor(diffMs / (1000 * 60))
|
|
111
|
+
if (diffMins < 0) return 'Scheduled'
|
|
112
|
+
return diffMins <= 1 ? 'Just now' : `${diffMins}m ago`
|
|
113
|
+
}
|
|
114
|
+
return `${diffHours}h ago`
|
|
115
|
+
}
|
|
116
|
+
if (diffDays === 1) return 'Yesterday'
|
|
117
|
+
if (diffDays < 7) return `${diffDays}d ago`
|
|
118
|
+
|
|
119
|
+
return date.toLocaleDateString('en-US', {
|
|
120
|
+
month: 'short',
|
|
121
|
+
day: 'numeric',
|
|
122
|
+
year: date.getFullYear() !== now.getFullYear() ? 'numeric' : undefined
|
|
123
|
+
})
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function getStatusColor(status: string): string {
|
|
127
|
+
switch (status) {
|
|
128
|
+
case 'published':
|
|
129
|
+
return 'bg-green-500/15 text-green-700 border border-green-500/20 dark:bg-green-500/20 dark:text-green-400 dark:border-green-500/30'
|
|
130
|
+
case 'draft':
|
|
131
|
+
return 'bg-amber-500/15 text-amber-700 border border-amber-500/20 dark:bg-amber-500/20 dark:text-amber-400 dark:border-amber-500/30'
|
|
132
|
+
case 'scheduled':
|
|
133
|
+
return 'bg-blue-500/15 text-blue-700 border border-blue-500/20 dark:bg-blue-500/20 dark:text-blue-400 dark:border-blue-500/30'
|
|
134
|
+
default:
|
|
135
|
+
return 'bg-gray-500/15 text-gray-700 border border-gray-500/20 dark:bg-gray-500/20 dark:text-gray-400 dark:border-gray-500/30'
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function getStatusIcon(status: string) {
|
|
140
|
+
switch (status) {
|
|
141
|
+
case 'published':
|
|
142
|
+
return <CheckCircle className="h-3 w-3" />
|
|
143
|
+
case 'draft':
|
|
144
|
+
return <Clock className="h-3 w-3" />
|
|
145
|
+
case 'scheduled':
|
|
146
|
+
return <Calendar className="h-3 w-3" />
|
|
147
|
+
default:
|
|
148
|
+
return <FileText className="h-3 w-3" />
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
export default function PostsListPage() {
|
|
153
|
+
const router = useRouter()
|
|
154
|
+
const [posts, setPosts] = useState<Post[]>([])
|
|
155
|
+
const [loading, setLoading] = useState(true)
|
|
156
|
+
const [searchQuery, setSearchQuery] = useState('')
|
|
157
|
+
const [statusFilter, setStatusFilter] = useState<StatusFilter>('all')
|
|
158
|
+
const [sortBy, setSortBy] = useState<SortOption>('updatedAt')
|
|
159
|
+
const [sortOrder, setSortOrder] = useState<'asc' | 'desc'>('desc')
|
|
160
|
+
const [viewMode, setViewMode] = useState<ViewMode>('table')
|
|
161
|
+
const [selectedPosts, setSelectedPosts] = useState<Set<string>>(new Set())
|
|
162
|
+
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false)
|
|
163
|
+
const [postToDelete, setPostToDelete] = useState<Post | null>(null)
|
|
164
|
+
const [actionLoading, setActionLoading] = useState<string | null>(null)
|
|
165
|
+
|
|
166
|
+
// Fetch posts
|
|
167
|
+
const fetchPosts = useCallback(async () => {
|
|
168
|
+
try {
|
|
169
|
+
setLoading(true)
|
|
170
|
+
const headers = buildHeaders()
|
|
171
|
+
const params = new URLSearchParams({
|
|
172
|
+
limit: '100',
|
|
173
|
+
sortBy,
|
|
174
|
+
sortOrder,
|
|
175
|
+
})
|
|
176
|
+
|
|
177
|
+
const response = await fetch(`/api/v1/posts?${params}`, {
|
|
178
|
+
credentials: 'include',
|
|
179
|
+
headers,
|
|
180
|
+
})
|
|
181
|
+
|
|
182
|
+
if (!response.ok) throw new Error('Failed to fetch posts')
|
|
183
|
+
|
|
184
|
+
const result = await response.json()
|
|
185
|
+
setPosts(result.data || [])
|
|
186
|
+
} catch (error) {
|
|
187
|
+
console.error('Error fetching posts:', error)
|
|
188
|
+
} finally {
|
|
189
|
+
setLoading(false)
|
|
190
|
+
}
|
|
191
|
+
}, [sortBy, sortOrder])
|
|
192
|
+
|
|
193
|
+
useEffect(() => {
|
|
194
|
+
fetchPosts()
|
|
195
|
+
}, [fetchPosts])
|
|
196
|
+
|
|
197
|
+
// Filter and search posts
|
|
198
|
+
const filteredPosts = posts.filter((post) => {
|
|
199
|
+
// Status filter
|
|
200
|
+
if (statusFilter !== 'all' && post.status !== statusFilter) {
|
|
201
|
+
return false
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
// Search query
|
|
205
|
+
if (searchQuery) {
|
|
206
|
+
const query = searchQuery.toLowerCase()
|
|
207
|
+
return (
|
|
208
|
+
post.title.toLowerCase().includes(query) ||
|
|
209
|
+
post.excerpt?.toLowerCase().includes(query) ||
|
|
210
|
+
post.slug.toLowerCase().includes(query)
|
|
211
|
+
)
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
return true
|
|
215
|
+
})
|
|
216
|
+
|
|
217
|
+
// Post stats
|
|
218
|
+
const stats = {
|
|
219
|
+
total: posts.length,
|
|
220
|
+
published: posts.filter((p) => p.status === 'published').length,
|
|
221
|
+
draft: posts.filter((p) => p.status === 'draft').length,
|
|
222
|
+
scheduled: posts.filter((p) => p.status === 'scheduled').length,
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
// Selection handlers
|
|
226
|
+
const toggleSelectAll = () => {
|
|
227
|
+
if (selectedPosts.size === filteredPosts.length) {
|
|
228
|
+
setSelectedPosts(new Set())
|
|
229
|
+
} else {
|
|
230
|
+
setSelectedPosts(new Set(filteredPosts.map((p) => p.id)))
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
const toggleSelectPost = (postId: string) => {
|
|
235
|
+
const newSelected = new Set(selectedPosts)
|
|
236
|
+
if (newSelected.has(postId)) {
|
|
237
|
+
newSelected.delete(postId)
|
|
238
|
+
} else {
|
|
239
|
+
newSelected.add(postId)
|
|
240
|
+
}
|
|
241
|
+
setSelectedPosts(newSelected)
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
// Action handlers
|
|
245
|
+
const handlePublish = async (post: Post) => {
|
|
246
|
+
setActionLoading(post.id)
|
|
247
|
+
try {
|
|
248
|
+
const headers = buildHeaders()
|
|
249
|
+
const response = await fetch(`/api/v1/posts/${post.id}`, {
|
|
250
|
+
method: 'PATCH',
|
|
251
|
+
headers,
|
|
252
|
+
credentials: 'include',
|
|
253
|
+
body: JSON.stringify({
|
|
254
|
+
status: 'published',
|
|
255
|
+
publishedAt: new Date().toISOString(),
|
|
256
|
+
}),
|
|
257
|
+
})
|
|
258
|
+
|
|
259
|
+
if (!response.ok) throw new Error('Failed to publish post')
|
|
260
|
+
await fetchPosts()
|
|
261
|
+
} catch (error) {
|
|
262
|
+
console.error('Error publishing post:', error)
|
|
263
|
+
} finally {
|
|
264
|
+
setActionLoading(null)
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
const handleUnpublish = async (post: Post) => {
|
|
269
|
+
setActionLoading(post.id)
|
|
270
|
+
try {
|
|
271
|
+
const headers = buildHeaders()
|
|
272
|
+
const response = await fetch(`/api/v1/posts/${post.id}`, {
|
|
273
|
+
method: 'PATCH',
|
|
274
|
+
headers,
|
|
275
|
+
credentials: 'include',
|
|
276
|
+
body: JSON.stringify({ status: 'draft' }),
|
|
277
|
+
})
|
|
278
|
+
|
|
279
|
+
if (!response.ok) throw new Error('Failed to unpublish post')
|
|
280
|
+
await fetchPosts()
|
|
281
|
+
} catch (error) {
|
|
282
|
+
console.error('Error unpublishing post:', error)
|
|
283
|
+
} finally {
|
|
284
|
+
setActionLoading(null)
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
const handleDelete = async () => {
|
|
289
|
+
if (!postToDelete) return
|
|
290
|
+
|
|
291
|
+
setActionLoading(postToDelete.id)
|
|
292
|
+
try {
|
|
293
|
+
const headers = buildHeaders()
|
|
294
|
+
const response = await fetch(`/api/v1/posts/${postToDelete.id}`, {
|
|
295
|
+
method: 'DELETE',
|
|
296
|
+
headers,
|
|
297
|
+
credentials: 'include',
|
|
298
|
+
})
|
|
299
|
+
|
|
300
|
+
if (!response.ok) throw new Error('Failed to delete post')
|
|
301
|
+
await fetchPosts()
|
|
302
|
+
setDeleteDialogOpen(false)
|
|
303
|
+
setPostToDelete(null)
|
|
304
|
+
} catch (error) {
|
|
305
|
+
console.error('Error deleting post:', error)
|
|
306
|
+
} finally {
|
|
307
|
+
setActionLoading(null)
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
const openDeleteDialog = (post: Post) => {
|
|
312
|
+
setPostToDelete(post)
|
|
313
|
+
setDeleteDialogOpen(true)
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
// Bulk actions
|
|
317
|
+
const handleBulkDelete = async () => {
|
|
318
|
+
// TODO: Implement bulk delete
|
|
319
|
+
console.log('Bulk delete:', Array.from(selectedPosts))
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
const handleBulkPublish = async () => {
|
|
323
|
+
// TODO: Implement bulk publish
|
|
324
|
+
console.log('Bulk publish:', Array.from(selectedPosts))
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
return (
|
|
328
|
+
<div className="py-6 px-4 sm:px-6 lg:px-8" data-cy="posts-list-container">
|
|
329
|
+
<div className="max-w-7xl mx-auto space-y-6">
|
|
330
|
+
{/* Header */}
|
|
331
|
+
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
|
|
332
|
+
<div>
|
|
333
|
+
<h1 className="text-2xl font-bold" data-cy="posts-list-title">Posts</h1>
|
|
334
|
+
<p className="text-sm text-muted-foreground mt-1">
|
|
335
|
+
Manage and organize your blog content
|
|
336
|
+
</p>
|
|
337
|
+
</div>
|
|
338
|
+
<Link href="/dashboard/posts/create">
|
|
339
|
+
<Button data-cy="posts-create-button">
|
|
340
|
+
<Plus className="h-4 w-4 mr-2" />
|
|
341
|
+
New Post
|
|
342
|
+
</Button>
|
|
343
|
+
</Link>
|
|
344
|
+
</div>
|
|
345
|
+
|
|
346
|
+
{/* Stats Cards */}
|
|
347
|
+
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
|
348
|
+
<button
|
|
349
|
+
onClick={() => setStatusFilter('all')}
|
|
350
|
+
data-cy="posts-stat-all"
|
|
351
|
+
className={cn(
|
|
352
|
+
'p-4 rounded-lg border text-left transition-colors',
|
|
353
|
+
statusFilter === 'all'
|
|
354
|
+
? 'border-primary bg-primary/5'
|
|
355
|
+
: 'border-border hover:bg-muted/50'
|
|
356
|
+
)}
|
|
357
|
+
>
|
|
358
|
+
<div className="text-2xl font-bold">{stats.total}</div>
|
|
359
|
+
<div className="text-sm text-muted-foreground">All Posts</div>
|
|
360
|
+
</button>
|
|
361
|
+
<button
|
|
362
|
+
onClick={() => setStatusFilter('published')}
|
|
363
|
+
data-cy="posts-stat-published"
|
|
364
|
+
className={cn(
|
|
365
|
+
'p-4 rounded-lg border text-left transition-colors',
|
|
366
|
+
statusFilter === 'published'
|
|
367
|
+
? 'border-green-500 bg-green-50 dark:bg-green-900/20'
|
|
368
|
+
: 'border-border hover:bg-muted/50'
|
|
369
|
+
)}
|
|
370
|
+
>
|
|
371
|
+
<div className="text-2xl font-bold text-green-600">{stats.published}</div>
|
|
372
|
+
<div className="text-sm text-muted-foreground">Published</div>
|
|
373
|
+
</button>
|
|
374
|
+
<button
|
|
375
|
+
onClick={() => setStatusFilter('draft')}
|
|
376
|
+
data-cy="posts-stat-draft"
|
|
377
|
+
className={cn(
|
|
378
|
+
'p-4 rounded-lg border text-left transition-colors',
|
|
379
|
+
statusFilter === 'draft'
|
|
380
|
+
? 'border-amber-500 bg-amber-50 dark:bg-amber-900/20'
|
|
381
|
+
: 'border-border hover:bg-muted/50'
|
|
382
|
+
)}
|
|
383
|
+
>
|
|
384
|
+
<div className="text-2xl font-bold text-amber-600">{stats.draft}</div>
|
|
385
|
+
<div className="text-sm text-muted-foreground">Drafts</div>
|
|
386
|
+
</button>
|
|
387
|
+
<button
|
|
388
|
+
onClick={() => setStatusFilter('scheduled')}
|
|
389
|
+
data-cy="posts-stat-scheduled"
|
|
390
|
+
className={cn(
|
|
391
|
+
'p-4 rounded-lg border text-left transition-colors',
|
|
392
|
+
statusFilter === 'scheduled'
|
|
393
|
+
? 'border-blue-500 bg-blue-50 dark:bg-blue-900/20'
|
|
394
|
+
: 'border-border hover:bg-muted/50'
|
|
395
|
+
)}
|
|
396
|
+
>
|
|
397
|
+
<div className="text-2xl font-bold text-blue-600">{stats.scheduled}</div>
|
|
398
|
+
<div className="text-sm text-muted-foreground">Scheduled</div>
|
|
399
|
+
</button>
|
|
400
|
+
</div>
|
|
401
|
+
|
|
402
|
+
{/* Toolbar */}
|
|
403
|
+
<div className="flex flex-col sm:flex-row gap-4 items-start sm:items-center justify-between" data-cy="posts-toolbar">
|
|
404
|
+
<div className="flex flex-1 gap-2 w-full sm:w-auto">
|
|
405
|
+
{/* Search */}
|
|
406
|
+
<div className="relative flex-1 sm:max-w-xs">
|
|
407
|
+
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
|
408
|
+
<Input
|
|
409
|
+
placeholder="Search posts..."
|
|
410
|
+
value={searchQuery}
|
|
411
|
+
onChange={(e) => setSearchQuery(e.target.value)}
|
|
412
|
+
className="pl-9"
|
|
413
|
+
data-cy="posts-search-input"
|
|
414
|
+
/>
|
|
415
|
+
</div>
|
|
416
|
+
|
|
417
|
+
{/* Sort */}
|
|
418
|
+
<Select value={sortBy} onValueChange={(v: string) => setSortBy(v as SortOption)}>
|
|
419
|
+
<SelectTrigger className="w-[140px]" data-cy="posts-sort-select">
|
|
420
|
+
<ArrowUpDown className="h-4 w-4 mr-2" />
|
|
421
|
+
<SelectValue />
|
|
422
|
+
</SelectTrigger>
|
|
423
|
+
<SelectContent>
|
|
424
|
+
<SelectItem value="updatedAt">Last Updated</SelectItem>
|
|
425
|
+
<SelectItem value="createdAt">Date Created</SelectItem>
|
|
426
|
+
<SelectItem value="publishedAt">Date Published</SelectItem>
|
|
427
|
+
<SelectItem value="title">Title</SelectItem>
|
|
428
|
+
</SelectContent>
|
|
429
|
+
</Select>
|
|
430
|
+
</div>
|
|
431
|
+
|
|
432
|
+
<div className="flex gap-2">
|
|
433
|
+
{/* Bulk Actions */}
|
|
434
|
+
{selectedPosts.size > 0 && (
|
|
435
|
+
<DropdownMenu>
|
|
436
|
+
<DropdownMenuTrigger asChild>
|
|
437
|
+
<Button variant="outline" size="sm" data-cy="posts-bulk-actions">
|
|
438
|
+
{selectedPosts.size} selected
|
|
439
|
+
</Button>
|
|
440
|
+
</DropdownMenuTrigger>
|
|
441
|
+
<DropdownMenuContent align="end">
|
|
442
|
+
<DropdownMenuItem onClick={handleBulkPublish} data-cy="posts-bulk-publish">
|
|
443
|
+
<Send className="h-4 w-4 mr-2" />
|
|
444
|
+
Publish All
|
|
445
|
+
</DropdownMenuItem>
|
|
446
|
+
<DropdownMenuItem onClick={handleBulkDelete} className="text-destructive" data-cy="posts-bulk-delete">
|
|
447
|
+
<Trash2 className="h-4 w-4 mr-2" />
|
|
448
|
+
Delete All
|
|
449
|
+
</DropdownMenuItem>
|
|
450
|
+
</DropdownMenuContent>
|
|
451
|
+
</DropdownMenu>
|
|
452
|
+
)}
|
|
453
|
+
|
|
454
|
+
{/* View Toggle */}
|
|
455
|
+
<div className="flex border rounded-lg overflow-hidden">
|
|
456
|
+
<button
|
|
457
|
+
onClick={() => setViewMode('table')}
|
|
458
|
+
data-cy="posts-view-table"
|
|
459
|
+
className={cn(
|
|
460
|
+
'p-2 transition-colors',
|
|
461
|
+
viewMode === 'table'
|
|
462
|
+
? 'bg-primary text-primary-foreground'
|
|
463
|
+
: 'hover:bg-muted'
|
|
464
|
+
)}
|
|
465
|
+
>
|
|
466
|
+
<LayoutList className="h-4 w-4" />
|
|
467
|
+
</button>
|
|
468
|
+
<button
|
|
469
|
+
onClick={() => setViewMode('grid')}
|
|
470
|
+
data-cy="posts-view-grid"
|
|
471
|
+
className={cn(
|
|
472
|
+
'p-2 transition-colors',
|
|
473
|
+
viewMode === 'grid'
|
|
474
|
+
? 'bg-primary text-primary-foreground'
|
|
475
|
+
: 'hover:bg-muted'
|
|
476
|
+
)}
|
|
477
|
+
>
|
|
478
|
+
<LayoutGrid className="h-4 w-4" />
|
|
479
|
+
</button>
|
|
480
|
+
</div>
|
|
481
|
+
</div>
|
|
482
|
+
</div>
|
|
483
|
+
|
|
484
|
+
{/* Content */}
|
|
485
|
+
{loading ? (
|
|
486
|
+
<div className="flex items-center justify-center py-12" data-cy="posts-loading">
|
|
487
|
+
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
|
|
488
|
+
</div>
|
|
489
|
+
) : filteredPosts.length === 0 ? (
|
|
490
|
+
<Card data-cy="posts-empty">
|
|
491
|
+
<CardContent className="flex flex-col items-center justify-center py-12">
|
|
492
|
+
<FileText className="h-12 w-12 text-muted-foreground/50 mb-4" />
|
|
493
|
+
<h3 className="font-medium text-lg mb-2">No posts found</h3>
|
|
494
|
+
<p className="text-sm text-muted-foreground mb-4">
|
|
495
|
+
{searchQuery || statusFilter !== 'all'
|
|
496
|
+
? 'Try adjusting your search or filters'
|
|
497
|
+
: 'Get started by creating your first post'}
|
|
498
|
+
</p>
|
|
499
|
+
{!searchQuery && statusFilter === 'all' && (
|
|
500
|
+
<Link href="/dashboard/posts/create">
|
|
501
|
+
<Button data-cy="posts-empty-create">
|
|
502
|
+
<Plus className="h-4 w-4 mr-2" />
|
|
503
|
+
Create your first post
|
|
504
|
+
</Button>
|
|
505
|
+
</Link>
|
|
506
|
+
)}
|
|
507
|
+
</CardContent>
|
|
508
|
+
</Card>
|
|
509
|
+
) : viewMode === 'table' ? (
|
|
510
|
+
/* Table View */
|
|
511
|
+
<div className="border rounded-lg overflow-hidden" data-cy="posts-table-container">
|
|
512
|
+
<div className="overflow-x-auto">
|
|
513
|
+
<table className="w-full" data-cy="posts-table">
|
|
514
|
+
<thead className="bg-muted/50">
|
|
515
|
+
<tr className="border-b">
|
|
516
|
+
<th className="p-3 text-left w-10">
|
|
517
|
+
<Checkbox
|
|
518
|
+
checked={selectedPosts.size === filteredPosts.length && filteredPosts.length > 0}
|
|
519
|
+
onCheckedChange={toggleSelectAll}
|
|
520
|
+
/>
|
|
521
|
+
</th>
|
|
522
|
+
<th className="p-3 text-left text-sm font-medium text-muted-foreground">
|
|
523
|
+
Post
|
|
524
|
+
</th>
|
|
525
|
+
<th className="p-3 text-left text-sm font-medium text-muted-foreground hidden md:table-cell">
|
|
526
|
+
Status
|
|
527
|
+
</th>
|
|
528
|
+
<th className="p-3 text-left text-sm font-medium text-muted-foreground hidden lg:table-cell">
|
|
529
|
+
Published
|
|
530
|
+
</th>
|
|
531
|
+
<th className="p-3 text-left text-sm font-medium text-muted-foreground hidden sm:table-cell">
|
|
532
|
+
Updated
|
|
533
|
+
</th>
|
|
534
|
+
<th className="p-3 text-right text-sm font-medium text-muted-foreground w-10">
|
|
535
|
+
<span className="sr-only">Actions</span>
|
|
536
|
+
</th>
|
|
537
|
+
</tr>
|
|
538
|
+
</thead>
|
|
539
|
+
<tbody className="divide-y">
|
|
540
|
+
{filteredPosts.map((post) => (
|
|
541
|
+
<tr
|
|
542
|
+
key={post.id}
|
|
543
|
+
className="hover:bg-muted/30 transition-colors group"
|
|
544
|
+
data-cy={`posts-row-${post.id}`}
|
|
545
|
+
>
|
|
546
|
+
<td className="p-3">
|
|
547
|
+
<Checkbox
|
|
548
|
+
checked={selectedPosts.has(post.id)}
|
|
549
|
+
onCheckedChange={() => toggleSelectPost(post.id)}
|
|
550
|
+
/>
|
|
551
|
+
</td>
|
|
552
|
+
<td className="p-3">
|
|
553
|
+
<div className="flex items-center gap-3">
|
|
554
|
+
{/* Thumbnail */}
|
|
555
|
+
<div className="hidden sm:block flex-shrink-0 w-16 h-12 rounded overflow-hidden bg-muted">
|
|
556
|
+
{post.featuredImage ? (
|
|
557
|
+
<Image
|
|
558
|
+
src={post.featuredImage}
|
|
559
|
+
alt=""
|
|
560
|
+
width={64}
|
|
561
|
+
height={48}
|
|
562
|
+
className="w-full h-full object-cover"
|
|
563
|
+
/>
|
|
564
|
+
) : (
|
|
565
|
+
<div className="w-full h-full flex items-center justify-center">
|
|
566
|
+
<ImageIcon className="h-5 w-5 text-muted-foreground/50" />
|
|
567
|
+
</div>
|
|
568
|
+
)}
|
|
569
|
+
</div>
|
|
570
|
+
<div className="min-w-0">
|
|
571
|
+
<Link
|
|
572
|
+
href={`/dashboard/posts/${post.id}/edit`}
|
|
573
|
+
className="font-medium hover:text-primary transition-colors line-clamp-1"
|
|
574
|
+
data-cy={`posts-title-${post.id}`}
|
|
575
|
+
>
|
|
576
|
+
{post.title || 'Untitled'}
|
|
577
|
+
</Link>
|
|
578
|
+
{post.excerpt && (
|
|
579
|
+
<p className="text-sm text-muted-foreground line-clamp-1 mt-0.5">
|
|
580
|
+
{post.excerpt}
|
|
581
|
+
</p>
|
|
582
|
+
)}
|
|
583
|
+
{/* Mobile Status Badge */}
|
|
584
|
+
<div className="mt-1 md:hidden">
|
|
585
|
+
<span
|
|
586
|
+
className={cn(
|
|
587
|
+
'inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs font-medium',
|
|
588
|
+
getStatusColor(post.status)
|
|
589
|
+
)}
|
|
590
|
+
>
|
|
591
|
+
{getStatusIcon(post.status)}
|
|
592
|
+
{post.status.charAt(0).toUpperCase() + post.status.slice(1)}
|
|
593
|
+
</span>
|
|
594
|
+
</div>
|
|
595
|
+
</div>
|
|
596
|
+
</div>
|
|
597
|
+
</td>
|
|
598
|
+
<td className="p-3 hidden md:table-cell">
|
|
599
|
+
<span
|
|
600
|
+
className={cn(
|
|
601
|
+
'inline-flex items-center gap-1 px-2.5 py-1 rounded-full text-xs font-medium',
|
|
602
|
+
getStatusColor(post.status)
|
|
603
|
+
)}
|
|
604
|
+
data-cy={`posts-status-${post.id}`}
|
|
605
|
+
data-cy-status={post.status}
|
|
606
|
+
>
|
|
607
|
+
{getStatusIcon(post.status)}
|
|
608
|
+
{post.status.charAt(0).toUpperCase() + post.status.slice(1)}
|
|
609
|
+
</span>
|
|
610
|
+
</td>
|
|
611
|
+
<td className="p-3 text-sm text-muted-foreground hidden lg:table-cell">
|
|
612
|
+
{formatDate(post.publishedAt)}
|
|
613
|
+
</td>
|
|
614
|
+
<td className="p-3 text-sm text-muted-foreground hidden sm:table-cell">
|
|
615
|
+
{formatDate(post.updatedAt)}
|
|
616
|
+
</td>
|
|
617
|
+
<td className="p-3 text-right">
|
|
618
|
+
<DropdownMenu>
|
|
619
|
+
<DropdownMenuTrigger asChild>
|
|
620
|
+
<Button
|
|
621
|
+
variant="ghost"
|
|
622
|
+
size="icon"
|
|
623
|
+
className="h-8 w-8 opacity-0 group-hover:opacity-100 transition-opacity"
|
|
624
|
+
disabled={actionLoading === post.id}
|
|
625
|
+
data-cy={`posts-actions-${post.id}`}
|
|
626
|
+
>
|
|
627
|
+
{actionLoading === post.id ? (
|
|
628
|
+
<Loader2 className="h-4 w-4 animate-spin" />
|
|
629
|
+
) : (
|
|
630
|
+
<MoreHorizontal className="h-4 w-4" />
|
|
631
|
+
)}
|
|
632
|
+
</Button>
|
|
633
|
+
</DropdownMenuTrigger>
|
|
634
|
+
<DropdownMenuContent align="end" className="w-48">
|
|
635
|
+
<DropdownMenuItem
|
|
636
|
+
onClick={() => router.push(`/dashboard/posts/${post.id}/edit`)}
|
|
637
|
+
data-cy={`posts-edit-${post.id}`}
|
|
638
|
+
>
|
|
639
|
+
<Pencil className="h-4 w-4 mr-2" />
|
|
640
|
+
Edit
|
|
641
|
+
</DropdownMenuItem>
|
|
642
|
+
{post.status === 'published' && (
|
|
643
|
+
<DropdownMenuItem asChild data-cy={`posts-view-live-${post.id}`}>
|
|
644
|
+
<Link href={`/posts/${post.slug || post.id}`} target="_blank">
|
|
645
|
+
<ExternalLink className="h-4 w-4 mr-2" />
|
|
646
|
+
View Live
|
|
647
|
+
</Link>
|
|
648
|
+
</DropdownMenuItem>
|
|
649
|
+
)}
|
|
650
|
+
<DropdownMenuSeparator />
|
|
651
|
+
{post.status === 'published' ? (
|
|
652
|
+
<DropdownMenuItem onClick={() => handleUnpublish(post)} data-cy={`posts-unpublish-${post.id}`}>
|
|
653
|
+
<Archive className="h-4 w-4 mr-2" />
|
|
654
|
+
Unpublish
|
|
655
|
+
</DropdownMenuItem>
|
|
656
|
+
) : (
|
|
657
|
+
<DropdownMenuItem onClick={() => handlePublish(post)} data-cy={`posts-publish-${post.id}`}>
|
|
658
|
+
<Send className="h-4 w-4 mr-2" />
|
|
659
|
+
Publish
|
|
660
|
+
</DropdownMenuItem>
|
|
661
|
+
)}
|
|
662
|
+
<DropdownMenuSeparator />
|
|
663
|
+
<DropdownMenuItem
|
|
664
|
+
onClick={() => openDeleteDialog(post)}
|
|
665
|
+
className="text-destructive focus:text-destructive"
|
|
666
|
+
data-cy={`posts-delete-${post.id}`}
|
|
667
|
+
>
|
|
668
|
+
<Trash2 className="h-4 w-4 mr-2" />
|
|
669
|
+
Delete
|
|
670
|
+
</DropdownMenuItem>
|
|
671
|
+
</DropdownMenuContent>
|
|
672
|
+
</DropdownMenu>
|
|
673
|
+
</td>
|
|
674
|
+
</tr>
|
|
675
|
+
))}
|
|
676
|
+
</tbody>
|
|
677
|
+
</table>
|
|
678
|
+
</div>
|
|
679
|
+
</div>
|
|
680
|
+
) : (
|
|
681
|
+
/* Grid View */
|
|
682
|
+
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4" data-cy="posts-grid-container">
|
|
683
|
+
{filteredPosts.map((post) => (
|
|
684
|
+
<Card
|
|
685
|
+
key={post.id}
|
|
686
|
+
className="group overflow-hidden hover:shadow-md transition-shadow"
|
|
687
|
+
data-cy={`posts-card-${post.id}`}
|
|
688
|
+
>
|
|
689
|
+
{/* Featured Image */}
|
|
690
|
+
<div className="aspect-video relative bg-muted">
|
|
691
|
+
{post.featuredImage ? (
|
|
692
|
+
<Image
|
|
693
|
+
src={post.featuredImage}
|
|
694
|
+
alt=""
|
|
695
|
+
fill
|
|
696
|
+
className="object-cover"
|
|
697
|
+
/>
|
|
698
|
+
) : (
|
|
699
|
+
<div className="absolute inset-0 flex items-center justify-center">
|
|
700
|
+
<ImageIcon className="h-10 w-10 text-muted-foreground/30" />
|
|
701
|
+
</div>
|
|
702
|
+
)}
|
|
703
|
+
{/* Status Badge */}
|
|
704
|
+
<div className="absolute top-2 left-2">
|
|
705
|
+
<span
|
|
706
|
+
className={cn(
|
|
707
|
+
'inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs font-medium shadow-sm',
|
|
708
|
+
getStatusColor(post.status)
|
|
709
|
+
)}
|
|
710
|
+
>
|
|
711
|
+
{getStatusIcon(post.status)}
|
|
712
|
+
{post.status.charAt(0).toUpperCase() + post.status.slice(1)}
|
|
713
|
+
</span>
|
|
714
|
+
</div>
|
|
715
|
+
{/* Selection Checkbox */}
|
|
716
|
+
<div className="absolute top-2 right-2">
|
|
717
|
+
<Checkbox
|
|
718
|
+
checked={selectedPosts.has(post.id)}
|
|
719
|
+
onCheckedChange={() => toggleSelectPost(post.id)}
|
|
720
|
+
className="bg-background/80 backdrop-blur-sm"
|
|
721
|
+
/>
|
|
722
|
+
</div>
|
|
723
|
+
</div>
|
|
724
|
+
|
|
725
|
+
<CardContent className="p-4">
|
|
726
|
+
<Link
|
|
727
|
+
href={`/dashboard/posts/${post.id}/edit`}
|
|
728
|
+
className="font-medium hover:text-primary transition-colors line-clamp-2"
|
|
729
|
+
data-cy={`posts-card-title-${post.id}`}
|
|
730
|
+
>
|
|
731
|
+
{post.title || 'Untitled'}
|
|
732
|
+
</Link>
|
|
733
|
+
{post.excerpt && (
|
|
734
|
+
<p className="text-sm text-muted-foreground line-clamp-2 mt-1">
|
|
735
|
+
{post.excerpt}
|
|
736
|
+
</p>
|
|
737
|
+
)}
|
|
738
|
+
|
|
739
|
+
<div className="flex items-center justify-between mt-4 pt-4 border-t">
|
|
740
|
+
<span className="text-xs text-muted-foreground">
|
|
741
|
+
{formatDate(post.updatedAt)}
|
|
742
|
+
</span>
|
|
743
|
+
<div className="flex gap-1">
|
|
744
|
+
<Button
|
|
745
|
+
variant="ghost"
|
|
746
|
+
size="icon"
|
|
747
|
+
className="h-8 w-8"
|
|
748
|
+
onClick={() => router.push(`/dashboard/posts/${post.id}/edit`)}
|
|
749
|
+
>
|
|
750
|
+
<Pencil className="h-4 w-4" />
|
|
751
|
+
</Button>
|
|
752
|
+
{post.status === 'published' && (
|
|
753
|
+
<Button variant="ghost" size="icon" className="h-8 w-8" asChild>
|
|
754
|
+
<Link href={`/posts/${post.slug || post.id}`} target="_blank">
|
|
755
|
+
<ExternalLink className="h-4 w-4" />
|
|
756
|
+
</Link>
|
|
757
|
+
</Button>
|
|
758
|
+
)}
|
|
759
|
+
<DropdownMenu>
|
|
760
|
+
<DropdownMenuTrigger asChild>
|
|
761
|
+
<Button variant="ghost" size="icon" className="h-8 w-8">
|
|
762
|
+
<MoreHorizontal className="h-4 w-4" />
|
|
763
|
+
</Button>
|
|
764
|
+
</DropdownMenuTrigger>
|
|
765
|
+
<DropdownMenuContent align="end">
|
|
766
|
+
{post.status === 'published' ? (
|
|
767
|
+
<DropdownMenuItem onClick={() => handleUnpublish(post)}>
|
|
768
|
+
<Archive className="h-4 w-4 mr-2" />
|
|
769
|
+
Unpublish
|
|
770
|
+
</DropdownMenuItem>
|
|
771
|
+
) : (
|
|
772
|
+
<DropdownMenuItem onClick={() => handlePublish(post)}>
|
|
773
|
+
<Send className="h-4 w-4 mr-2" />
|
|
774
|
+
Publish
|
|
775
|
+
</DropdownMenuItem>
|
|
776
|
+
)}
|
|
777
|
+
<DropdownMenuSeparator />
|
|
778
|
+
<DropdownMenuItem
|
|
779
|
+
onClick={() => openDeleteDialog(post)}
|
|
780
|
+
className="text-destructive"
|
|
781
|
+
>
|
|
782
|
+
<Trash2 className="h-4 w-4 mr-2" />
|
|
783
|
+
Delete
|
|
784
|
+
</DropdownMenuItem>
|
|
785
|
+
</DropdownMenuContent>
|
|
786
|
+
</DropdownMenu>
|
|
787
|
+
</div>
|
|
788
|
+
</div>
|
|
789
|
+
</CardContent>
|
|
790
|
+
</Card>
|
|
791
|
+
))}
|
|
792
|
+
</div>
|
|
793
|
+
)}
|
|
794
|
+
|
|
795
|
+
{/* Results count */}
|
|
796
|
+
{!loading && filteredPosts.length > 0 && (
|
|
797
|
+
<div className="text-sm text-muted-foreground text-center">
|
|
798
|
+
Showing {filteredPosts.length} of {posts.length} posts
|
|
799
|
+
</div>
|
|
800
|
+
)}
|
|
801
|
+
</div>
|
|
802
|
+
|
|
803
|
+
{/* Delete Confirmation Dialog */}
|
|
804
|
+
<Dialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}>
|
|
805
|
+
<DialogContent data-cy="posts-delete-dialog">
|
|
806
|
+
<DialogHeader>
|
|
807
|
+
<DialogTitle>Delete Post</DialogTitle>
|
|
808
|
+
<DialogDescription>
|
|
809
|
+
Are you sure you want to delete "{postToDelete?.title}"? This action
|
|
810
|
+
cannot be undone.
|
|
811
|
+
</DialogDescription>
|
|
812
|
+
</DialogHeader>
|
|
813
|
+
<DialogFooter>
|
|
814
|
+
<Button variant="outline" onClick={() => setDeleteDialogOpen(false)} data-cy="posts-delete-cancel">
|
|
815
|
+
Cancel
|
|
816
|
+
</Button>
|
|
817
|
+
<Button
|
|
818
|
+
variant="destructive"
|
|
819
|
+
onClick={handleDelete}
|
|
820
|
+
disabled={actionLoading === postToDelete?.id}
|
|
821
|
+
data-cy="posts-delete-confirm"
|
|
822
|
+
>
|
|
823
|
+
{actionLoading === postToDelete?.id ? (
|
|
824
|
+
<Loader2 className="h-4 w-4 animate-spin mr-2" />
|
|
825
|
+
) : null}
|
|
826
|
+
Delete
|
|
827
|
+
</Button>
|
|
828
|
+
</DialogFooter>
|
|
829
|
+
</DialogContent>
|
|
830
|
+
</Dialog>
|
|
831
|
+
</div>
|
|
832
|
+
)
|
|
833
|
+
}
|