@actuate-media/cms-admin 0.72.2 → 0.73.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/CHANGELOG.md +39 -0
- package/dist/__tests__/components/resizable-aside.test.d.ts +2 -0
- package/dist/__tests__/components/resizable-aside.test.d.ts.map +1 -0
- package/dist/__tests__/components/resizable-aside.test.js +77 -0
- package/dist/__tests__/components/resizable-aside.test.js.map +1 -0
- package/dist/__tests__/components/toggle.test.d.ts +2 -0
- package/dist/__tests__/components/toggle.test.d.ts.map +1 -0
- package/dist/__tests__/components/toggle.test.js +17 -0
- package/dist/__tests__/components/toggle.test.js.map +1 -0
- package/dist/__tests__/views/post-fields-modal.render.test.js +38 -0
- package/dist/__tests__/views/post-fields-modal.render.test.js.map +1 -1
- package/dist/actuate-admin.css +1 -1
- package/dist/components/MediaPickerModal.d.ts.map +1 -1
- package/dist/components/MediaPickerModal.js +1 -1
- package/dist/components/MediaPickerModal.js.map +1 -1
- package/dist/components/TagInput.js +1 -1
- package/dist/components/TagInput.js.map +1 -1
- package/dist/components/ui/Modal.d.ts +17 -1
- package/dist/components/ui/Modal.d.ts.map +1 -1
- package/dist/components/ui/Modal.js +29 -3
- package/dist/components/ui/Modal.js.map +1 -1
- package/dist/components/ui/ResizableAside.d.ts +38 -0
- package/dist/components/ui/ResizableAside.d.ts.map +1 -0
- package/dist/components/ui/ResizableAside.js +209 -0
- package/dist/components/ui/ResizableAside.js.map +1 -0
- package/dist/components/ui/Toggle.d.ts.map +1 -1
- package/dist/components/ui/Toggle.js +4 -3
- package/dist/components/ui/Toggle.js.map +1 -1
- package/dist/fields/RichTextField.d.ts +3 -1
- package/dist/fields/RichTextField.d.ts.map +1 -1
- package/dist/fields/RichTextField.js +2 -2
- package/dist/fields/RichTextField.js.map +1 -1
- package/dist/views/Dashboard.d.ts.map +1 -1
- package/dist/views/Dashboard.js +1 -1
- package/dist/views/Dashboard.js.map +1 -1
- package/dist/views/page-editor/PageSectionEditor.js +1 -1
- package/dist/views/page-editor/PageSectionEditor.js.map +1 -1
- package/dist/views/page-editor/SectionInspector.d.ts +4 -1
- package/dist/views/page-editor/SectionInspector.d.ts.map +1 -1
- package/dist/views/page-editor/SectionInspector.js +14 -3
- package/dist/views/page-editor/SectionInspector.js.map +1 -1
- package/dist/views/post-editor/PostFieldsModal.d.ts +1 -1
- package/dist/views/post-editor/PostFieldsModal.d.ts.map +1 -1
- package/dist/views/post-editor/PostFieldsModal.js +152 -13
- package/dist/views/post-editor/PostFieldsModal.js.map +1 -1
- package/dist/views/post-editor/PostHeaderPanel.d.ts.map +1 -1
- package/dist/views/post-editor/PostHeaderPanel.js +2 -1
- package/dist/views/post-editor/PostHeaderPanel.js.map +1 -1
- package/dist/views/post-editor/PostSectionEditor.d.ts.map +1 -1
- package/dist/views/post-editor/PostSectionEditor.js +6 -1
- package/dist/views/post-editor/PostSectionEditor.js.map +1 -1
- package/dist/views/post-editor/PostTemplateEditor.js +1 -1
- package/dist/views/post-editor/PostTemplateEditor.js.map +1 -1
- package/package.json +1 -1
- package/src/__tests__/components/resizable-aside.test.tsx +130 -0
- package/src/__tests__/components/toggle.test.tsx +17 -0
- package/src/__tests__/views/post-fields-modal.render.test.tsx +54 -0
- package/src/components/MediaPickerModal.tsx +6 -2
- package/src/components/TagInput.tsx +1 -1
- package/src/components/ui/Modal.tsx +46 -3
- package/src/components/ui/ResizableAside.tsx +317 -0
- package/src/components/ui/Toggle.tsx +4 -3
- package/src/fields/RichTextField.tsx +4 -1
- package/src/styles/actuate-tokens.css +7 -2
- package/src/views/Dashboard.tsx +2 -1
- package/src/views/page-editor/PageSectionEditor.tsx +1 -1
- package/src/views/page-editor/SectionInspector.tsx +54 -26
- package/src/views/post-editor/PostFieldsModal.tsx +412 -174
- package/src/views/post-editor/PostHeaderPanel.tsx +15 -11
- package/src/views/post-editor/PostSectionEditor.tsx +9 -1
- package/src/views/post-editor/PostTemplateEditor.tsx +1 -1
|
@@ -1,13 +1,17 @@
|
|
|
1
1
|
'use client'
|
|
2
2
|
|
|
3
|
-
import { useEffect, useRef, useState } from 'react'
|
|
3
|
+
import { useEffect, useMemo, useRef, useState } from 'react'
|
|
4
|
+
import { Loader2, Sparkles } from 'lucide-react'
|
|
5
|
+
import { toast } from 'sonner'
|
|
4
6
|
import { normalizeSlug, validateSlug } from '@actuate-media/cms-core/client'
|
|
5
7
|
import { Modal } from '../../components/ui/Modal.js'
|
|
6
8
|
import { Button } from '../../components/ui/Button.js'
|
|
9
|
+
import { ConfirmDialog } from '../../components/ui/ConfirmDialog.js'
|
|
7
10
|
import { MediaPickerModal } from '../../components/MediaPickerModal.js'
|
|
8
11
|
import { TagInput } from '../../components/TagInput.js'
|
|
9
|
-
import { RichTextField } from '../../fields/RichTextField.js'
|
|
10
12
|
import { SeoCharCount } from '../../components/seo/SeoCharCount.js'
|
|
13
|
+
import { coauthorText } from '../../lib/coauthor-client.js'
|
|
14
|
+
import { generateSeoField } from '../../lib/seo-service.js'
|
|
11
15
|
import type { EditorPost } from '../../lib/post-editor-service.js'
|
|
12
16
|
|
|
13
17
|
const SEO_TITLE_MIN = 30
|
|
@@ -37,12 +41,98 @@ interface PostFieldsModalProps {
|
|
|
37
41
|
}
|
|
38
42
|
|
|
39
43
|
const LABEL = 'text-foreground mb-1 block text-xs font-medium'
|
|
44
|
+
/** Match TipTap body editor borders — `border-input` is transparent in theme.css. */
|
|
40
45
|
const INPUT =
|
|
41
|
-
'border-
|
|
46
|
+
'border-border bg-input-background text-foreground focus-visible:ring-ring w-full rounded-md border px-2.5 py-1.5 text-sm focus-visible:ring-2 focus-visible:outline-none'
|
|
42
47
|
|
|
43
48
|
const slugify = normalizeSlug
|
|
44
49
|
|
|
45
|
-
|
|
50
|
+
type AiTarget = 'excerpt' | 'seoTitle' | 'seoDescription'
|
|
51
|
+
|
|
52
|
+
type FormSnapshot = {
|
|
53
|
+
title: string
|
|
54
|
+
slug: string
|
|
55
|
+
excerpt: string
|
|
56
|
+
featuredImage: string
|
|
57
|
+
category: string
|
|
58
|
+
tags: string
|
|
59
|
+
seoTitle: string
|
|
60
|
+
seoDescription: string
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function snapshotFromPost(post: EditorPost): FormSnapshot {
|
|
64
|
+
return {
|
|
65
|
+
title: post.title,
|
|
66
|
+
slug: post.slug,
|
|
67
|
+
excerpt: post.excerpt,
|
|
68
|
+
featuredImage: post.featuredImage,
|
|
69
|
+
category: post.category,
|
|
70
|
+
tags: [...post.tags].sort().join('\0'),
|
|
71
|
+
seoTitle: post.seoTitle,
|
|
72
|
+
seoDescription: post.seoDescription,
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function snapshotFromFields(input: {
|
|
77
|
+
title: string
|
|
78
|
+
slug: string
|
|
79
|
+
excerpt: string
|
|
80
|
+
featuredImage: string
|
|
81
|
+
category: string
|
|
82
|
+
tags: string[]
|
|
83
|
+
seoTitle: string
|
|
84
|
+
seoDescription: string
|
|
85
|
+
}): FormSnapshot {
|
|
86
|
+
return {
|
|
87
|
+
...input,
|
|
88
|
+
tags: [...input.tags].sort().join('\0'),
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function stripHtml(html: string): string {
|
|
93
|
+
return html
|
|
94
|
+
.replace(/<[^>]*>/g, ' ')
|
|
95
|
+
.replace(/\s+/g, ' ')
|
|
96
|
+
.trim()
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function buildContentContext(title: string, body: string, excerpt: string): string {
|
|
100
|
+
const plain = stripHtml(body)
|
|
101
|
+
const parts = [title.trim(), excerpt.trim(), plain].filter(Boolean)
|
|
102
|
+
return parts.join('\n\n')
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function AiFieldButton({
|
|
106
|
+
label,
|
|
107
|
+
busy,
|
|
108
|
+
disabled,
|
|
109
|
+
onClick,
|
|
110
|
+
}: {
|
|
111
|
+
label: string
|
|
112
|
+
busy: boolean
|
|
113
|
+
disabled?: boolean
|
|
114
|
+
onClick: () => void
|
|
115
|
+
}) {
|
|
116
|
+
return (
|
|
117
|
+
<button
|
|
118
|
+
type="button"
|
|
119
|
+
aria-label={label}
|
|
120
|
+
title={label}
|
|
121
|
+
disabled={disabled || busy}
|
|
122
|
+
onClick={onClick}
|
|
123
|
+
className="inline-flex items-center gap-1 rounded-md px-1.5 py-0.5 text-[11px] font-medium text-[var(--acc)] transition-colors duration-[var(--motion-fast)] hover:bg-[var(--acc-l)] focus-visible:ring-2 focus-visible:ring-[var(--ring)] focus-visible:outline-none disabled:opacity-60"
|
|
124
|
+
>
|
|
125
|
+
{busy ? (
|
|
126
|
+
<Loader2 className="h-3 w-3 animate-spin" aria-hidden />
|
|
127
|
+
) : (
|
|
128
|
+
<Sparkles className="h-3 w-3" aria-hidden />
|
|
129
|
+
)}
|
|
130
|
+
{busy ? 'Generating…' : 'AI'}
|
|
131
|
+
</button>
|
|
132
|
+
)
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/** Edits post metadata: title, slug, excerpt, featured image, category, tags, SEO. */
|
|
46
136
|
export function PostFieldsModal({
|
|
47
137
|
open,
|
|
48
138
|
post,
|
|
@@ -57,11 +147,13 @@ export function PostFieldsModal({
|
|
|
57
147
|
const [featuredImage, setFeaturedImage] = useState(post.featuredImage)
|
|
58
148
|
const [category, setCategory] = useState(post.category)
|
|
59
149
|
const [tags, setTags] = useState<string[]>(post.tags)
|
|
60
|
-
const [body, setBody] = useState(post.body)
|
|
61
150
|
const [seoTitle, setSeoTitle] = useState(post.seoTitle)
|
|
62
151
|
const [seoDescription, setSeoDescription] = useState(post.seoDescription)
|
|
63
152
|
const [slugTouched, setSlugTouched] = useState(Boolean(post.slug))
|
|
64
153
|
const [mediaOpen, setMediaOpen] = useState(false)
|
|
154
|
+
const [aiBusy, setAiBusy] = useState<AiTarget | null>(null)
|
|
155
|
+
const [confirmDiscard, setConfirmDiscard] = useState(false)
|
|
156
|
+
const baselineRef = useRef<FormSnapshot | null>(null)
|
|
65
157
|
// Land the user in the Title field (name-it-first flow for new posts).
|
|
66
158
|
const titleRef = useRef<HTMLInputElement | null>(null)
|
|
67
159
|
|
|
@@ -74,19 +166,47 @@ export function PostFieldsModal({
|
|
|
74
166
|
const hasTags = tagsField?.type === 'array'
|
|
75
167
|
|
|
76
168
|
useEffect(() => {
|
|
77
|
-
if (!open)
|
|
169
|
+
if (!open) {
|
|
170
|
+
baselineRef.current = null
|
|
171
|
+
setConfirmDiscard(false)
|
|
172
|
+
return
|
|
173
|
+
}
|
|
78
174
|
setTitle(post.title)
|
|
79
175
|
setSlug(post.slug)
|
|
80
176
|
setExcerpt(post.excerpt)
|
|
81
177
|
setFeaturedImage(post.featuredImage)
|
|
82
178
|
setCategory(post.category)
|
|
83
179
|
setTags(post.tags)
|
|
84
|
-
setBody(post.body)
|
|
85
180
|
setSeoTitle(post.seoTitle)
|
|
86
181
|
setSeoDescription(post.seoDescription)
|
|
87
182
|
setSlugTouched(Boolean(post.slug))
|
|
183
|
+
setAiBusy(null)
|
|
184
|
+
baselineRef.current = snapshotFromPost(post)
|
|
88
185
|
}, [open, post])
|
|
89
186
|
|
|
187
|
+
const isDirty = useMemo(() => {
|
|
188
|
+
if (!open || !baselineRef.current) return false
|
|
189
|
+
const current = snapshotFromFields({
|
|
190
|
+
title,
|
|
191
|
+
slug,
|
|
192
|
+
excerpt,
|
|
193
|
+
featuredImage,
|
|
194
|
+
category,
|
|
195
|
+
tags,
|
|
196
|
+
seoTitle,
|
|
197
|
+
seoDescription,
|
|
198
|
+
})
|
|
199
|
+
return JSON.stringify(current) !== JSON.stringify(baselineRef.current)
|
|
200
|
+
}, [open, title, slug, excerpt, featuredImage, category, tags, seoTitle, seoDescription])
|
|
201
|
+
|
|
202
|
+
function requestDismiss() {
|
|
203
|
+
if (isDirty) {
|
|
204
|
+
setConfirmDiscard(true)
|
|
205
|
+
return
|
|
206
|
+
}
|
|
207
|
+
onClose()
|
|
208
|
+
}
|
|
209
|
+
|
|
90
210
|
function handleTitle(next: string) {
|
|
91
211
|
setTitle(next)
|
|
92
212
|
if (!slugTouched) setSlug(slugify(next))
|
|
@@ -94,201 +214,308 @@ export function PostFieldsModal({
|
|
|
94
214
|
|
|
95
215
|
const slugError = validateSlug(slug)
|
|
96
216
|
|
|
217
|
+
async function handleAiGenerate(target: AiTarget) {
|
|
218
|
+
const context = buildContentContext(title, post.body, excerpt)
|
|
219
|
+
if (!context.trim()) {
|
|
220
|
+
toast.error(
|
|
221
|
+
'Add a title or write body content in the Article Body section before generating with AI.',
|
|
222
|
+
)
|
|
223
|
+
return
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
setAiBusy(target)
|
|
227
|
+
try {
|
|
228
|
+
if (target === 'excerpt') {
|
|
229
|
+
const outcome = await coauthorText('compress', context, {
|
|
230
|
+
instructions:
|
|
231
|
+
'Write a 1–2 sentence excerpt for blog listings and cards. Return only the excerpt text.',
|
|
232
|
+
targetLength: 45,
|
|
233
|
+
})
|
|
234
|
+
if (!outcome.ok) {
|
|
235
|
+
toast.error(outcome.error)
|
|
236
|
+
return
|
|
237
|
+
}
|
|
238
|
+
setExcerpt(outcome.result.text.trim())
|
|
239
|
+
toast.success('Excerpt generated — review before saving.')
|
|
240
|
+
return
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
if (post.id) {
|
|
244
|
+
const field = target === 'seoTitle' ? 'metaTitle' : 'metaDescription'
|
|
245
|
+
const result = await generateSeoField(field, {
|
|
246
|
+
entityType: post.postType,
|
|
247
|
+
entityId: post.id,
|
|
248
|
+
})
|
|
249
|
+
if (result.unavailable) {
|
|
250
|
+
toast.error('AI is not configured. Add an AI provider to enable suggestions.')
|
|
251
|
+
return
|
|
252
|
+
}
|
|
253
|
+
if (result.text) {
|
|
254
|
+
if (target === 'seoTitle') setSeoTitle(result.text)
|
|
255
|
+
else setSeoDescription(result.text)
|
|
256
|
+
toast.success('AI suggestion applied — review before saving.')
|
|
257
|
+
} else {
|
|
258
|
+
toast.error('No suggestion was returned.')
|
|
259
|
+
}
|
|
260
|
+
return
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
if (target === 'seoTitle') {
|
|
264
|
+
const outcome = await coauthorText('rewrite', context, {
|
|
265
|
+
style: 'seo-optimized',
|
|
266
|
+
instructions:
|
|
267
|
+
'Write a single SEO page title under 60 characters. Return only the title — no quotes.',
|
|
268
|
+
})
|
|
269
|
+
if (!outcome.ok) {
|
|
270
|
+
toast.error(outcome.error)
|
|
271
|
+
return
|
|
272
|
+
}
|
|
273
|
+
setSeoTitle(outcome.result.text.trim().slice(0, SEO_TITLE_MAX))
|
|
274
|
+
} else {
|
|
275
|
+
const outcome = await coauthorText('compress', context, {
|
|
276
|
+
instructions:
|
|
277
|
+
'Write a compelling SEO meta description of 120–160 characters for search results. Return only the description.',
|
|
278
|
+
targetLength: 155,
|
|
279
|
+
})
|
|
280
|
+
if (!outcome.ok) {
|
|
281
|
+
toast.error(outcome.error)
|
|
282
|
+
return
|
|
283
|
+
}
|
|
284
|
+
setSeoDescription(outcome.result.text.trim().slice(0, SEO_DESC_MAX))
|
|
285
|
+
}
|
|
286
|
+
toast.success('AI suggestion applied — review before saving.')
|
|
287
|
+
} catch {
|
|
288
|
+
toast.error('AI request failed.')
|
|
289
|
+
} finally {
|
|
290
|
+
setAiBusy(null)
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
|
|
97
294
|
function handleSave() {
|
|
98
295
|
if (slugError) return
|
|
99
|
-
onSave({ title, slug, excerpt, featuredImage, category, tags,
|
|
296
|
+
onSave({ title, slug, excerpt, featuredImage, category, tags, seoTitle, seoDescription })
|
|
100
297
|
onClose()
|
|
101
298
|
}
|
|
102
299
|
|
|
103
300
|
return (
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
className={INPUT}
|
|
129
|
-
value={title}
|
|
130
|
-
disabled={!canEdit}
|
|
131
|
-
placeholder="e.g. Announcing our new product"
|
|
132
|
-
onChange={(e) => handleTitle(e.target.value)}
|
|
133
|
-
/>
|
|
134
|
-
</div>
|
|
135
|
-
<div className="grid grid-cols-2 gap-3">
|
|
301
|
+
<>
|
|
302
|
+
<Modal
|
|
303
|
+
open={open}
|
|
304
|
+
onClose={onClose}
|
|
305
|
+
onDismiss={requestDismiss}
|
|
306
|
+
title="Post details"
|
|
307
|
+
size="lg"
|
|
308
|
+
initialFocusRef={titleRef}
|
|
309
|
+
actions={
|
|
310
|
+
<>
|
|
311
|
+
<Button variant="ghost" onClick={requestDismiss}>
|
|
312
|
+
Cancel
|
|
313
|
+
</Button>
|
|
314
|
+
<Button
|
|
315
|
+
variant="primary"
|
|
316
|
+
onClick={handleSave}
|
|
317
|
+
disabled={!canEdit || Boolean(slugError)}
|
|
318
|
+
>
|
|
319
|
+
Done
|
|
320
|
+
</Button>
|
|
321
|
+
</>
|
|
322
|
+
}
|
|
323
|
+
>
|
|
324
|
+
<div className="space-y-3">
|
|
136
325
|
<div>
|
|
137
|
-
<label className={LABEL} htmlFor="pf-
|
|
138
|
-
|
|
326
|
+
<label className={LABEL} htmlFor="pf-title">
|
|
327
|
+
Title <span className="text-destructive">*</span>
|
|
139
328
|
</label>
|
|
140
329
|
<input
|
|
141
|
-
id="pf-
|
|
330
|
+
id="pf-title"
|
|
331
|
+
ref={titleRef}
|
|
142
332
|
className={INPUT}
|
|
143
|
-
value={
|
|
333
|
+
value={title}
|
|
144
334
|
disabled={!canEdit}
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
onChange={(e) => {
|
|
148
|
-
setSlugTouched(true)
|
|
149
|
-
setSlug(slugify(e.target.value))
|
|
150
|
-
}}
|
|
335
|
+
placeholder="e.g. Announcing our new product"
|
|
336
|
+
onChange={(e) => handleTitle(e.target.value)}
|
|
151
337
|
/>
|
|
152
|
-
{slugError && (
|
|
153
|
-
<p id="pf-slug-error" className="text-destructive mt-1 text-xs">
|
|
154
|
-
{slugError}
|
|
155
|
-
</p>
|
|
156
|
-
)}
|
|
157
338
|
</div>
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
id="pf-category"
|
|
165
|
-
className={INPUT}
|
|
166
|
-
value={category}
|
|
167
|
-
disabled={!canEdit}
|
|
168
|
-
onChange={(e) => setCategory(e.target.value)}
|
|
169
|
-
>
|
|
170
|
-
<option value="">No category</option>
|
|
171
|
-
{categoryOptions.map((o) => (
|
|
172
|
-
<option key={o.value} value={o.value}>
|
|
173
|
-
{o.label}
|
|
174
|
-
</option>
|
|
175
|
-
))}
|
|
176
|
-
{/* Keep a legacy free-text value selectable so opening the
|
|
177
|
-
modal never silently discards it. */}
|
|
178
|
-
{category && !categoryOptions.some((o) => o.value === category) && (
|
|
179
|
-
<option value={category}>{category}</option>
|
|
180
|
-
)}
|
|
181
|
-
</select>
|
|
182
|
-
) : (
|
|
339
|
+
|
|
340
|
+
<div className="grid grid-cols-2 gap-3">
|
|
341
|
+
<div>
|
|
342
|
+
<label className={LABEL} htmlFor="pf-slug">
|
|
343
|
+
Slug <span className="text-destructive">*</span>
|
|
344
|
+
</label>
|
|
183
345
|
<input
|
|
184
|
-
id="pf-
|
|
346
|
+
id="pf-slug"
|
|
185
347
|
className={INPUT}
|
|
186
|
-
value={
|
|
348
|
+
value={slug}
|
|
187
349
|
disabled={!canEdit}
|
|
188
|
-
placeholder=
|
|
189
|
-
|
|
350
|
+
placeholder={slugTouched ? undefined : 'Generated from title…'}
|
|
351
|
+
aria-invalid={Boolean(slugError)}
|
|
352
|
+
aria-describedby={slugError ? 'pf-slug-error' : undefined}
|
|
353
|
+
onChange={(e) => {
|
|
354
|
+
setSlugTouched(true)
|
|
355
|
+
setSlug(slugify(e.target.value))
|
|
356
|
+
}}
|
|
190
357
|
/>
|
|
191
|
-
|
|
358
|
+
{slugError && (
|
|
359
|
+
<p id="pf-slug-error" className="text-destructive mt-1 text-xs">
|
|
360
|
+
{slugError}
|
|
361
|
+
</p>
|
|
362
|
+
)}
|
|
363
|
+
</div>
|
|
364
|
+
|
|
365
|
+
<div>
|
|
366
|
+
<label className={LABEL} htmlFor="pf-category">
|
|
367
|
+
Category
|
|
368
|
+
</label>
|
|
369
|
+
{categoryOptions ? (
|
|
370
|
+
<select
|
|
371
|
+
id="pf-category"
|
|
372
|
+
className={INPUT}
|
|
373
|
+
value={category}
|
|
374
|
+
disabled={!canEdit}
|
|
375
|
+
onChange={(e) => setCategory(e.target.value)}
|
|
376
|
+
>
|
|
377
|
+
<option value="">No category</option>
|
|
378
|
+
{categoryOptions.map((o) => (
|
|
379
|
+
<option key={o.value} value={o.value}>
|
|
380
|
+
{o.label}
|
|
381
|
+
</option>
|
|
382
|
+
))}
|
|
383
|
+
{category && !categoryOptions.some((o) => o.value === category) && (
|
|
384
|
+
<option value={category}>{category}</option>
|
|
385
|
+
)}
|
|
386
|
+
</select>
|
|
387
|
+
) : (
|
|
388
|
+
<input
|
|
389
|
+
id="pf-category"
|
|
390
|
+
className={INPUT}
|
|
391
|
+
value={category}
|
|
392
|
+
disabled={!canEdit}
|
|
393
|
+
placeholder="e.g. Product"
|
|
394
|
+
onChange={(e) => setCategory(e.target.value)}
|
|
395
|
+
/>
|
|
396
|
+
)}
|
|
397
|
+
</div>
|
|
192
398
|
</div>
|
|
193
|
-
|
|
194
|
-
|
|
399
|
+
|
|
400
|
+
{hasTags && (
|
|
401
|
+
<div>
|
|
402
|
+
<label className={LABEL} htmlFor="pf-tags">
|
|
403
|
+
Tags
|
|
404
|
+
</label>
|
|
405
|
+
<TagInput
|
|
406
|
+
id="pf-tags"
|
|
407
|
+
value={tags}
|
|
408
|
+
onChange={setTags}
|
|
409
|
+
collection={post.postType}
|
|
410
|
+
field="tags"
|
|
411
|
+
disabled={!canEdit}
|
|
412
|
+
maxTags={tagsField?.maxRows}
|
|
413
|
+
/>
|
|
414
|
+
</div>
|
|
415
|
+
)}
|
|
416
|
+
|
|
195
417
|
<div>
|
|
196
|
-
<
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
418
|
+
<div className="mb-1 flex items-center justify-between gap-2">
|
|
419
|
+
<label className={`${LABEL} mb-0`} htmlFor="pf-excerpt">
|
|
420
|
+
Excerpt
|
|
421
|
+
</label>
|
|
422
|
+
<AiFieldButton
|
|
423
|
+
label="Generate excerpt from content"
|
|
424
|
+
busy={aiBusy === 'excerpt'}
|
|
425
|
+
disabled={!canEdit}
|
|
426
|
+
onClick={() => void handleAiGenerate('excerpt')}
|
|
427
|
+
/>
|
|
428
|
+
</div>
|
|
429
|
+
<textarea
|
|
430
|
+
id="pf-excerpt"
|
|
431
|
+
className={`${INPUT} min-h-[72px] resize-y`}
|
|
432
|
+
value={excerpt}
|
|
205
433
|
disabled={!canEdit}
|
|
206
|
-
|
|
434
|
+
placeholder="A short summary shown in listings and the header."
|
|
435
|
+
onChange={(e) => setExcerpt(e.target.value)}
|
|
207
436
|
/>
|
|
208
437
|
</div>
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
438
|
+
|
|
439
|
+
<div>
|
|
440
|
+
<label className={LABEL} htmlFor="pf-image">
|
|
441
|
+
Featured image
|
|
442
|
+
</label>
|
|
443
|
+
<div className="flex gap-2">
|
|
444
|
+
<input
|
|
445
|
+
id="pf-image"
|
|
446
|
+
className={`${INPUT} min-w-0 flex-1`}
|
|
447
|
+
value={featuredImage}
|
|
448
|
+
disabled={!canEdit}
|
|
449
|
+
placeholder="https://… or pick from the media library"
|
|
450
|
+
onChange={(e) => setFeaturedImage(e.target.value)}
|
|
451
|
+
/>
|
|
452
|
+
<button
|
|
453
|
+
type="button"
|
|
454
|
+
disabled={!canEdit}
|
|
455
|
+
onClick={() => setMediaOpen(true)}
|
|
456
|
+
className="bg-accent text-foreground border-border hover:bg-accent/80 shrink-0 rounded-md border px-3 py-1.5 text-xs font-medium disabled:opacity-50"
|
|
457
|
+
>
|
|
458
|
+
Browse
|
|
459
|
+
</button>
|
|
460
|
+
</div>
|
|
461
|
+
{featuredImage && (
|
|
462
|
+
<div className="border-border bg-muted mt-2 overflow-hidden rounded-md border">
|
|
463
|
+
<img src={featuredImage} alt="" className="h-28 w-full object-cover" />
|
|
464
|
+
</div>
|
|
465
|
+
)}
|
|
466
|
+
</div>
|
|
467
|
+
|
|
468
|
+
<div>
|
|
469
|
+
<div className="mb-1 flex items-center justify-between gap-2">
|
|
470
|
+
<div className="flex min-w-0 items-center gap-2">
|
|
471
|
+
<label className={`${LABEL} mb-0`} htmlFor="pf-seo-title">
|
|
472
|
+
SEO title
|
|
473
|
+
</label>
|
|
474
|
+
<AiFieldButton
|
|
475
|
+
label="Generate SEO title from content"
|
|
476
|
+
busy={aiBusy === 'seoTitle'}
|
|
477
|
+
disabled={!canEdit}
|
|
478
|
+
onClick={() => void handleAiGenerate('seoTitle')}
|
|
479
|
+
/>
|
|
480
|
+
</div>
|
|
481
|
+
<SeoCharCount value={seoTitle} min={SEO_TITLE_MIN} max={SEO_TITLE_MAX} />
|
|
482
|
+
</div>
|
|
228
483
|
<input
|
|
229
|
-
id="pf-
|
|
230
|
-
className={
|
|
231
|
-
value={
|
|
484
|
+
id="pf-seo-title"
|
|
485
|
+
className={INPUT}
|
|
486
|
+
value={seoTitle}
|
|
232
487
|
disabled={!canEdit}
|
|
233
|
-
placeholder=
|
|
234
|
-
onChange={(e) =>
|
|
488
|
+
placeholder={title || 'Defaults to the post title'}
|
|
489
|
+
onChange={(e) => setSeoTitle(e.target.value)}
|
|
235
490
|
/>
|
|
236
|
-
<button
|
|
237
|
-
type="button"
|
|
238
|
-
disabled={!canEdit}
|
|
239
|
-
onClick={() => setMediaOpen(true)}
|
|
240
|
-
className="bg-accent text-foreground border-border hover:bg-accent/80 shrink-0 rounded-md border px-3 py-1.5 text-xs font-medium disabled:opacity-50"
|
|
241
|
-
>
|
|
242
|
-
Browse
|
|
243
|
-
</button>
|
|
244
491
|
</div>
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
492
|
+
|
|
493
|
+
<div>
|
|
494
|
+
<div className="mb-1 flex items-center justify-between gap-2">
|
|
495
|
+
<div className="flex min-w-0 items-center gap-2">
|
|
496
|
+
<label className={`${LABEL} mb-0`} htmlFor="pf-seo-desc">
|
|
497
|
+
SEO description
|
|
498
|
+
</label>
|
|
499
|
+
<AiFieldButton
|
|
500
|
+
label="Generate SEO description from content"
|
|
501
|
+
busy={aiBusy === 'seoDescription'}
|
|
502
|
+
disabled={!canEdit}
|
|
503
|
+
onClick={() => void handleAiGenerate('seoDescription')}
|
|
504
|
+
/>
|
|
505
|
+
</div>
|
|
506
|
+
<SeoCharCount value={seoDescription} min={SEO_DESC_MIN} max={SEO_DESC_MAX} />
|
|
248
507
|
</div>
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
/>
|
|
258
|
-
</div>
|
|
259
|
-
<div>
|
|
260
|
-
<div className="mb-1 flex items-center justify-between">
|
|
261
|
-
<label className={`${LABEL} mb-0`} htmlFor="pf-seo-title">
|
|
262
|
-
SEO title
|
|
263
|
-
</label>
|
|
264
|
-
<SeoCharCount value={seoTitle} min={SEO_TITLE_MIN} max={SEO_TITLE_MAX} />
|
|
265
|
-
</div>
|
|
266
|
-
<input
|
|
267
|
-
id="pf-seo-title"
|
|
268
|
-
className={INPUT}
|
|
269
|
-
value={seoTitle}
|
|
270
|
-
disabled={!canEdit}
|
|
271
|
-
placeholder={title || 'Defaults to the post title'}
|
|
272
|
-
onChange={(e) => setSeoTitle(e.target.value)}
|
|
273
|
-
/>
|
|
274
|
-
</div>
|
|
275
|
-
<div>
|
|
276
|
-
<div className="mb-1 flex items-center justify-between">
|
|
277
|
-
<label className={`${LABEL} mb-0`} htmlFor="pf-seo-desc">
|
|
278
|
-
SEO description
|
|
279
|
-
</label>
|
|
280
|
-
<SeoCharCount value={seoDescription} min={SEO_DESC_MIN} max={SEO_DESC_MAX} />
|
|
508
|
+
<textarea
|
|
509
|
+
id="pf-seo-desc"
|
|
510
|
+
className={`${INPUT} min-h-[72px] resize-y`}
|
|
511
|
+
value={seoDescription}
|
|
512
|
+
disabled={!canEdit}
|
|
513
|
+
placeholder={excerpt || 'Summarize the post for search results (120–160 characters)'}
|
|
514
|
+
onChange={(e) => setSeoDescription(e.target.value)}
|
|
515
|
+
/>
|
|
281
516
|
</div>
|
|
282
|
-
<textarea
|
|
283
|
-
id="pf-seo-desc"
|
|
284
|
-
className={`${INPUT} min-h-[60px] resize-y`}
|
|
285
|
-
value={seoDescription}
|
|
286
|
-
disabled={!canEdit}
|
|
287
|
-
placeholder={excerpt || 'Summarize the post for search results (120–160 characters)'}
|
|
288
|
-
onChange={(e) => setSeoDescription(e.target.value)}
|
|
289
|
-
/>
|
|
290
517
|
</div>
|
|
291
|
-
</
|
|
518
|
+
</Modal>
|
|
292
519
|
|
|
293
520
|
<MediaPickerModal
|
|
294
521
|
open={mediaOpen}
|
|
@@ -299,6 +526,17 @@ export function PostFieldsModal({
|
|
|
299
526
|
}}
|
|
300
527
|
accept="image/*"
|
|
301
528
|
/>
|
|
302
|
-
|
|
529
|
+
|
|
530
|
+
<ConfirmDialog
|
|
531
|
+
open={confirmDiscard}
|
|
532
|
+
onClose={() => setConfirmDiscard(false)}
|
|
533
|
+
onConfirm={onClose}
|
|
534
|
+
title="Discard unsaved changes?"
|
|
535
|
+
description="You have unsaved post details. Closing now will discard them."
|
|
536
|
+
confirmLabel="Discard"
|
|
537
|
+
cancelLabel="Keep editing"
|
|
538
|
+
destructive
|
|
539
|
+
/>
|
|
540
|
+
</>
|
|
303
541
|
)
|
|
304
542
|
}
|