@actuate-media/cms-admin 0.49.4 → 0.50.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 +50 -0
- package/dist/__tests__/components/seo-char-count.test.d.ts +2 -0
- package/dist/__tests__/components/seo-char-count.test.d.ts.map +1 -0
- package/dist/__tests__/components/seo-char-count.test.js +25 -0
- package/dist/__tests__/components/seo-char-count.test.js.map +1 -0
- package/dist/__tests__/lib/page-editor-service.test.js +12 -0
- package/dist/__tests__/lib/page-editor-service.test.js.map +1 -1
- package/dist/__tests__/lib/post-editor-service.test.js +4 -0
- package/dist/__tests__/lib/post-editor-service.test.js.map +1 -1
- package/dist/__tests__/views/seo-settings.render.test.js +1 -1
- package/dist/__tests__/views/seo-settings.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 +3 -1
- package/dist/components/MediaPickerModal.js.map +1 -1
- package/dist/components/SEOPanel.d.ts +1 -0
- package/dist/components/SEOPanel.d.ts.map +1 -1
- package/dist/components/SEOPanel.js +1 -1
- package/dist/components/SEOPanel.js.map +1 -1
- package/dist/components/seo/SeoCharCount.d.ts +15 -0
- package/dist/components/seo/SeoCharCount.d.ts.map +1 -0
- package/dist/components/seo/SeoCharCount.js +25 -0
- package/dist/components/seo/SeoCharCount.js.map +1 -0
- package/dist/components/seo/SeoEditorDrawer.d.ts.map +1 -1
- package/dist/components/seo/SeoEditorDrawer.js +3 -1
- package/dist/components/seo/SeoEditorDrawer.js.map +1 -1
- package/dist/fields/SlugField.d.ts.map +1 -1
- package/dist/fields/SlugField.js +6 -2
- package/dist/fields/SlugField.js.map +1 -1
- package/dist/lib/page-editor-service.d.ts.map +1 -1
- package/dist/lib/page-editor-service.js +6 -0
- package/dist/lib/page-editor-service.js.map +1 -1
- package/dist/lib/post-editor-service.d.ts.map +1 -1
- package/dist/lib/post-editor-service.js +6 -0
- package/dist/lib/post-editor-service.js.map +1 -1
- package/dist/lib/seo-service.d.ts +3 -1
- package/dist/lib/seo-service.d.ts.map +1 -1
- package/dist/lib/seo-service.js.map +1 -1
- package/dist/views/DocumentEdit.d.ts.map +1 -1
- package/dist/views/DocumentEdit.js +1 -0
- package/dist/views/DocumentEdit.js.map +1 -1
- package/dist/views/page-editor/PageSettingsModal.d.ts.map +1 -1
- package/dist/views/page-editor/PageSettingsModal.js +12 -9
- package/dist/views/page-editor/PageSettingsModal.js.map +1 -1
- package/dist/views/post-editor/PostFieldsModal.d.ts.map +1 -1
- package/dist/views/post-editor/PostFieldsModal.js +12 -9
- package/dist/views/post-editor/PostFieldsModal.js.map +1 -1
- package/package.json +2 -2
- package/src/__tests__/components/seo-char-count.test.tsx +27 -0
- package/src/__tests__/lib/page-editor-service.test.ts +14 -0
- package/src/__tests__/lib/post-editor-service.test.ts +9 -0
- package/src/__tests__/views/seo-settings.render.test.tsx +1 -1
- package/src/components/MediaPickerModal.tsx +5 -1
- package/src/components/SEOPanel.tsx +9 -0
- package/src/components/seo/SeoCharCount.tsx +44 -0
- package/src/components/seo/SeoEditorDrawer.tsx +27 -1
- package/src/fields/SlugField.tsx +6 -2
- package/src/lib/page-editor-service.ts +6 -0
- package/src/lib/post-editor-service.ts +6 -0
- package/src/lib/seo-service.ts +9 -1
- package/src/views/DocumentEdit.tsx +1 -0
- package/src/views/page-editor/PageSettingsModal.tsx +33 -14
- package/src/views/post-editor/PostFieldsModal.tsx +33 -14
|
@@ -1,10 +1,17 @@
|
|
|
1
1
|
'use client'
|
|
2
2
|
|
|
3
3
|
import { useEffect, useState } from 'react'
|
|
4
|
+
import { normalizeSlug, validateSlug } from '@actuate-media/cms-core/client'
|
|
4
5
|
import { Modal } from '../../components/ui/Modal.js'
|
|
5
6
|
import { Button } from '../../components/ui/Button.js'
|
|
7
|
+
import { SeoCharCount } from '../../components/seo/SeoCharCount.js'
|
|
6
8
|
import type { EditorPage } from '../../lib/page-editor-service.js'
|
|
7
9
|
|
|
10
|
+
const SEO_TITLE_MIN = 30
|
|
11
|
+
const SEO_TITLE_MAX = 60
|
|
12
|
+
const SEO_DESC_MIN = 120
|
|
13
|
+
const SEO_DESC_MAX = 160
|
|
14
|
+
|
|
8
15
|
interface PageSettingsModalProps {
|
|
9
16
|
open: boolean
|
|
10
17
|
page: EditorPage
|
|
@@ -17,13 +24,7 @@ const LABEL = 'text-foreground mb-1 block text-xs font-medium'
|
|
|
17
24
|
const INPUT =
|
|
18
25
|
'border-input 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'
|
|
19
26
|
|
|
20
|
-
|
|
21
|
-
return value
|
|
22
|
-
.toLowerCase()
|
|
23
|
-
.trim()
|
|
24
|
-
.replace(/[^a-z0-9]+/g, '-')
|
|
25
|
-
.replace(/^-+|-+$/g, '')
|
|
26
|
-
}
|
|
27
|
+
const slugify = normalizeSlug
|
|
27
28
|
|
|
28
29
|
/** Edits page-level fields: title, slug, path, and SEO. */
|
|
29
30
|
export function PageSettingsModal({
|
|
@@ -60,7 +61,10 @@ export function PageSettingsModal({
|
|
|
60
61
|
}
|
|
61
62
|
}
|
|
62
63
|
|
|
64
|
+
const slugError = validateSlug(slug)
|
|
65
|
+
|
|
63
66
|
function handleSave() {
|
|
67
|
+
if (slugError) return
|
|
64
68
|
onSave({ title, slug, path: path || (slug ? `/${slug}` : '/'), seoTitle, seoDescription })
|
|
65
69
|
onClose()
|
|
66
70
|
}
|
|
@@ -75,7 +79,7 @@ export function PageSettingsModal({
|
|
|
75
79
|
<Button variant="ghost" onClick={onClose}>
|
|
76
80
|
Cancel
|
|
77
81
|
</Button>
|
|
78
|
-
<Button variant="primary" onClick={handleSave} disabled={!canEdit}>
|
|
82
|
+
<Button variant="primary" onClick={handleSave} disabled={!canEdit || Boolean(slugError)}>
|
|
79
83
|
Done
|
|
80
84
|
</Button>
|
|
81
85
|
</>
|
|
@@ -104,11 +108,18 @@ export function PageSettingsModal({
|
|
|
104
108
|
className={INPUT}
|
|
105
109
|
value={slug}
|
|
106
110
|
disabled={!canEdit}
|
|
111
|
+
aria-invalid={Boolean(slugError)}
|
|
112
|
+
aria-describedby={slugError ? 'ps-slug-error' : undefined}
|
|
107
113
|
onChange={(e) => {
|
|
108
114
|
setSlugTouched(true)
|
|
109
115
|
setSlug(slugify(e.target.value))
|
|
110
116
|
}}
|
|
111
117
|
/>
|
|
118
|
+
{slugError && (
|
|
119
|
+
<p id="ps-slug-error" className="text-destructive mt-1 text-xs">
|
|
120
|
+
{slugError}
|
|
121
|
+
</p>
|
|
122
|
+
)}
|
|
112
123
|
</div>
|
|
113
124
|
<div>
|
|
114
125
|
<label className={LABEL} htmlFor="ps-path">
|
|
@@ -125,26 +136,34 @@ export function PageSettingsModal({
|
|
|
125
136
|
</div>
|
|
126
137
|
</div>
|
|
127
138
|
<div>
|
|
128
|
-
<
|
|
129
|
-
|
|
130
|
-
|
|
139
|
+
<div className="mb-1 flex items-center justify-between">
|
|
140
|
+
<label className={`${LABEL} mb-0`} htmlFor="ps-seo-title">
|
|
141
|
+
SEO title
|
|
142
|
+
</label>
|
|
143
|
+
<SeoCharCount value={seoTitle} min={SEO_TITLE_MIN} max={SEO_TITLE_MAX} />
|
|
144
|
+
</div>
|
|
131
145
|
<input
|
|
132
146
|
id="ps-seo-title"
|
|
133
147
|
className={INPUT}
|
|
134
148
|
value={seoTitle}
|
|
135
149
|
disabled={!canEdit}
|
|
150
|
+
placeholder={title || 'Defaults to the page title'}
|
|
136
151
|
onChange={(e) => setSeoTitle(e.target.value)}
|
|
137
152
|
/>
|
|
138
153
|
</div>
|
|
139
154
|
<div>
|
|
140
|
-
<
|
|
141
|
-
|
|
142
|
-
|
|
155
|
+
<div className="mb-1 flex items-center justify-between">
|
|
156
|
+
<label className={`${LABEL} mb-0`} htmlFor="ps-seo-desc">
|
|
157
|
+
SEO description
|
|
158
|
+
</label>
|
|
159
|
+
<SeoCharCount value={seoDescription} min={SEO_DESC_MIN} max={SEO_DESC_MAX} />
|
|
160
|
+
</div>
|
|
143
161
|
<textarea
|
|
144
162
|
id="ps-seo-desc"
|
|
145
163
|
className={`${INPUT} min-h-[64px] resize-y`}
|
|
146
164
|
value={seoDescription}
|
|
147
165
|
disabled={!canEdit}
|
|
166
|
+
placeholder="Summarize the page for search results (120–160 characters)"
|
|
148
167
|
onChange={(e) => setSeoDescription(e.target.value)}
|
|
149
168
|
/>
|
|
150
169
|
</div>
|
|
@@ -1,13 +1,20 @@
|
|
|
1
1
|
'use client'
|
|
2
2
|
|
|
3
3
|
import { useEffect, useState } from 'react'
|
|
4
|
+
import { normalizeSlug, validateSlug } from '@actuate-media/cms-core/client'
|
|
4
5
|
import { Modal } from '../../components/ui/Modal.js'
|
|
5
6
|
import { Button } from '../../components/ui/Button.js'
|
|
6
7
|
import { MediaPickerModal } from '../../components/MediaPickerModal.js'
|
|
7
8
|
import { TagInput } from '../../components/TagInput.js'
|
|
8
9
|
import { RichTextField } from '../../fields/RichTextField.js'
|
|
10
|
+
import { SeoCharCount } from '../../components/seo/SeoCharCount.js'
|
|
9
11
|
import type { EditorPost } from '../../lib/post-editor-service.js'
|
|
10
12
|
|
|
13
|
+
const SEO_TITLE_MIN = 30
|
|
14
|
+
const SEO_TITLE_MAX = 60
|
|
15
|
+
const SEO_DESC_MIN = 120
|
|
16
|
+
const SEO_DESC_MAX = 160
|
|
17
|
+
|
|
11
18
|
/**
|
|
12
19
|
* The slice of a collection's field config the modal reads to pick controls:
|
|
13
20
|
* a `select` category renders a dropdown (structured vocabulary), an `array`
|
|
@@ -33,13 +40,7 @@ const LABEL = 'text-foreground mb-1 block text-xs font-medium'
|
|
|
33
40
|
const INPUT =
|
|
34
41
|
'border-input 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'
|
|
35
42
|
|
|
36
|
-
|
|
37
|
-
return value
|
|
38
|
-
.toLowerCase()
|
|
39
|
-
.trim()
|
|
40
|
-
.replace(/[^a-z0-9]+/g, '-')
|
|
41
|
-
.replace(/^-+|-+$/g, '')
|
|
42
|
-
}
|
|
43
|
+
const slugify = normalizeSlug
|
|
43
44
|
|
|
44
45
|
/** Edits post-level fields: title, slug, excerpt, featured image, category, tags, body, SEO. */
|
|
45
46
|
export function PostFieldsModal({
|
|
@@ -89,7 +90,10 @@ export function PostFieldsModal({
|
|
|
89
90
|
if (!slugTouched) setSlug(slugify(next))
|
|
90
91
|
}
|
|
91
92
|
|
|
93
|
+
const slugError = validateSlug(slug)
|
|
94
|
+
|
|
92
95
|
function handleSave() {
|
|
96
|
+
if (slugError) return
|
|
93
97
|
onSave({ title, slug, excerpt, featuredImage, category, tags, body, seoTitle, seoDescription })
|
|
94
98
|
onClose()
|
|
95
99
|
}
|
|
@@ -104,7 +108,7 @@ export function PostFieldsModal({
|
|
|
104
108
|
<Button variant="ghost" onClick={onClose}>
|
|
105
109
|
Cancel
|
|
106
110
|
</Button>
|
|
107
|
-
<Button variant="primary" onClick={handleSave} disabled={!canEdit}>
|
|
111
|
+
<Button variant="primary" onClick={handleSave} disabled={!canEdit || Boolean(slugError)}>
|
|
108
112
|
Done
|
|
109
113
|
</Button>
|
|
110
114
|
</>
|
|
@@ -133,11 +137,18 @@ export function PostFieldsModal({
|
|
|
133
137
|
className={INPUT}
|
|
134
138
|
value={slug}
|
|
135
139
|
disabled={!canEdit}
|
|
140
|
+
aria-invalid={Boolean(slugError)}
|
|
141
|
+
aria-describedby={slugError ? 'pf-slug-error' : undefined}
|
|
136
142
|
onChange={(e) => {
|
|
137
143
|
setSlugTouched(true)
|
|
138
144
|
setSlug(slugify(e.target.value))
|
|
139
145
|
}}
|
|
140
146
|
/>
|
|
147
|
+
{slugError && (
|
|
148
|
+
<p id="pf-slug-error" className="text-destructive mt-1 text-xs">
|
|
149
|
+
{slugError}
|
|
150
|
+
</p>
|
|
151
|
+
)}
|
|
141
152
|
</div>
|
|
142
153
|
<div>
|
|
143
154
|
<label className={LABEL} htmlFor="pf-category">
|
|
@@ -241,26 +252,34 @@ export function PostFieldsModal({
|
|
|
241
252
|
/>
|
|
242
253
|
</div>
|
|
243
254
|
<div>
|
|
244
|
-
<
|
|
245
|
-
|
|
246
|
-
|
|
255
|
+
<div className="mb-1 flex items-center justify-between">
|
|
256
|
+
<label className={`${LABEL} mb-0`} htmlFor="pf-seo-title">
|
|
257
|
+
SEO title
|
|
258
|
+
</label>
|
|
259
|
+
<SeoCharCount value={seoTitle} min={SEO_TITLE_MIN} max={SEO_TITLE_MAX} />
|
|
260
|
+
</div>
|
|
247
261
|
<input
|
|
248
262
|
id="pf-seo-title"
|
|
249
263
|
className={INPUT}
|
|
250
264
|
value={seoTitle}
|
|
251
265
|
disabled={!canEdit}
|
|
266
|
+
placeholder={title || 'Defaults to the post title'}
|
|
252
267
|
onChange={(e) => setSeoTitle(e.target.value)}
|
|
253
268
|
/>
|
|
254
269
|
</div>
|
|
255
270
|
<div>
|
|
256
|
-
<
|
|
257
|
-
|
|
258
|
-
|
|
271
|
+
<div className="mb-1 flex items-center justify-between">
|
|
272
|
+
<label className={`${LABEL} mb-0`} htmlFor="pf-seo-desc">
|
|
273
|
+
SEO description
|
|
274
|
+
</label>
|
|
275
|
+
<SeoCharCount value={seoDescription} min={SEO_DESC_MIN} max={SEO_DESC_MAX} />
|
|
276
|
+
</div>
|
|
259
277
|
<textarea
|
|
260
278
|
id="pf-seo-desc"
|
|
261
279
|
className={`${INPUT} min-h-[60px] resize-y`}
|
|
262
280
|
value={seoDescription}
|
|
263
281
|
disabled={!canEdit}
|
|
282
|
+
placeholder={excerpt || 'Summarize the post for search results (120–160 characters)'}
|
|
264
283
|
onChange={(e) => setSeoDescription(e.target.value)}
|
|
265
284
|
/>
|
|
266
285
|
</div>
|