@actuate-media/cms-admin 0.18.0 → 0.20.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.
- package/dist/AdminRoot.d.ts.map +1 -1
- package/dist/AdminRoot.js +9 -5
- package/dist/AdminRoot.js.map +1 -1
- package/dist/__tests__/lib/post-editor-service.test.js +75 -23
- package/dist/__tests__/lib/post-editor-service.test.js.map +1 -1
- package/dist/__tests__/lib/posts-service.test.js +132 -142
- package/dist/__tests__/lib/posts-service.test.js.map +1 -1
- package/dist/__tests__/views/posts-list-view.render.test.d.ts +2 -0
- package/dist/__tests__/views/posts-list-view.render.test.d.ts.map +1 -0
- package/dist/__tests__/views/posts-list-view.render.test.js +73 -0
- package/dist/__tests__/views/posts-list-view.render.test.js.map +1 -0
- package/dist/actuate-admin.css +1 -1
- package/dist/components/seo/Drawer.d.ts +11 -0
- package/dist/components/seo/Drawer.d.ts.map +1 -0
- package/dist/components/seo/Drawer.js +13 -0
- package/dist/components/seo/Drawer.js.map +1 -0
- package/dist/components/seo/RedirectEditorDrawer.d.ts +9 -0
- package/dist/components/seo/RedirectEditorDrawer.d.ts.map +1 -0
- package/dist/components/seo/RedirectEditorDrawer.js +94 -0
- package/dist/components/seo/RedirectEditorDrawer.js.map +1 -0
- package/dist/components/seo/SeoCopilotDrawer.d.ts +10 -0
- package/dist/components/seo/SeoCopilotDrawer.d.ts.map +1 -0
- package/dist/components/seo/SeoCopilotDrawer.js +34 -0
- package/dist/components/seo/SeoCopilotDrawer.js.map +1 -0
- package/dist/components/seo/SeoEditorDrawer.d.ts +8 -0
- package/dist/components/seo/SeoEditorDrawer.d.ts.map +1 -0
- package/dist/components/seo/SeoEditorDrawer.js +111 -0
- package/dist/components/seo/SeoEditorDrawer.js.map +1 -0
- package/dist/components/seo/SeoIssueFixDrawer.d.ts +8 -0
- package/dist/components/seo/SeoIssueFixDrawer.d.ts.map +1 -0
- package/dist/components/seo/SeoIssueFixDrawer.js +111 -0
- package/dist/components/seo/SeoIssueFixDrawer.js.map +1 -0
- package/dist/components/seo/primitives.d.ts +53 -0
- package/dist/components/seo/primitives.d.ts.map +1 -0
- package/dist/components/seo/primitives.js +100 -0
- package/dist/components/seo/primitives.js.map +1 -0
- package/dist/index.d.ts +0 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +0 -2
- package/dist/index.js.map +1 -1
- package/dist/layout/Sidebar.d.ts.map +1 -1
- package/dist/layout/Sidebar.js +3 -0
- package/dist/layout/Sidebar.js.map +1 -1
- package/dist/lib/post-editor-service.d.ts +6 -4
- package/dist/lib/post-editor-service.d.ts.map +1 -1
- package/dist/lib/post-editor-service.js +32 -37
- package/dist/lib/post-editor-service.js.map +1 -1
- package/dist/lib/posts-service.d.ts.map +1 -1
- package/dist/lib/posts-service.js +91 -160
- package/dist/lib/posts-service.js.map +1 -1
- package/dist/lib/seo-service.d.ts +322 -0
- package/dist/lib/seo-service.d.ts.map +1 -0
- package/dist/lib/seo-service.js +295 -0
- package/dist/lib/seo-service.js.map +1 -0
- package/dist/router/index.d.ts.map +1 -1
- package/dist/router/index.js +4 -1
- package/dist/router/index.js.map +1 -1
- package/dist/views/MediaBrowser.d.ts.map +1 -1
- package/dist/views/MediaBrowser.js +36 -25
- package/dist/views/MediaBrowser.js.map +1 -1
- package/dist/views/Posts/NewPostPicker.d.ts +5 -4
- package/dist/views/Posts/NewPostPicker.d.ts.map +1 -1
- package/dist/views/Posts/NewPostPicker.js +5 -4
- package/dist/views/Posts/NewPostPicker.js.map +1 -1
- package/dist/views/Posts/NewPostTypeInstructions.js +2 -2
- package/dist/views/Posts/PostTypesView.d.ts +5 -4
- package/dist/views/Posts/PostTypesView.d.ts.map +1 -1
- package/dist/views/Posts/PostTypesView.js.map +1 -1
- package/dist/views/Posts/PostsListView.d.ts.map +1 -1
- package/dist/views/Posts/PostsListView.js +8 -0
- package/dist/views/Posts/PostsListView.js.map +1 -1
- package/dist/views/Posts/post-type-presets.d.ts +5 -0
- package/dist/views/Posts/post-type-presets.d.ts.map +1 -1
- package/dist/views/Posts/post-type-presets.js +10 -64
- package/dist/views/Posts/post-type-presets.js.map +1 -1
- package/dist/views/SEO.d.ts.map +1 -1
- package/dist/views/SEO.js +89 -132
- package/dist/views/SEO.js.map +1 -1
- package/dist/views/page-editor/sections/ArticleBodySection.d.ts +6 -2
- package/dist/views/page-editor/sections/ArticleBodySection.d.ts.map +1 -1
- package/dist/views/page-editor/sections/ArticleBodySection.js +8 -8
- package/dist/views/page-editor/sections/ArticleBodySection.js.map +1 -1
- package/dist/views/post-editor/PostEditorCanvas.js +7 -1
- package/dist/views/post-editor/PostEditorCanvas.js.map +1 -1
- package/dist/views/post-editor/PostFieldsModal.d.ts.map +1 -1
- package/dist/views/post-editor/PostFieldsModal.js +10 -4
- package/dist/views/post-editor/PostFieldsModal.js.map +1 -1
- package/dist/views/post-editor/PostHeader.d.ts.map +1 -1
- package/dist/views/post-editor/PostHeader.js +4 -3
- package/dist/views/post-editor/PostHeader.js.map +1 -1
- package/dist/views/post-editor/PostPreview.d.ts.map +1 -1
- package/dist/views/post-editor/PostPreview.js +11 -1
- package/dist/views/post-editor/PostPreview.js.map +1 -1
- package/dist/views/post-editor/PostSectionEditor.d.ts +22 -2
- package/dist/views/post-editor/PostSectionEditor.d.ts.map +1 -1
- package/dist/views/post-editor/PostSectionEditor.js +4 -1
- package/dist/views/post-editor/PostSectionEditor.js.map +1 -1
- package/dist/views/seo/AuditTab.d.ts +6 -0
- package/dist/views/seo/AuditTab.d.ts.map +1 -0
- package/dist/views/seo/AuditTab.js +57 -0
- package/dist/views/seo/AuditTab.js.map +1 -0
- package/dist/views/seo/ContentTab.d.ts +5 -0
- package/dist/views/seo/ContentTab.d.ts.map +1 -0
- package/dist/views/seo/ContentTab.js +27 -0
- package/dist/views/seo/ContentTab.js.map +1 -0
- package/dist/views/seo/OverviewTab.d.ts +8 -0
- package/dist/views/seo/OverviewTab.d.ts.map +1 -0
- package/dist/views/seo/OverviewTab.js +35 -0
- package/dist/views/seo/OverviewTab.js.map +1 -0
- package/dist/views/seo/RedirectsTab.d.ts +2 -0
- package/dist/views/seo/RedirectsTab.d.ts.map +1 -0
- package/dist/views/seo/RedirectsTab.js +124 -0
- package/dist/views/seo/RedirectsTab.js.map +1 -0
- package/dist/views/seo/TechnicalTab.d.ts +2 -0
- package/dist/views/seo/TechnicalTab.d.ts.map +1 -0
- package/dist/views/seo/TechnicalTab.js +102 -0
- package/dist/views/seo/TechnicalTab.js.map +1 -0
- package/dist/views/seo/useSeoResource.d.ts +8 -0
- package/dist/views/seo/useSeoResource.d.ts.map +1 -0
- package/dist/views/seo/useSeoResource.js +42 -0
- package/dist/views/seo/useSeoResource.js.map +1 -0
- package/package.json +2 -2
- package/src/AdminRoot.tsx +9 -5
- package/src/__tests__/lib/post-editor-service.test.ts +78 -23
- package/src/__tests__/lib/posts-service.test.ts +134 -163
- package/src/__tests__/views/posts-list-view.render.test.tsx +84 -0
- package/src/components/seo/Drawer.tsx +61 -0
- package/src/components/seo/RedirectEditorDrawer.tsx +235 -0
- package/src/components/seo/SeoCopilotDrawer.tsx +76 -0
- package/src/components/seo/SeoEditorDrawer.tsx +347 -0
- package/src/components/seo/SeoIssueFixDrawer.tsx +244 -0
- package/src/components/seo/primitives.tsx +237 -0
- package/src/index.ts +0 -2
- package/src/layout/Sidebar.tsx +3 -0
- package/src/lib/post-editor-service.ts +41 -40
- package/src/lib/posts-service.ts +109 -180
- package/src/lib/seo-service.ts +623 -0
- package/src/router/index.ts +4 -1
- package/src/views/MediaBrowser.tsx +89 -67
- package/src/views/Posts/NewPostPicker.tsx +5 -4
- package/src/views/Posts/NewPostTypeInstructions.tsx +2 -2
- package/src/views/Posts/PostTypesView.tsx +5 -4
- package/src/views/Posts/PostsListView.tsx +9 -0
- package/src/views/Posts/post-type-presets.ts +15 -64
- package/src/views/SEO.tsx +186 -863
- package/src/views/page-editor/sections/ArticleBodySection.tsx +12 -16
- package/src/views/post-editor/PostEditorCanvas.tsx +7 -1
- package/src/views/post-editor/PostFieldsModal.tsx +36 -21
- package/src/views/post-editor/PostHeader.tsx +4 -4
- package/src/views/post-editor/PostPreview.tsx +12 -1
- package/src/views/post-editor/PostSectionEditor.tsx +22 -5
- package/src/views/seo/AuditTab.tsx +229 -0
- package/src/views/seo/ContentTab.tsx +164 -0
- package/src/views/seo/OverviewTab.tsx +257 -0
- package/src/views/seo/RedirectsTab.tsx +392 -0
- package/src/views/seo/TechnicalTab.tsx +303 -0
- package/src/views/seo/useSeoResource.ts +52 -0
- package/dist/views/PostEditor.d.ts +0 -6
- package/dist/views/PostEditor.d.ts.map +0 -1
- package/dist/views/PostEditor.js +0 -79
- package/dist/views/PostEditor.js.map +0 -1
- package/dist/views/Posts.d.ts +0 -5
- package/dist/views/Posts.d.ts.map +0 -1
- package/dist/views/Posts.js +0 -73
- package/dist/views/Posts.js.map +0 -1
- package/src/views/PostEditor.tsx +0 -257
- package/src/views/Posts.tsx +0 -375
package/src/views/PostEditor.tsx
DELETED
|
@@ -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
|
-
}
|
package/src/views/Posts.tsx
DELETED
|
@@ -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
|
-
}
|