@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.
Files changed (63) hide show
  1. package/CHANGELOG.md +50 -0
  2. package/dist/__tests__/components/seo-char-count.test.d.ts +2 -0
  3. package/dist/__tests__/components/seo-char-count.test.d.ts.map +1 -0
  4. package/dist/__tests__/components/seo-char-count.test.js +25 -0
  5. package/dist/__tests__/components/seo-char-count.test.js.map +1 -0
  6. package/dist/__tests__/lib/page-editor-service.test.js +12 -0
  7. package/dist/__tests__/lib/page-editor-service.test.js.map +1 -1
  8. package/dist/__tests__/lib/post-editor-service.test.js +4 -0
  9. package/dist/__tests__/lib/post-editor-service.test.js.map +1 -1
  10. package/dist/__tests__/views/seo-settings.render.test.js +1 -1
  11. package/dist/__tests__/views/seo-settings.render.test.js.map +1 -1
  12. package/dist/actuate-admin.css +1 -1
  13. package/dist/components/MediaPickerModal.d.ts.map +1 -1
  14. package/dist/components/MediaPickerModal.js +3 -1
  15. package/dist/components/MediaPickerModal.js.map +1 -1
  16. package/dist/components/SEOPanel.d.ts +1 -0
  17. package/dist/components/SEOPanel.d.ts.map +1 -1
  18. package/dist/components/SEOPanel.js +1 -1
  19. package/dist/components/SEOPanel.js.map +1 -1
  20. package/dist/components/seo/SeoCharCount.d.ts +15 -0
  21. package/dist/components/seo/SeoCharCount.d.ts.map +1 -0
  22. package/dist/components/seo/SeoCharCount.js +25 -0
  23. package/dist/components/seo/SeoCharCount.js.map +1 -0
  24. package/dist/components/seo/SeoEditorDrawer.d.ts.map +1 -1
  25. package/dist/components/seo/SeoEditorDrawer.js +3 -1
  26. package/dist/components/seo/SeoEditorDrawer.js.map +1 -1
  27. package/dist/fields/SlugField.d.ts.map +1 -1
  28. package/dist/fields/SlugField.js +6 -2
  29. package/dist/fields/SlugField.js.map +1 -1
  30. package/dist/lib/page-editor-service.d.ts.map +1 -1
  31. package/dist/lib/page-editor-service.js +6 -0
  32. package/dist/lib/page-editor-service.js.map +1 -1
  33. package/dist/lib/post-editor-service.d.ts.map +1 -1
  34. package/dist/lib/post-editor-service.js +6 -0
  35. package/dist/lib/post-editor-service.js.map +1 -1
  36. package/dist/lib/seo-service.d.ts +3 -1
  37. package/dist/lib/seo-service.d.ts.map +1 -1
  38. package/dist/lib/seo-service.js.map +1 -1
  39. package/dist/views/DocumentEdit.d.ts.map +1 -1
  40. package/dist/views/DocumentEdit.js +1 -0
  41. package/dist/views/DocumentEdit.js.map +1 -1
  42. package/dist/views/page-editor/PageSettingsModal.d.ts.map +1 -1
  43. package/dist/views/page-editor/PageSettingsModal.js +12 -9
  44. package/dist/views/page-editor/PageSettingsModal.js.map +1 -1
  45. package/dist/views/post-editor/PostFieldsModal.d.ts.map +1 -1
  46. package/dist/views/post-editor/PostFieldsModal.js +12 -9
  47. package/dist/views/post-editor/PostFieldsModal.js.map +1 -1
  48. package/package.json +2 -2
  49. package/src/__tests__/components/seo-char-count.test.tsx +27 -0
  50. package/src/__tests__/lib/page-editor-service.test.ts +14 -0
  51. package/src/__tests__/lib/post-editor-service.test.ts +9 -0
  52. package/src/__tests__/views/seo-settings.render.test.tsx +1 -1
  53. package/src/components/MediaPickerModal.tsx +5 -1
  54. package/src/components/SEOPanel.tsx +9 -0
  55. package/src/components/seo/SeoCharCount.tsx +44 -0
  56. package/src/components/seo/SeoEditorDrawer.tsx +27 -1
  57. package/src/fields/SlugField.tsx +6 -2
  58. package/src/lib/page-editor-service.ts +6 -0
  59. package/src/lib/post-editor-service.ts +6 -0
  60. package/src/lib/seo-service.ts +9 -1
  61. package/src/views/DocumentEdit.tsx +1 -0
  62. package/src/views/page-editor/PageSettingsModal.tsx +33 -14
  63. 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
- function slugify(value: string): string {
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
- <label className={LABEL} htmlFor="ps-seo-title">
129
- SEO title
130
- </label>
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
- <label className={LABEL} htmlFor="ps-seo-desc">
141
- SEO description
142
- </label>
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
- function slugify(value: string): string {
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
- <label className={LABEL} htmlFor="pf-seo-title">
245
- SEO title
246
- </label>
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
- <label className={LABEL} htmlFor="pf-seo-desc">
257
- SEO description
258
- </label>
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>