@actuate-media/cms-admin 0.18.0 → 0.19.0

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 (75) hide show
  1. package/dist/__tests__/lib/post-editor-service.test.js +75 -23
  2. package/dist/__tests__/lib/post-editor-service.test.js.map +1 -1
  3. package/dist/__tests__/lib/posts-service.test.js +132 -142
  4. package/dist/__tests__/lib/posts-service.test.js.map +1 -1
  5. package/dist/__tests__/views/posts-list-view.render.test.d.ts +2 -0
  6. package/dist/__tests__/views/posts-list-view.render.test.d.ts.map +1 -0
  7. package/dist/__tests__/views/posts-list-view.render.test.js +73 -0
  8. package/dist/__tests__/views/posts-list-view.render.test.js.map +1 -0
  9. package/dist/actuate-admin.css +1 -1
  10. package/dist/index.d.ts +0 -2
  11. package/dist/index.d.ts.map +1 -1
  12. package/dist/index.js +0 -2
  13. package/dist/index.js.map +1 -1
  14. package/dist/lib/post-editor-service.d.ts +6 -4
  15. package/dist/lib/post-editor-service.d.ts.map +1 -1
  16. package/dist/lib/post-editor-service.js +32 -37
  17. package/dist/lib/post-editor-service.js.map +1 -1
  18. package/dist/lib/posts-service.d.ts.map +1 -1
  19. package/dist/lib/posts-service.js +91 -160
  20. package/dist/lib/posts-service.js.map +1 -1
  21. package/dist/views/Posts/NewPostPicker.d.ts +5 -4
  22. package/dist/views/Posts/NewPostPicker.d.ts.map +1 -1
  23. package/dist/views/Posts/NewPostPicker.js +5 -4
  24. package/dist/views/Posts/NewPostPicker.js.map +1 -1
  25. package/dist/views/Posts/PostTypesView.d.ts +5 -4
  26. package/dist/views/Posts/PostTypesView.d.ts.map +1 -1
  27. package/dist/views/Posts/PostTypesView.js.map +1 -1
  28. package/dist/views/Posts/PostsListView.d.ts.map +1 -1
  29. package/dist/views/Posts/PostsListView.js +8 -0
  30. package/dist/views/Posts/PostsListView.js.map +1 -1
  31. package/dist/views/page-editor/sections/ArticleBodySection.d.ts +6 -2
  32. package/dist/views/page-editor/sections/ArticleBodySection.d.ts.map +1 -1
  33. package/dist/views/page-editor/sections/ArticleBodySection.js +8 -8
  34. package/dist/views/page-editor/sections/ArticleBodySection.js.map +1 -1
  35. package/dist/views/post-editor/PostEditorCanvas.js +7 -1
  36. package/dist/views/post-editor/PostEditorCanvas.js.map +1 -1
  37. package/dist/views/post-editor/PostFieldsModal.d.ts.map +1 -1
  38. package/dist/views/post-editor/PostFieldsModal.js +10 -4
  39. package/dist/views/post-editor/PostFieldsModal.js.map +1 -1
  40. package/dist/views/post-editor/PostHeader.d.ts.map +1 -1
  41. package/dist/views/post-editor/PostHeader.js +4 -3
  42. package/dist/views/post-editor/PostHeader.js.map +1 -1
  43. package/dist/views/post-editor/PostPreview.d.ts.map +1 -1
  44. package/dist/views/post-editor/PostPreview.js +11 -1
  45. package/dist/views/post-editor/PostPreview.js.map +1 -1
  46. package/dist/views/post-editor/PostSectionEditor.d.ts +22 -2
  47. package/dist/views/post-editor/PostSectionEditor.d.ts.map +1 -1
  48. package/dist/views/post-editor/PostSectionEditor.js +4 -1
  49. package/dist/views/post-editor/PostSectionEditor.js.map +1 -1
  50. package/package.json +2 -2
  51. package/src/__tests__/lib/post-editor-service.test.ts +78 -23
  52. package/src/__tests__/lib/posts-service.test.ts +134 -163
  53. package/src/__tests__/views/posts-list-view.render.test.tsx +84 -0
  54. package/src/index.ts +0 -2
  55. package/src/lib/post-editor-service.ts +41 -40
  56. package/src/lib/posts-service.ts +109 -180
  57. package/src/views/Posts/NewPostPicker.tsx +5 -4
  58. package/src/views/Posts/PostTypesView.tsx +5 -4
  59. package/src/views/Posts/PostsListView.tsx +9 -0
  60. package/src/views/page-editor/sections/ArticleBodySection.tsx +12 -16
  61. package/src/views/post-editor/PostEditorCanvas.tsx +7 -1
  62. package/src/views/post-editor/PostFieldsModal.tsx +36 -21
  63. package/src/views/post-editor/PostHeader.tsx +4 -4
  64. package/src/views/post-editor/PostPreview.tsx +12 -1
  65. package/src/views/post-editor/PostSectionEditor.tsx +22 -5
  66. package/dist/views/PostEditor.d.ts +0 -6
  67. package/dist/views/PostEditor.d.ts.map +0 -1
  68. package/dist/views/PostEditor.js +0 -79
  69. package/dist/views/PostEditor.js.map +0 -1
  70. package/dist/views/Posts.d.ts +0 -5
  71. package/dist/views/Posts.d.ts.map +0 -1
  72. package/dist/views/Posts.js +0 -73
  73. package/dist/views/Posts.js.map +0 -1
  74. package/src/views/PostEditor.tsx +0 -257
  75. package/src/views/Posts.tsx +0 -375
@@ -1,257 +0,0 @@
1
- 'use client'
2
-
3
- import { useState, useEffect } from 'react'
4
- import { ArrowLeft, Eye, Loader2, AlertTriangle } from 'lucide-react'
5
- import { toast } from 'sonner'
6
- import { TipTapEditor } from '../components/TipTapEditor.js'
7
- import { SEOPanel, type SEOData } from '../components/SEOPanel.js'
8
- import { useApiData } from '../lib/useApiData.js'
9
- import { cmsApi } from '../lib/api.js'
10
-
11
- export interface PostEditorProps {
12
- id?: string
13
- onNavigate?: (path: string) => void
14
- }
15
-
16
- export function PostEditor({ id, onNavigate }: PostEditorProps) {
17
- const isNew = !id
18
- const { data, loading, error } = useApiData<any>(isNew ? '' : `/collections/posts/${id}`)
19
-
20
- const [title, setTitle] = useState('')
21
- const [content, setContent] = useState('')
22
- const [status, setStatus] = useState<'draft' | 'published'>('draft')
23
- const [slug, setSlug] = useState('')
24
- const [saving, setSaving] = useState(false)
25
- const [initialized, setInitialized] = useState(isNew)
26
- const [seoData, setSeoData] = useState<SEOData>({})
27
-
28
- useEffect(() => {
29
- if (data && !initialized) {
30
- setTitle(data.title ?? '')
31
- setContent(data.content ?? data.data?.content ?? '')
32
- setStatus(data.status === 'PUBLISHED' ? 'published' : 'draft')
33
- setSlug(data.slug ?? '')
34
- setInitialized(true)
35
- }
36
- }, [data, initialized])
37
-
38
- const savePost = async () => {
39
- setSaving(true)
40
- const body = JSON.stringify({
41
- title,
42
- content,
43
- slug,
44
- status: status === 'published' ? 'PUBLISHED' : 'DRAFT',
45
- })
46
-
47
- const res = isNew
48
- ? await cmsApi('/collections/posts', { method: 'POST', body })
49
- : await cmsApi(`/collections/posts/${id}`, { method: 'PUT', body })
50
-
51
- setSaving(false)
52
- if (res.error) {
53
- toast.error(res.error)
54
- } else {
55
- toast.success('Post saved successfully!')
56
- if (isNew && (res.data as any)?.id) {
57
- onNavigate?.(`/posts/${(res.data as any).id}`)
58
- }
59
- }
60
- }
61
-
62
- const publishPost = async () => {
63
- setSaving(true)
64
- const body = JSON.stringify({
65
- title,
66
- content,
67
- slug,
68
- status: 'PUBLISHED',
69
- })
70
-
71
- const endpoint = isNew ? '/collections/posts' : `/collections/posts/${id}`
72
- const method = isNew ? 'POST' : 'PUT'
73
- const res = await cmsApi(endpoint, { method, body })
74
-
75
- setSaving(false)
76
- if (res.error) {
77
- toast.error(res.error)
78
- } else {
79
- setStatus('published')
80
- toast.success('Post published!')
81
- if (isNew && (res.data as any)?.id) {
82
- onNavigate?.(`/posts/${(res.data as any).id}`)
83
- }
84
- }
85
- }
86
-
87
- if (!isNew && loading) {
88
- return (
89
- <div className="flex h-full items-center justify-center">
90
- <Loader2 className="h-6 w-6 animate-spin text-blue-600" />
91
- </div>
92
- )
93
- }
94
-
95
- return (
96
- <div className="flex h-full flex-col bg-white">
97
- {error && (
98
- <div className="mx-4 mt-3 flex items-center gap-3 rounded-lg border border-red-200 bg-red-50 p-3">
99
- <AlertTriangle className="h-5 w-5 shrink-0 text-red-600" />
100
- <span className="flex-1 text-sm text-red-800">{error}</span>
101
- </div>
102
- )}
103
-
104
- <div className="border-b border-gray-200 px-3 py-3 sm:px-4">
105
- <div className="flex items-center justify-between">
106
- <button
107
- onClick={() => onNavigate?.('/posts')}
108
- className="inline-flex items-center gap-2 text-sm text-gray-600 transition-colors hover:text-gray-900"
109
- >
110
- <ArrowLeft className="h-4 w-4" />
111
- <span className="hidden sm:inline">Back to Posts</span>
112
- <span className="sm:hidden">Back</span>
113
- </button>
114
- <div className="flex items-center gap-2">
115
- <button className="flex items-center gap-2 rounded-lg border border-gray-300 px-3 py-1.5 text-sm transition-colors hover:bg-gray-50">
116
- <Eye className="h-4 w-4" />
117
- <span className="hidden sm:inline">Preview</span>
118
- </button>
119
- </div>
120
- </div>
121
- </div>
122
-
123
- <div className="flex flex-1 flex-col overflow-hidden lg:flex-row">
124
- <div className="flex-1 overflow-y-auto">
125
- <div className="mx-auto max-w-4xl p-3 pr-6 sm:p-4 sm:pr-8">
126
- <input
127
- type="text"
128
- value={title}
129
- onChange={(e) => setTitle(e.target.value)}
130
- placeholder="Post title"
131
- className="mb-4 w-full border-none px-0 text-2xl font-bold placeholder:text-gray-300 focus:ring-0 focus:outline-none sm:text-3xl"
132
- />
133
-
134
- <div className="mb-4">
135
- <label className="mb-1 block text-xs font-medium text-gray-600">URL Slug</label>
136
- <input
137
- type="text"
138
- value={slug}
139
- onChange={(e) => setSlug(e.target.value)}
140
- placeholder="url-slug"
141
- className="w-full rounded-lg border border-gray-300 px-3 py-1.5 text-sm focus:ring-2 focus:ring-blue-500 focus:outline-none"
142
- />
143
- </div>
144
-
145
- <div className="mb-4">
146
- <TipTapEditor content={content} onChange={setContent} />
147
- </div>
148
- </div>
149
- </div>
150
-
151
- <div className="hidden w-[30%] overflow-y-auto border-l border-gray-200 bg-gray-50 lg:block">
152
- <div className="space-y-4 p-4">
153
- <div className="rounded-lg border border-gray-200 bg-white p-4">
154
- <h3 className="mb-3 text-sm font-semibold text-gray-900">Publish</h3>
155
- <div className="space-y-3">
156
- <div>
157
- <label className="mb-1 block text-xs font-medium text-gray-700">Status</label>
158
- <select
159
- value={status}
160
- onChange={(e) => setStatus(e.target.value as 'draft' | 'published')}
161
- className="w-full rounded-lg border border-gray-300 px-3 py-1.5 text-sm focus:ring-2 focus:ring-blue-500 focus:outline-none"
162
- >
163
- <option value="draft">Draft</option>
164
- <option value="published">Published</option>
165
- </select>
166
- </div>
167
- <div>
168
- <label className="mb-1 block text-xs font-medium text-gray-700">
169
- Publish Date
170
- </label>
171
- <input
172
- type="datetime-local"
173
- className="w-full rounded-lg border border-gray-300 px-3 py-1.5 text-sm focus:ring-2 focus:ring-blue-500 focus:outline-none"
174
- />
175
- </div>
176
- <button
177
- onClick={savePost}
178
- disabled={saving}
179
- className="w-full rounded-lg bg-blue-600 py-2 text-sm font-medium text-white transition-colors hover:bg-blue-700 disabled:opacity-50"
180
- >
181
- {saving ? 'Saving...' : 'Save Post'}
182
- </button>
183
- {status === 'draft' && (
184
- <button
185
- onClick={publishPost}
186
- disabled={saving}
187
- className="w-full rounded-lg bg-green-600 py-2 text-sm font-medium text-white transition-colors hover:bg-green-700 disabled:opacity-50"
188
- >
189
- {saving ? 'Publishing...' : 'Publish'}
190
- </button>
191
- )}
192
- </div>
193
- </div>
194
-
195
- <div className="rounded-lg border border-gray-200 bg-white p-4">
196
- <h3 className="mb-3 text-sm font-semibold text-gray-900">Categories & Tags</h3>
197
- <div className="space-y-3">
198
- <div>
199
- <label className="mb-1 block text-xs font-medium text-gray-700">Category</label>
200
- <select className="w-full rounded-lg border border-gray-300 px-3 py-1.5 text-sm focus:ring-2 focus:ring-blue-500 focus:outline-none">
201
- <option>Technology</option>
202
- <option>Design</option>
203
- <option>Business</option>
204
- </select>
205
- </div>
206
- <div>
207
- <label className="mb-1 block text-xs font-medium text-gray-700">Tags</label>
208
- <input
209
- type="text"
210
- placeholder="Add tags..."
211
- className="w-full rounded-lg border border-gray-300 px-3 py-1.5 text-sm focus:ring-2 focus:ring-blue-500 focus:outline-none"
212
- />
213
- </div>
214
- </div>
215
- </div>
216
-
217
- <div className="rounded-lg border border-gray-200 bg-white p-4">
218
- <h3 className="mb-3 text-sm font-semibold text-gray-900">Featured Image</h3>
219
- <button className="w-full rounded-lg border-2 border-dashed border-gray-300 py-2 text-sm text-gray-600 transition-colors hover:border-gray-400">
220
- Upload Image
221
- </button>
222
- </div>
223
-
224
- <SEOPanel
225
- title={title}
226
- slug={slug}
227
- content={content}
228
- seoData={seoData}
229
- onChange={setSeoData}
230
- />
231
- </div>
232
- </div>
233
-
234
- <div className="border-t border-gray-200 bg-white p-3 lg:hidden">
235
- <div className="flex gap-2">
236
- <button
237
- onClick={savePost}
238
- disabled={saving}
239
- className="flex-1 rounded-lg bg-blue-600 py-2 text-sm font-medium text-white transition-colors hover:bg-blue-700 disabled:opacity-50"
240
- >
241
- {saving ? 'Saving...' : 'Save Post'}
242
- </button>
243
- {status === 'draft' && (
244
- <button
245
- onClick={publishPost}
246
- disabled={saving}
247
- className="flex-1 rounded-lg bg-green-600 py-2 text-sm font-medium text-white transition-colors hover:bg-green-700 disabled:opacity-50"
248
- >
249
- Publish
250
- </button>
251
- )}
252
- </div>
253
- </div>
254
- </div>
255
- </div>
256
- )
257
- }
@@ -1,375 +0,0 @@
1
- 'use client'
2
-
3
- import {
4
- Plus,
5
- Search,
6
- Trash2,
7
- SlidersHorizontal,
8
- Pencil,
9
- ArrowUpDown,
10
- ArrowUp,
11
- ArrowDown,
12
- Loader2,
13
- AlertTriangle,
14
- } from 'lucide-react'
15
- import { useState, useMemo } from 'react'
16
- import { toast } from 'sonner'
17
- import { sortByRelevance, type SortConfig, toggleSort } from '../lib/search.js'
18
- import { useApiData } from '../lib/useApiData.js'
19
- import { cmsApi } from '../lib/api.js'
20
-
21
- type PostSortKey = 'title' | 'author' | 'category' | 'status' | 'date'
22
-
23
- export interface PostsProps {
24
- onNavigate?: (path: string) => void
25
- }
26
-
27
- export function Posts({ onNavigate }: PostsProps) {
28
- const { data, loading, error, refetch } = useApiData<{ docs: any[]; total: number }>(
29
- '/collections/posts?pageSize=100',
30
- )
31
- const [searchQuery, setSearchQuery] = useState('')
32
- const [filterStatus, setFilterStatus] = useState<string>('all')
33
- const [filterCategory, setFilterCategory] = useState<string>('all')
34
- const [selectedPosts, setSelectedPosts] = useState<number[]>([])
35
- const [sortConfig, setSortConfig] = useState<SortConfig<PostSortKey> | null>(null)
36
-
37
- const posts = data?.docs ?? []
38
-
39
- const filteredAndSorted = useMemo(() => {
40
- let results = posts.filter((post: any) => {
41
- const matchesSearch =
42
- (post.title ?? '').toLowerCase().includes(searchQuery.toLowerCase()) ||
43
- (post.author ?? '').toLowerCase().includes(searchQuery.toLowerCase())
44
- const matchesStatus =
45
- filterStatus === 'all' || (post.status ?? '').toLowerCase() === filterStatus.toLowerCase()
46
- const matchesCategory =
47
- filterCategory === 'all' ||
48
- (post.category ?? '').toLowerCase() === filterCategory.toLowerCase()
49
- return matchesSearch && matchesStatus && matchesCategory
50
- })
51
-
52
- if (searchQuery.trim()) {
53
- results = sortByRelevance(results, searchQuery, (p: any) => [
54
- p.title,
55
- p.author ?? '',
56
- p.category ?? '',
57
- ])
58
- } else if (sortConfig) {
59
- results = [...results].sort((a: any, b: any) => {
60
- const aVal = a[sortConfig.key] ?? ''
61
- const bVal = b[sortConfig.key] ?? ''
62
- const cmp = String(aVal).localeCompare(String(bVal))
63
- return sortConfig.direction === 'asc' ? cmp : -cmp
64
- })
65
- }
66
- return results
67
- }, [posts, searchQuery, filterStatus, filterCategory, sortConfig])
68
-
69
- const handleSelectAll = (checked: boolean) => {
70
- setSelectedPosts(checked ? filteredAndSorted.map((p: any) => p.id) : [])
71
- }
72
-
73
- const handleSelectPost = (id: number) => {
74
- setSelectedPosts((prev) =>
75
- prev.includes(id) ? prev.filter((pid) => pid !== id) : [...prev, id],
76
- )
77
- }
78
-
79
- const handleBulkDelete = async () => {
80
- for (const id of selectedPosts) {
81
- await cmsApi(`/collections/posts/${id}`, { method: 'DELETE' })
82
- }
83
- toast.success(`${selectedPosts.length} posts deleted`)
84
- setSelectedPosts([])
85
- refetch()
86
- }
87
-
88
- const handleDelete = async (id: number) => {
89
- await cmsApi(`/collections/posts/${id}`, { method: 'DELETE' })
90
- toast.success('Post deleted')
91
- setSelectedPosts((prev) => prev.filter((pid) => pid !== id))
92
- refetch()
93
- }
94
-
95
- function SortHeader({ label, sortKey }: { label: string; sortKey: PostSortKey }) {
96
- const active = sortConfig?.key === sortKey
97
- return (
98
- <button
99
- type="button"
100
- onClick={() => setSortConfig(toggleSort(sortConfig, sortKey))}
101
- className="flex items-center gap-1 text-xs font-medium text-gray-700 transition-colors hover:text-gray-900"
102
- >
103
- {label}
104
- {active ? (
105
- sortConfig!.direction === 'asc' ? (
106
- <ArrowUp className="h-3 w-3" />
107
- ) : (
108
- <ArrowDown className="h-3 w-3" />
109
- )
110
- ) : (
111
- <ArrowUpDown className="h-3 w-3 text-gray-400" />
112
- )}
113
- </button>
114
- )
115
- }
116
-
117
- if (loading) {
118
- return (
119
- <div className="flex h-64 items-center justify-center p-3 pr-6 sm:p-4 sm:pr-8">
120
- <Loader2 className="h-6 w-6 animate-spin text-blue-600" />
121
- </div>
122
- )
123
- }
124
-
125
- return (
126
- <div className="flex h-full flex-col p-3 pr-6 sm:p-4 sm:pr-8">
127
- {error && (
128
- <div className="mb-4 flex items-center gap-3 rounded-lg border border-red-200 bg-red-50 p-3">
129
- <AlertTriangle className="h-5 w-5 shrink-0 text-red-600" />
130
- <span className="flex-1 text-sm text-red-800">{error}</span>
131
- <button
132
- onClick={refetch}
133
- className="rounded-lg border border-red-300 px-3 py-1 text-sm text-red-700 transition-colors hover:bg-red-100"
134
- >
135
- Retry
136
- </button>
137
- </div>
138
- )}
139
-
140
- <div className="mb-4 flex flex-col justify-between gap-3 sm:flex-row sm:items-center">
141
- <div>
142
- <h1 className="mb-1 text-xl font-semibold text-gray-900 sm:text-2xl">Posts</h1>
143
- <p className="text-sm text-gray-600">{filteredAndSorted.length} total posts</p>
144
- </div>
145
- <button
146
- onClick={() => onNavigate?.('/posts/new')}
147
- className="flex items-center justify-center gap-2 rounded-lg bg-blue-600 px-4 py-2 text-sm text-white transition-colors hover:bg-blue-700"
148
- >
149
- <Plus className="h-4 w-4" />
150
- <span className="hidden sm:inline">New Post</span>
151
- <span className="sm:hidden">New</span>
152
- </button>
153
- </div>
154
-
155
- <div className="mb-4 rounded-lg border border-gray-200 bg-white">
156
- <div className="flex flex-col gap-3 p-3">
157
- <div className="relative">
158
- <Search className="absolute top-1/2 left-3 h-4 w-4 -translate-y-1/2 text-gray-400" />
159
- <input
160
- type="text"
161
- placeholder="Search posts..."
162
- value={searchQuery}
163
- onChange={(e) => setSearchQuery(e.target.value)}
164
- className="w-full rounded-lg border border-gray-300 py-2 pr-3 pl-9 text-sm focus:ring-2 focus:ring-blue-500 focus:outline-none"
165
- />
166
- </div>
167
- <div className="flex flex-col gap-2 sm:flex-row">
168
- <select
169
- value={filterStatus}
170
- onChange={(e) => setFilterStatus(e.target.value)}
171
- className="flex-1 rounded-lg border border-gray-300 px-3 py-2 text-sm focus:ring-2 focus:ring-blue-500 focus:outline-none"
172
- >
173
- <option value="all">All Status</option>
174
- <option value="published">Published</option>
175
- <option value="draft">Draft</option>
176
- </select>
177
- <select
178
- value={filterCategory}
179
- onChange={(e) => setFilterCategory(e.target.value)}
180
- className="flex-1 rounded-lg border border-gray-300 px-3 py-2 text-sm focus:ring-2 focus:ring-blue-500 focus:outline-none"
181
- >
182
- <option value="all">All Categories</option>
183
- <option value="technology">Technology</option>
184
- <option value="design">Design</option>
185
- <option value="business">Business</option>
186
- </select>
187
- <button
188
- type="button"
189
- className="flex items-center justify-center gap-2 rounded-lg border border-gray-300 px-3 py-2 text-sm transition-colors hover:bg-gray-50"
190
- >
191
- <SlidersHorizontal className="h-4 w-4" />
192
- <span className="hidden sm:inline">More Filters</span>
193
- </button>
194
- </div>
195
- </div>
196
- </div>
197
-
198
- {selectedPosts.length > 0 && (
199
- <div className="mb-4 rounded-lg border border-blue-200 bg-blue-50 p-3">
200
- <div className="flex flex-col justify-between gap-2 sm:flex-row sm:items-center">
201
- <span className="text-sm text-blue-900">
202
- {selectedPosts.length} post{selectedPosts.length !== 1 ? 's' : ''} selected
203
- </span>
204
- <div className="flex items-center gap-2">
205
- <button
206
- type="button"
207
- onClick={handleBulkDelete}
208
- className="flex-1 rounded-lg bg-red-600 px-3 py-1.5 text-sm text-white transition-colors hover:bg-red-700 sm:flex-none"
209
- >
210
- Delete
211
- </button>
212
- <button
213
- type="button"
214
- onClick={() => setSelectedPosts([])}
215
- className="flex-1 rounded-lg border border-gray-300 bg-white px-3 py-1.5 text-sm transition-colors hover:bg-gray-50 sm:flex-none"
216
- >
217
- Cancel
218
- </button>
219
- </div>
220
- </div>
221
- </div>
222
- )}
223
-
224
- {filteredAndSorted.length === 0 && !error ? (
225
- <div className="flex flex-1 flex-col items-center justify-center rounded-lg border border-gray-200 bg-white p-8 text-center">
226
- <p className="mb-2 text-sm text-gray-500">No posts yet</p>
227
- <button
228
- onClick={() => onNavigate?.('/posts/new')}
229
- className="rounded-lg bg-blue-600 px-4 py-2 text-sm text-white transition-colors hover:bg-blue-700"
230
- >
231
- Create your first post
232
- </button>
233
- </div>
234
- ) : (
235
- <>
236
- <div className="hidden flex-1 overflow-hidden rounded-lg border border-gray-200 bg-white md:block">
237
- <div className="h-full overflow-x-auto">
238
- <table className="w-full">
239
- <thead className="sticky top-0 border-b border-gray-200 bg-gray-50">
240
- <tr>
241
- <th className="w-8 px-3 py-2 text-left">
242
- <input
243
- type="checkbox"
244
- checked={
245
- selectedPosts.length === filteredAndSorted.length &&
246
- filteredAndSorted.length > 0
247
- }
248
- onChange={(e) => handleSelectAll(e.target.checked)}
249
- className="rounded border-gray-300"
250
- />
251
- </th>
252
- <th className="px-3 py-2 text-left">
253
- <SortHeader label="Title" sortKey="title" />
254
- </th>
255
- <th className="px-3 py-2 text-left">
256
- <SortHeader label="Author" sortKey="author" />
257
- </th>
258
- <th className="px-3 py-2 text-left">
259
- <SortHeader label="Category" sortKey="category" />
260
- </th>
261
- <th className="px-3 py-2 text-left">
262
- <SortHeader label="Status" sortKey="status" />
263
- </th>
264
- <th className="px-3 py-2 text-left">
265
- <SortHeader label="Date" sortKey="date" />
266
- </th>
267
- <th className="px-3 py-2 text-left text-xs font-medium text-gray-700">
268
- Actions
269
- </th>
270
- </tr>
271
- </thead>
272
- <tbody className="divide-y divide-gray-200">
273
- {filteredAndSorted.map((post: any) => (
274
- <tr key={post.id} className="transition-colors hover:bg-gray-50">
275
- <td className="px-3 py-2">
276
- <input
277
- type="checkbox"
278
- checked={selectedPosts.includes(post.id)}
279
- onChange={() => handleSelectPost(post.id)}
280
- className="rounded border-gray-300"
281
- />
282
- </td>
283
- <td className="px-3 py-2">
284
- <button
285
- type="button"
286
- onClick={() => onNavigate?.(`/posts/${post.id}`)}
287
- className="text-left text-sm font-medium text-gray-900 hover:text-blue-600"
288
- >
289
- {post.title}
290
- </button>
291
- </td>
292
- <td className="px-3 py-2 text-sm text-gray-600">{post.author}</td>
293
- <td className="px-3 py-2 text-sm text-gray-600">{post.category}</td>
294
- <td className="px-3 py-2">
295
- <span
296
- className={`inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium ${post.status === 'Published' ? 'bg-green-100 text-green-800' : 'bg-gray-100 text-gray-800'}`}
297
- >
298
- {post.status}
299
- </span>
300
- </td>
301
- <td className="px-3 py-2 text-sm text-gray-600">{post.date}</td>
302
- <td className="px-3 py-2">
303
- <div className="flex items-center gap-2">
304
- <button
305
- type="button"
306
- onClick={() => onNavigate?.(`/posts/${post.id}`)}
307
- className="rounded p-1.5 transition-colors hover:bg-gray-100"
308
- title="Edit"
309
- >
310
- <Pencil className="h-4 w-4 text-gray-600" />
311
- </button>
312
- <button
313
- type="button"
314
- onClick={() => handleDelete(post.id)}
315
- className="rounded p-1.5 transition-colors hover:bg-gray-100"
316
- title="Delete"
317
- >
318
- <Trash2 className="h-4 w-4 text-red-600" />
319
- </button>
320
- </div>
321
- </td>
322
- </tr>
323
- ))}
324
- </tbody>
325
- </table>
326
- </div>
327
- </div>
328
-
329
- <div className="flex-1 overflow-auto rounded-lg border border-gray-200 bg-white md:hidden">
330
- <div className="divide-y divide-gray-200">
331
- {filteredAndSorted.map((post: any) => (
332
- <div key={post.id} className="p-3">
333
- <div className="flex items-start gap-3">
334
- <input
335
- type="checkbox"
336
- checked={selectedPosts.includes(post.id)}
337
- onChange={() => handleSelectPost(post.id)}
338
- className="mt-1 rounded border-gray-300"
339
- />
340
- <div className="min-w-0 flex-1">
341
- <button
342
- type="button"
343
- onClick={() => onNavigate?.(`/posts/${post.id}`)}
344
- className="mb-1 block text-left text-sm font-medium text-gray-900 hover:text-blue-600"
345
- >
346
- {post.title}
347
- </button>
348
- <div className="mb-2 flex flex-wrap items-center gap-2 text-xs text-gray-600">
349
- <span>{post.author}</span>
350
- <span>•</span>
351
- <span>{post.date}</span>
352
- <span
353
- className={`rounded-full px-2 py-0.5 text-xs font-medium ${post.status === 'Published' ? 'bg-green-100 text-green-800' : 'bg-gray-100 text-gray-800'}`}
354
- >
355
- {post.status}
356
- </span>
357
- </div>
358
- </div>
359
- <button
360
- type="button"
361
- onClick={() => handleDelete(post.id)}
362
- className="rounded p-1.5 transition-colors hover:bg-gray-100"
363
- >
364
- <Trash2 className="h-4 w-4 text-red-600" />
365
- </button>
366
- </div>
367
- </div>
368
- ))}
369
- </div>
370
- </div>
371
- </>
372
- )}
373
- </div>
374
- )
375
- }