@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,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 &quot;{postToDelete?.title}&quot;? 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
+ }