@actuate-media/cms-admin 0.65.0 → 0.67.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 (138) hide show
  1. package/CHANGELOG.md +39 -0
  2. package/dist/AdminRoot.d.ts.map +1 -1
  3. package/dist/AdminRoot.js +7 -4
  4. package/dist/AdminRoot.js.map +1 -1
  5. package/dist/__tests__/components/inspector-pane.test.d.ts +2 -0
  6. package/dist/__tests__/components/inspector-pane.test.d.ts.map +1 -0
  7. package/dist/__tests__/components/inspector-pane.test.js +31 -0
  8. package/dist/__tests__/components/inspector-pane.test.js.map +1 -0
  9. package/dist/__tests__/lib/pages-tree.test.d.ts +2 -0
  10. package/dist/__tests__/lib/pages-tree.test.d.ts.map +1 -0
  11. package/dist/__tests__/lib/pages-tree.test.js +173 -0
  12. package/dist/__tests__/lib/pages-tree.test.js.map +1 -0
  13. package/dist/__tests__/views/dashboard.test.js +7 -3
  14. package/dist/__tests__/views/dashboard.test.js.map +1 -1
  15. package/dist/__tests__/views/media-seo-audit.render.test.d.ts +2 -0
  16. package/dist/__tests__/views/media-seo-audit.render.test.d.ts.map +1 -0
  17. package/dist/__tests__/views/media-seo-audit.render.test.js +102 -0
  18. package/dist/__tests__/views/media-seo-audit.render.test.js.map +1 -0
  19. package/dist/__tests__/views/onboarding-block.render.test.d.ts +2 -0
  20. package/dist/__tests__/views/onboarding-block.render.test.d.ts.map +1 -0
  21. package/dist/__tests__/views/onboarding-block.render.test.js +146 -0
  22. package/dist/__tests__/views/onboarding-block.render.test.js.map +1 -0
  23. package/dist/__tests__/views/pages-list-view.test.js +77 -1
  24. package/dist/__tests__/views/pages-list-view.test.js.map +1 -1
  25. package/dist/actuate-admin.css +1 -1
  26. package/dist/components/script-tags/ScriptTagEditorPane.d.ts +26 -0
  27. package/dist/components/script-tags/ScriptTagEditorPane.d.ts.map +1 -0
  28. package/dist/components/script-tags/ScriptTagEditorPane.js +160 -0
  29. package/dist/components/script-tags/ScriptTagEditorPane.js.map +1 -0
  30. package/dist/components/ui/InspectorPane.d.ts +51 -0
  31. package/dist/components/ui/InspectorPane.d.ts.map +1 -0
  32. package/dist/components/ui/InspectorPane.js +42 -0
  33. package/dist/components/ui/InspectorPane.js.map +1 -0
  34. package/dist/components/ui/RowActions.d.ts +19 -0
  35. package/dist/components/ui/RowActions.d.ts.map +1 -0
  36. package/dist/components/ui/RowActions.js +16 -0
  37. package/dist/components/ui/RowActions.js.map +1 -0
  38. package/dist/components/ui/index.d.ts +4 -0
  39. package/dist/components/ui/index.d.ts.map +1 -1
  40. package/dist/components/ui/index.js +2 -0
  41. package/dist/components/ui/index.js.map +1 -1
  42. package/dist/layout/Header.js +1 -1
  43. package/dist/layout/Header.js.map +1 -1
  44. package/dist/layout/Layout.js +8 -38
  45. package/dist/layout/Layout.js.map +1 -1
  46. package/dist/lib/page-editor-service.d.ts +11 -2
  47. package/dist/lib/page-editor-service.d.ts.map +1 -1
  48. package/dist/lib/page-editor-service.js +8 -2
  49. package/dist/lib/page-editor-service.js.map +1 -1
  50. package/dist/lib/pages-service.d.ts +9 -0
  51. package/dist/lib/pages-service.d.ts.map +1 -1
  52. package/dist/lib/pages-service.js +14 -0
  53. package/dist/lib/pages-service.js.map +1 -1
  54. package/dist/lib/pages-tree.d.ts +44 -0
  55. package/dist/lib/pages-tree.d.ts.map +1 -0
  56. package/dist/lib/pages-tree.js +106 -0
  57. package/dist/lib/pages-tree.js.map +1 -0
  58. package/dist/lib/posts-service.d.ts +11 -0
  59. package/dist/lib/posts-service.d.ts.map +1 -1
  60. package/dist/lib/posts-service.js +13 -0
  61. package/dist/lib/posts-service.js.map +1 -1
  62. package/dist/lib/seo-service.d.ts +3 -0
  63. package/dist/lib/seo-service.d.ts.map +1 -1
  64. package/dist/lib/seo-service.js.map +1 -1
  65. package/dist/views/Dashboard.d.ts.map +1 -1
  66. package/dist/views/Dashboard.js +40 -3
  67. package/dist/views/Dashboard.js.map +1 -1
  68. package/dist/views/MediaBrowser.d.ts.map +1 -1
  69. package/dist/views/MediaBrowser.js +55 -2
  70. package/dist/views/MediaBrowser.js.map +1 -1
  71. package/dist/views/MediaSeoAudit.d.ts +25 -0
  72. package/dist/views/MediaSeoAudit.d.ts.map +1 -0
  73. package/dist/views/MediaSeoAudit.js +97 -0
  74. package/dist/views/MediaSeoAudit.js.map +1 -0
  75. package/dist/views/Pages/PagesListView.d.ts.map +1 -1
  76. package/dist/views/Pages/PagesListView.js +145 -16
  77. package/dist/views/Pages/PagesListView.js.map +1 -1
  78. package/dist/views/Pages/PagesTreeTable.d.ts +35 -0
  79. package/dist/views/Pages/PagesTreeTable.d.ts.map +1 -0
  80. package/dist/views/Pages/PagesTreeTable.js +28 -0
  81. package/dist/views/Pages/PagesTreeTable.js.map +1 -0
  82. package/dist/views/Posts/PostsListView.d.ts.map +1 -1
  83. package/dist/views/Posts/PostsListView.js +66 -9
  84. package/dist/views/Posts/PostsListView.js.map +1 -1
  85. package/dist/views/ScriptTags.d.ts +5 -1
  86. package/dist/views/ScriptTags.d.ts.map +1 -1
  87. package/dist/views/ScriptTags.js +32 -7
  88. package/dist/views/ScriptTags.js.map +1 -1
  89. package/dist/views/dashboard/OnboardingBlock.d.ts +29 -0
  90. package/dist/views/dashboard/OnboardingBlock.d.ts.map +1 -0
  91. package/dist/views/dashboard/OnboardingBlock.js +71 -0
  92. package/dist/views/dashboard/OnboardingBlock.js.map +1 -0
  93. package/dist/views/page-editor/PageSectionEditor.d.ts +7 -1
  94. package/dist/views/page-editor/PageSectionEditor.d.ts.map +1 -1
  95. package/dist/views/page-editor/PageSectionEditor.js +3 -3
  96. package/dist/views/page-editor/PageSectionEditor.js.map +1 -1
  97. package/dist/views/page-editor/PageSettingsModal.d.ts +1 -1
  98. package/dist/views/page-editor/PageSettingsModal.d.ts.map +1 -1
  99. package/dist/views/page-editor/PageSettingsModal.js +42 -3
  100. package/dist/views/page-editor/PageSettingsModal.js.map +1 -1
  101. package/dist/views/seo/TechnicalTab.d.ts.map +1 -1
  102. package/dist/views/seo/TechnicalTab.js +15 -0
  103. package/dist/views/seo/TechnicalTab.js.map +1 -1
  104. package/package.json +3 -3
  105. package/src/AdminRoot.tsx +7 -3
  106. package/src/__tests__/components/inspector-pane.test.tsx +64 -0
  107. package/src/__tests__/lib/pages-tree.test.ts +197 -0
  108. package/src/__tests__/views/dashboard.test.tsx +7 -3
  109. package/src/__tests__/views/media-seo-audit.render.test.tsx +117 -0
  110. package/src/__tests__/views/onboarding-block.render.test.tsx +193 -0
  111. package/src/__tests__/views/pages-list-view.test.tsx +93 -1
  112. package/src/components/script-tags/ScriptTagEditorPane.tsx +474 -0
  113. package/src/components/ui/InspectorPane.tsx +129 -0
  114. package/src/components/ui/RowActions.tsx +50 -0
  115. package/src/components/ui/index.ts +12 -0
  116. package/src/layout/Header.tsx +2 -2
  117. package/src/layout/Layout.tsx +8 -80
  118. package/src/lib/page-editor-service.ts +16 -2
  119. package/src/lib/pages-service.ts +18 -0
  120. package/src/lib/pages-tree.ts +145 -0
  121. package/src/lib/posts-service.ts +26 -0
  122. package/src/lib/seo-service.ts +3 -0
  123. package/src/views/Dashboard.tsx +286 -214
  124. package/src/views/MediaBrowser.tsx +673 -512
  125. package/src/views/MediaSeoAudit.tsx +320 -0
  126. package/src/views/Pages/PagesListView.tsx +459 -185
  127. package/src/views/Pages/PagesTreeTable.tsx +339 -0
  128. package/src/views/Posts/PostsListView.tsx +135 -10
  129. package/src/views/ScriptTags.tsx +204 -129
  130. package/src/views/dashboard/OnboardingBlock.tsx +233 -0
  131. package/src/views/page-editor/PageSectionEditor.tsx +9 -2
  132. package/src/views/page-editor/PageSettingsModal.tsx +75 -2
  133. package/src/views/seo/TechnicalTab.tsx +15 -0
  134. package/dist/views/ScriptTagEditor.d.ts +0 -6
  135. package/dist/views/ScriptTagEditor.d.ts.map +0 -1
  136. package/dist/views/ScriptTagEditor.js +0 -111
  137. package/dist/views/ScriptTagEditor.js.map +0 -1
  138. package/src/views/ScriptTagEditor.tsx +0 -353
@@ -0,0 +1,320 @@
1
+ 'use client'
2
+
3
+ import { Check, FileImage, ImageIcon, SquarePen, TriangleAlert, X } from 'lucide-react'
4
+ import { useMemo, useState } from 'react'
5
+ import { FilterPill } from '../components/ui/FilterPill.js'
6
+
7
+ /**
8
+ * The subset of the `GET /media` item shape the audit needs. `seoScore` is
9
+ * derived server-side by `calculateMediaSeoScore()` in cms-core — the audit
10
+ * never re-scores client-side, so this table can't drift from the SEO drawer
11
+ * or the MCP `validateMediaSEO` tool.
12
+ */
13
+ export interface MediaSeoAuditItem {
14
+ id: number | string
15
+ name: string
16
+ type: string
17
+ size: string
18
+ url: string
19
+ format?: string
20
+ altTag?: string
21
+ title?: string
22
+ description?: string
23
+ seoScore?: number
24
+ }
25
+
26
+ export interface MediaSeoAuditProps {
27
+ items: MediaSeoAuditItem[]
28
+ /** Fired when a row / Edit button is activated — opens the item in the Library tab. */
29
+ onEdit: (item: MediaSeoAuditItem) => void
30
+ }
31
+
32
+ type AuditFilter = 'all' | 'alt' | 'title' | 'poor'
33
+
34
+ const FILTERS: Array<[AuditFilter, string]> = [
35
+ ['all', 'All items'],
36
+ ['alt', 'Missing Alt'],
37
+ ['title', 'Missing Title'],
38
+ ['poor', 'Poor score'],
39
+ ]
40
+
41
+ const hasValue = (value?: string): boolean => Boolean(value?.trim())
42
+
43
+ const itemScore = (item: MediaSeoAuditItem): number => item.seoScore ?? 0
44
+
45
+ /** Presentational buckets for the score bar/number — score itself comes from cms-core. */
46
+ function scoreToneClass(score: number): string {
47
+ if (score >= 80) return 'text-[var(--suc)]'
48
+ if (score >= 30) return 'text-[var(--warn)]'
49
+ return 'text-[var(--err)]'
50
+ }
51
+
52
+ function scoreBarClass(score: number): string {
53
+ if (score >= 80) return 'bg-[var(--suc)]'
54
+ if (score >= 30) return 'bg-[var(--warn)]'
55
+ return 'bg-[var(--err)]'
56
+ }
57
+
58
+ function scoreLabel(score: number): string {
59
+ if (score >= 80) return 'Good'
60
+ if (score >= 55) return 'Fair'
61
+ if (score >= 30) return 'Needs work'
62
+ return 'Poor'
63
+ }
64
+
65
+ function isImageItem(item: MediaSeoAuditItem): boolean {
66
+ return (
67
+ Boolean(item.url) &&
68
+ (item.type?.startsWith('image/') || /\.(avif|gif|jpe?g|png|webp|svg)$/i.test(item.url))
69
+ )
70
+ }
71
+
72
+ function typeLabel(item: MediaSeoAuditItem): string {
73
+ return (item.format ?? item.type ?? '').replace(/^.*\//, '').toUpperCase()
74
+ }
75
+
76
+ function ScoreBar({ score }: { score: number }) {
77
+ return (
78
+ <div className="flex items-center gap-[7px]">
79
+ <div
80
+ role="meter"
81
+ aria-label="SEO score"
82
+ aria-valuenow={score}
83
+ aria-valuemin={0}
84
+ aria-valuemax={100}
85
+ className="h-[5px] w-[60px] overflow-hidden rounded-full bg-[var(--bdr)]"
86
+ >
87
+ <div
88
+ className={`h-full rounded-full motion-safe:transition-[width] motion-safe:duration-300 ${scoreBarClass(score)}`}
89
+ style={{ width: `${score}%` }}
90
+ />
91
+ </div>
92
+ <span
93
+ className={`w-7 text-right text-xs font-semibold tabular-nums ${scoreToneClass(score)}`}
94
+ >
95
+ {score}
96
+ </span>
97
+ </div>
98
+ )
99
+ }
100
+
101
+ function SeoCheck({ set, missTone }: { set: boolean; missTone: 'err' | 'warn' }) {
102
+ if (set) {
103
+ return (
104
+ <span className="inline-flex items-center gap-[3px] rounded-[4px] bg-[var(--suc-l)] px-[7px] py-[2px] text-[11px] font-medium text-[var(--suc)]">
105
+ <Check size={10} aria-hidden />
106
+ Set
107
+ </span>
108
+ )
109
+ }
110
+ const Icon = missTone === 'err' ? X : TriangleAlert
111
+ const tone =
112
+ missTone === 'err'
113
+ ? 'bg-[var(--err-l)] text-[var(--err)]'
114
+ : 'bg-[var(--warn-l)] text-[var(--warn)]'
115
+ return (
116
+ <span
117
+ className={`inline-flex items-center gap-[3px] rounded-[4px] px-[7px] py-[2px] text-[11px] font-medium ${tone}`}
118
+ >
119
+ <Icon size={10} aria-hidden />
120
+ Missing
121
+ </span>
122
+ )
123
+ }
124
+
125
+ function StatCard({
126
+ value,
127
+ label,
128
+ sub,
129
+ toneClass,
130
+ }: {
131
+ value: string
132
+ label: string
133
+ sub: string
134
+ toneClass: string
135
+ }) {
136
+ return (
137
+ <div className="rounded-[10px] border border-[var(--bdr)] bg-[var(--card)] px-4 py-3.5">
138
+ <div className={`text-[26px] leading-none font-bold tracking-[-0.8px] ${toneClass}`}>
139
+ {value}
140
+ </div>
141
+ <div className="mt-1 text-xs text-[var(--sub)]">{label}</div>
142
+ <div className={`mt-[3px] text-[11px] font-medium ${toneClass}`}>{sub}</div>
143
+ </div>
144
+ )
145
+ }
146
+
147
+ export function MediaSeoAudit({ items, onEdit }: MediaSeoAuditProps) {
148
+ const [filter, setFilter] = useState<AuditFilter>('all')
149
+
150
+ const stats = useMemo(() => {
151
+ const total = items.length
152
+ const missingAlt = items.filter((it) => !hasValue(it.altTag)).length
153
+ const missingTitle = items.filter((it) => !hasValue(it.title)).length
154
+ const missingDesc = items.filter((it) => !hasValue(it.description)).length
155
+ const avgScore =
156
+ total === 0 ? 0 : Math.round(items.reduce((sum, it) => sum + itemScore(it), 0) / total)
157
+ return { total, missingAlt, missingTitle, missingDesc, avgScore }
158
+ }, [items])
159
+
160
+ const filtered = useMemo(() => {
161
+ switch (filter) {
162
+ case 'all':
163
+ return items
164
+ case 'alt':
165
+ return items.filter((it) => !hasValue(it.altTag))
166
+ case 'title':
167
+ return items.filter((it) => !hasValue(it.title))
168
+ case 'poor':
169
+ return items.filter((it) => itemScore(it) < 40)
170
+ default: {
171
+ const exhaustive: never = filter
172
+ return exhaustive
173
+ }
174
+ }
175
+ }, [items, filter])
176
+
177
+ return (
178
+ <div className="min-h-0 flex-1 overflow-y-auto">
179
+ <div className="mb-[18px] grid grid-cols-2 gap-2.5 lg:grid-cols-4">
180
+ <StatCard
181
+ value={`${stats.avgScore}/100`}
182
+ label="Avg SEO Score"
183
+ sub={scoreLabel(stats.avgScore)}
184
+ toneClass={scoreToneClass(stats.avgScore)}
185
+ />
186
+ <StatCard
187
+ value={String(stats.missingAlt)}
188
+ label="Missing Alt Text"
189
+ sub={`${stats.total - stats.missingAlt} have alt text`}
190
+ toneClass={stats.missingAlt > 0 ? 'text-[var(--err)]' : 'text-[var(--suc)]'}
191
+ />
192
+ <StatCard
193
+ value={String(stats.missingTitle)}
194
+ label="Missing Titles"
195
+ sub={`${stats.total - stats.missingTitle} have titles`}
196
+ toneClass={stats.missingTitle > 0 ? 'text-[var(--warn)]' : 'text-[var(--suc)]'}
197
+ />
198
+ <StatCard
199
+ value={String(stats.missingDesc)}
200
+ label="Missing Descriptions"
201
+ sub={`${stats.total - stats.missingDesc} have descriptions`}
202
+ toneClass={stats.missingDesc > 5 ? 'text-[var(--warn)]' : 'text-[var(--sub)]'}
203
+ />
204
+ </div>
205
+
206
+ <div className="mb-3 flex flex-wrap items-center gap-2">
207
+ <span className="text-[12.5px] font-medium text-[var(--sub)]">Show:</span>
208
+ {FILTERS.map(([value, label]) => (
209
+ <FilterPill key={value} active={filter === value} onClick={() => setFilter(value)}>
210
+ {label}
211
+ </FilterPill>
212
+ ))}
213
+ <span className="ml-auto text-xs text-[var(--muted)]">
214
+ {filtered.length} item{filtered.length !== 1 ? 's' : ''}
215
+ </span>
216
+ </div>
217
+
218
+ <div className="overflow-hidden rounded-[10px] border border-[var(--bdr)] bg-[var(--card)]">
219
+ {filtered.length === 0 ? (
220
+ <div className="flex flex-col items-center justify-center px-4 py-14 text-center">
221
+ <ImageIcon size={24} className="mb-3 text-[var(--muted)]" aria-hidden />
222
+ <p className="text-sm font-medium text-[var(--txt)]">
223
+ {items.length === 0 ? 'No media to audit' : 'No items match this filter'}
224
+ </p>
225
+ <p className="mt-1 text-xs text-[var(--muted)]">
226
+ {items.length === 0
227
+ ? 'Upload files to the library to see their SEO health here.'
228
+ : 'Try a different filter to see more items.'}
229
+ </p>
230
+ </div>
231
+ ) : (
232
+ <table className="w-full border-collapse" aria-label="Media SEO audit">
233
+ <thead>
234
+ <tr>
235
+ {['File', 'Type', 'Alt Text', 'Title', 'Description', 'SEO Score', 'Action'].map(
236
+ (heading) => (
237
+ <th
238
+ key={heading}
239
+ scope="col"
240
+ className={`border-b border-[var(--bdr)] bg-[var(--bg)] px-3 py-2 text-[11px] font-semibold tracking-[0.5px] whitespace-nowrap text-[var(--muted)] uppercase ${
241
+ heading === 'Action' ? 'text-right' : 'text-left'
242
+ }`}
243
+ >
244
+ {heading}
245
+ </th>
246
+ ),
247
+ )}
248
+ </tr>
249
+ </thead>
250
+ <tbody>
251
+ {filtered.map((item) => {
252
+ const score = itemScore(item)
253
+ return (
254
+ <tr
255
+ key={item.id}
256
+ onClick={() => onEdit(item)}
257
+ className="cursor-pointer border-b border-[var(--bdr)] transition-colors last:border-b-0 hover:bg-[var(--acc-l)]"
258
+ >
259
+ <td className="px-3 py-[9px] align-middle">
260
+ <div className="flex items-center gap-2.5">
261
+ <div className="flex h-[30px] w-[38px] shrink-0 items-center justify-center overflow-hidden rounded-[5px] border border-[var(--bdr)] bg-[var(--bg)]">
262
+ {isImageItem(item) ? (
263
+ <img
264
+ src={item.url}
265
+ alt=""
266
+ className="h-full w-full object-cover"
267
+ loading="lazy"
268
+ />
269
+ ) : (
270
+ <FileImage size={14} className="text-[var(--muted)]" aria-hidden />
271
+ )}
272
+ </div>
273
+ <div className="min-w-0">
274
+ <div className="max-w-[160px] truncate text-[12.5px] font-medium text-[var(--txt)]">
275
+ {item.name}
276
+ </div>
277
+ <div className="text-[11px] text-[var(--muted)]">{item.size}</div>
278
+ </div>
279
+ </div>
280
+ </td>
281
+ <td className="px-3 py-[9px] align-middle">
282
+ <span className="text-[11.5px] font-semibold tracking-[0.3px] text-[var(--sub)] uppercase">
283
+ {typeLabel(item)}
284
+ </span>
285
+ </td>
286
+ <td className="px-3 py-[9px] align-middle">
287
+ <SeoCheck set={hasValue(item.altTag)} missTone="err" />
288
+ </td>
289
+ <td className="px-3 py-[9px] align-middle">
290
+ <SeoCheck set={hasValue(item.title)} missTone="warn" />
291
+ </td>
292
+ <td className="px-3 py-[9px] align-middle">
293
+ <SeoCheck set={hasValue(item.description)} missTone="warn" />
294
+ </td>
295
+ <td className="px-3 py-[9px] align-middle">
296
+ <ScoreBar score={score} />
297
+ </td>
298
+ <td className="px-3 py-[9px] text-right align-middle">
299
+ <button
300
+ type="button"
301
+ onClick={(e) => {
302
+ e.stopPropagation()
303
+ onEdit(item)
304
+ }}
305
+ className="inline-flex items-center gap-[5px] rounded-[7px] px-2 py-1 text-[11.5px] text-[var(--sub)] transition-colors hover:bg-[var(--acc-l)] hover:text-[var(--acc)] focus-visible:ring-2 focus-visible:ring-[var(--ring)] focus-visible:outline-none"
306
+ >
307
+ <SquarePen size={11} aria-hidden />
308
+ Edit
309
+ </button>
310
+ </td>
311
+ </tr>
312
+ )
313
+ })}
314
+ </tbody>
315
+ </table>
316
+ )}
317
+ </div>
318
+ </div>
319
+ )
320
+ }