@actuate-media/cms-admin 0.32.0 → 0.34.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 (69) hide show
  1. package/dist/__tests__/views/ai-settings.render.test.d.ts +2 -0
  2. package/dist/__tests__/views/ai-settings.render.test.d.ts.map +1 -0
  3. package/dist/__tests__/views/ai-settings.render.test.js +182 -0
  4. package/dist/__tests__/views/ai-settings.render.test.js.map +1 -0
  5. package/dist/__tests__/views/seo-settings.render.test.d.ts +2 -0
  6. package/dist/__tests__/views/seo-settings.render.test.d.ts.map +1 -0
  7. package/dist/__tests__/views/seo-settings.render.test.js +152 -0
  8. package/dist/__tests__/views/seo-settings.render.test.js.map +1 -0
  9. package/dist/__tests__/views/settings-save-footer.render.test.js +16 -9
  10. package/dist/__tests__/views/settings-save-footer.render.test.js.map +1 -1
  11. package/dist/actuate-admin.css +1 -1
  12. package/dist/components/MediaPickerModal.d.ts +15 -1
  13. package/dist/components/MediaPickerModal.d.ts.map +1 -1
  14. package/dist/components/MediaPickerModal.js +17 -1
  15. package/dist/components/MediaPickerModal.js.map +1 -1
  16. package/dist/views/Settings.d.ts.map +1 -1
  17. package/dist/views/Settings.js +3 -47
  18. package/dist/views/Settings.js.map +1 -1
  19. package/dist/views/settings/AISettingsTab.d.ts +6 -0
  20. package/dist/views/settings/AISettingsTab.d.ts.map +1 -0
  21. package/dist/views/settings/AISettingsTab.js +138 -0
  22. package/dist/views/settings/AISettingsTab.js.map +1 -0
  23. package/dist/views/settings/AiFeaturesCard.d.ts +12 -0
  24. package/dist/views/settings/AiFeaturesCard.d.ts.map +1 -0
  25. package/dist/views/settings/AiFeaturesCard.js +14 -0
  26. package/dist/views/settings/AiFeaturesCard.js.map +1 -0
  27. package/dist/views/settings/CapabilityBadges.d.ts +5 -0
  28. package/dist/views/settings/CapabilityBadges.d.ts.map +1 -0
  29. package/dist/views/settings/CapabilityBadges.js +26 -0
  30. package/dist/views/settings/CapabilityBadges.js.map +1 -0
  31. package/dist/views/settings/ModelSelector.d.ts +12 -0
  32. package/dist/views/settings/ModelSelector.d.ts.map +1 -0
  33. package/dist/views/settings/ModelSelector.js +29 -0
  34. package/dist/views/settings/ModelSelector.js.map +1 -0
  35. package/dist/views/settings/MonthlyUsageCard.d.ts +14 -0
  36. package/dist/views/settings/MonthlyUsageCard.d.ts.map +1 -0
  37. package/dist/views/settings/MonthlyUsageCard.js +30 -0
  38. package/dist/views/settings/MonthlyUsageCard.js.map +1 -0
  39. package/dist/views/settings/ProviderConnectionCard.d.ts +13 -0
  40. package/dist/views/settings/ProviderConnectionCard.d.ts.map +1 -0
  41. package/dist/views/settings/ProviderConnectionCard.js +48 -0
  42. package/dist/views/settings/ProviderConnectionCard.js.map +1 -0
  43. package/dist/views/settings/SeoSettingsTab.d.ts +7 -0
  44. package/dist/views/settings/SeoSettingsTab.d.ts.map +1 -0
  45. package/dist/views/settings/SeoSettingsTab.js +126 -0
  46. package/dist/views/settings/SeoSettingsTab.js.map +1 -0
  47. package/dist/views/settings/useAiSettings.d.ts +143 -0
  48. package/dist/views/settings/useAiSettings.d.ts.map +1 -0
  49. package/dist/views/settings/useAiSettings.js +278 -0
  50. package/dist/views/settings/useAiSettings.js.map +1 -0
  51. package/dist/views/settings/useSeoSettings.d.ts +79 -0
  52. package/dist/views/settings/useSeoSettings.d.ts.map +1 -0
  53. package/dist/views/settings/useSeoSettings.js +278 -0
  54. package/dist/views/settings/useSeoSettings.js.map +1 -0
  55. package/package.json +2 -2
  56. package/src/__tests__/views/ai-settings.render.test.tsx +225 -0
  57. package/src/__tests__/views/seo-settings.render.test.tsx +219 -0
  58. package/src/__tests__/views/settings-save-footer.render.test.tsx +16 -9
  59. package/src/components/MediaPickerModal.tsx +39 -2
  60. package/src/views/Settings.tsx +4 -141
  61. package/src/views/settings/AISettingsTab.tsx +411 -0
  62. package/src/views/settings/AiFeaturesCard.tsx +49 -0
  63. package/src/views/settings/CapabilityBadges.tsx +66 -0
  64. package/src/views/settings/ModelSelector.tsx +115 -0
  65. package/src/views/settings/MonthlyUsageCard.tsx +118 -0
  66. package/src/views/settings/ProviderConnectionCard.tsx +245 -0
  67. package/src/views/settings/SeoSettingsTab.tsx +658 -0
  68. package/src/views/settings/useAiSettings.ts +448 -0
  69. package/src/views/settings/useSeoSettings.ts +374 -0
@@ -0,0 +1,374 @@
1
+ 'use client'
2
+
3
+ import { useCallback, useEffect, useMemo, useState } from 'react'
4
+ import { cmsApi } from '../../lib/api.js'
5
+ import type { SettingsSourceMetadata, ValidationIssue } from './useGeneralSettings.js'
6
+
7
+ /**
8
+ * Settings → SEO ("Meta Defaults / Verification / Sitemap") form state.
9
+ *
10
+ * Single source of truth: every field reads from and writes back to the SAME
11
+ * shared SEO config service used by Settings → General and the /seo workspace.
12
+ * - meta defaults + verification → `/seo/config` (`site` overrides)
13
+ * - sitemap auto-generate / ping → `/seo/crawl-settings` (`module`)
14
+ * - sitemap status (read-only) → `/seo/sitemap/status`
15
+ *
16
+ * It deliberately does NOT own site URL or the document robots defaults — those
17
+ * are owned by Settings → General (also via `/seo/config`) and must not be
18
+ * duplicated here.
19
+ */
20
+ export interface SeoSettingsForm {
21
+ /** Site-wide title template (`%page% — %site%`), applied when a page has none. */
22
+ titleTemplate: string
23
+ /** Fallback meta description when a page/post defines none. */
24
+ defaultMetaDescription: string
25
+ /**
26
+ * Fallback social (Open Graph / Twitter) image URL. Kept as a denormalized
27
+ * cache of the selected asset's public URL so the synchronous public renderer
28
+ * doesn't need a DB lookup. Empty = use the dynamic /og.png fallback.
29
+ */
30
+ defaultOgImage: string
31
+ /** Canonical reference to the selected media asset (source of truth). */
32
+ defaultSocialImageAssetId: string
33
+ googleVerification: string
34
+ bingVerification: string
35
+ yandexVerification: string
36
+ pinterestVerification: string
37
+ facebookDomainVerification: string
38
+ /** Rebuild the sitemap whenever content is published (`module`). */
39
+ autoGenerateSitemap: boolean
40
+ /** Notify Google/Bing when the sitemap changes (`module`). */
41
+ pingOnPublish: boolean
42
+ }
43
+
44
+ export interface SitemapSummary {
45
+ sitemapUrl: string
46
+ lastGeneratedAt: string | null
47
+ pagesIncluded: number
48
+ pagesExcluded: number
49
+ warnings: string[]
50
+ }
51
+
52
+ const EMPTY_FORM: SeoSettingsForm = {
53
+ titleTemplate: '',
54
+ defaultMetaDescription: '',
55
+ defaultOgImage: '',
56
+ defaultSocialImageAssetId: '',
57
+ googleVerification: '',
58
+ bingVerification: '',
59
+ yandexVerification: '',
60
+ pinterestVerification: '',
61
+ facebookDomainVerification: '',
62
+ autoGenerateSitemap: true,
63
+ pingOnPublish: true,
64
+ }
65
+
66
+ /** Recommended max length for the default meta description (SERP truncation). */
67
+ export const META_DESCRIPTION_MAX = 160
68
+ /** Below this, the description is likely too thin to be useful. */
69
+ export const META_DESCRIPTION_MIN = 70
70
+
71
+ // Mirror of cms-core's `SITE_TITLE_TOKENS` (kept tiny + stable so the client can
72
+ // validate without importing the full cms-core module graph). The server
73
+ // re-validates on save via `validateTitleTemplate`, so this is a UX aid only.
74
+ const SITE_TITLE_TOKENS = ['page', 'site', 'type', 'separator', 'author', 'date', 'category', 'tag']
75
+ const TOKEN_RE = /%(\w+)%/g
76
+
77
+ export function validateTitleTemplateClient(template: string): {
78
+ valid: boolean
79
+ unknownTokens: string[]
80
+ } {
81
+ if (!template || template.trim() === '') return { valid: true, unknownTokens: [] }
82
+ const known = new Set(SITE_TITLE_TOKENS)
83
+ const unknown: string[] = []
84
+ let hasKnown = false
85
+ for (const m of template.matchAll(TOKEN_RE)) {
86
+ const t = m[1]!
87
+ if (known.has(t)) hasKnown = true
88
+ else if (!unknown.includes(t)) unknown.push(t)
89
+ }
90
+ const literal = template.replace(TOKEN_RE, '').trim()
91
+ if (unknown.length > 0) return { valid: false, unknownTokens: unknown }
92
+ if (!hasKnown && literal === '') return { valid: false, unknownTokens: [] }
93
+ return { valid: true, unknownTokens: [] }
94
+ }
95
+
96
+ function validate(form: SeoSettingsForm): ValidationIssue[] {
97
+ const issues: ValidationIssue[] = []
98
+ const tpl = validateTitleTemplateClient(form.titleTemplate)
99
+ if (!tpl.valid) {
100
+ issues.push({
101
+ field: 'titleTemplate',
102
+ message:
103
+ tpl.unknownTokens.length > 0
104
+ ? `Unsupported token${tpl.unknownTokens.length > 1 ? 's' : ''}: ${tpl.unknownTokens
105
+ .map((t) => `%${t}%`)
106
+ .join(', ')}`
107
+ : 'Template must include at least one token or some literal text.',
108
+ severity: 'error',
109
+ })
110
+ }
111
+ const desc = form.defaultMetaDescription.trim()
112
+ if (desc.length > META_DESCRIPTION_MAX) {
113
+ issues.push({
114
+ field: 'defaultMetaDescription',
115
+ message: `Default description is ${desc.length} characters — search engines truncate around ${META_DESCRIPTION_MAX}.`,
116
+ severity: 'warning',
117
+ })
118
+ } else if (desc.length > 0 && desc.length < META_DESCRIPTION_MIN) {
119
+ issues.push({
120
+ field: 'defaultMetaDescription',
121
+ message: `Default description is short (${desc.length} characters). Aim for ${META_DESCRIPTION_MIN}–${META_DESCRIPTION_MAX}.`,
122
+ severity: 'warning',
123
+ })
124
+ }
125
+ return issues
126
+ }
127
+
128
+ interface SeoConfigResponse {
129
+ static?: { site?: Record<string, any> | null }
130
+ overrides?: { site?: Record<string, any> | null } | null
131
+ effective?: { site?: Record<string, any> | null }
132
+ }
133
+
134
+ type SaveState = 'idle' | 'saving' | 'success' | 'error'
135
+
136
+ export interface UseSeoSettings {
137
+ loading: boolean
138
+ loadError: string | null
139
+ form: SeoSettingsForm
140
+ setField: <K extends keyof SeoSettingsForm>(key: K, value: SeoSettingsForm[K]) => void
141
+ dirty: boolean
142
+ issues: ValidationIssue[]
143
+ hasErrors: boolean
144
+ sources: Record<string, SettingsSourceMetadata>
145
+ /** Effective site name (read-only here; edited in General/advanced) for preview. */
146
+ siteName: string
147
+ /** Effective site URL for preview + warnings. */
148
+ siteUrl: string
149
+ isProduction: boolean
150
+ sitemap: SitemapSummary | null
151
+ saveState: SaveState
152
+ saveError: string | null
153
+ save: () => Promise<void>
154
+ reset: () => void
155
+ refetch: () => void
156
+ regenerateSitemap: () => Promise<void>
157
+ regenerating: boolean
158
+ }
159
+
160
+ export function useSeoSettings(canEdit: boolean): UseSeoSettings {
161
+ const [loading, setLoading] = useState(true)
162
+ const [loadError, setLoadError] = useState<string | null>(null)
163
+ const [baseline, setBaseline] = useState<SeoSettingsForm>(EMPTY_FORM)
164
+ const [form, setForm] = useState<SeoSettingsForm>(EMPTY_FORM)
165
+ const [overrideSite, setOverrideSite] = useState<Record<string, any>>({})
166
+ const [sources, setSources] = useState<Record<string, SettingsSourceMetadata>>({})
167
+ const [siteName, setSiteName] = useState('')
168
+ const [siteUrl, setSiteUrl] = useState('')
169
+ const [isProduction, setIsProduction] = useState(false)
170
+ const [sitemap, setSitemap] = useState<SitemapSummary | null>(null)
171
+ const [saveState, setSaveState] = useState<SaveState>('idle')
172
+ const [saveError, setSaveError] = useState<string | null>(null)
173
+ const [regenerating, setRegenerating] = useState(false)
174
+ const [reloadKey, setReloadKey] = useState(0)
175
+
176
+ useEffect(() => {
177
+ let cancelled = false
178
+ setLoading(true)
179
+ setLoadError(null)
180
+ Promise.all([
181
+ cmsApi<SeoConfigResponse>('/seo/config'),
182
+ cmsApi<Record<string, any>>('/seo/crawl-settings'),
183
+ cmsApi<SitemapSummary>('/seo/sitemap/status'),
184
+ cmsApi<{ environment?: string }>('/seo/index-status'),
185
+ ])
186
+ .then(([seoRes, crawlRes, sitemapRes, statusRes]) => {
187
+ if (cancelled) return
188
+ if (seoRes.error && seoRes.status !== 404) setLoadError(seoRes.error)
189
+
190
+ const seo = seoRes.data ?? {}
191
+ const effectiveSite = seo.effective?.site ?? {}
192
+ const staticSite = seo.static?.site ?? {}
193
+ const overSite = seo.overrides?.site ?? {}
194
+ const verification = effectiveSite.verification ?? {}
195
+ const crawl = crawlRes.data ?? {}
196
+
197
+ const loaded: SeoSettingsForm = {
198
+ titleTemplate: String(effectiveSite.titleTemplate ?? ''),
199
+ defaultMetaDescription: String(effectiveSite.defaultMetaDescription ?? ''),
200
+ defaultOgImage: String(effectiveSite.defaultOgImage ?? ''),
201
+ defaultSocialImageAssetId: String(effectiveSite.defaultSocialImageAssetId ?? ''),
202
+ googleVerification: String(verification.googleSearchConsole ?? ''),
203
+ bingVerification: String(verification.bingWebmasterTools ?? ''),
204
+ yandexVerification: String(verification.yandex ?? ''),
205
+ pinterestVerification: String(verification.pinterest ?? ''),
206
+ facebookDomainVerification: String(verification.facebookDomain ?? ''),
207
+ autoGenerateSitemap: crawl.autoRegenerateSitemapOnPublish !== false,
208
+ pingOnPublish: crawl.pingSearchEnginesOnPublish !== false,
209
+ }
210
+
211
+ // `editable` reflects the field's MANAGEMENT source (config vs database),
212
+ // not the user's role — role gates the inputs via `canEdit`. This keeps
213
+ // the source badge (and the field's accessible label) stable across
214
+ // roles, and only surfaces it when a value is locked by config.
215
+ const configManaged = (key: string): SettingsSourceMetadata =>
216
+ staticSite[key]
217
+ ? { source: 'config', editable: false, reason: 'Set in actuate.config.ts' }
218
+ : { source: 'database', editable: true }
219
+
220
+ setSources({
221
+ titleTemplate: configManaged('titleTemplate'),
222
+ defaultMetaDescription: configManaged('defaultMetaDescription'),
223
+ defaultOgImage: configManaged('defaultOgImage'),
224
+ verification: { source: 'database', editable: true },
225
+ sitemap: { source: 'database', editable: true },
226
+ })
227
+
228
+ setBaseline(loaded)
229
+ setForm(loaded)
230
+ setOverrideSite(overSite ?? {})
231
+ setSiteName(String(effectiveSite.siteName ?? ''))
232
+ setSiteUrl(String(effectiveSite.siteUrl ?? ''))
233
+ if (statusRes.data?.environment)
234
+ setIsProduction(statusRes.data.environment === 'production')
235
+ if (!sitemapRes.error && sitemapRes.data) setSitemap(sitemapRes.data)
236
+ })
237
+ .catch(() => {
238
+ if (!cancelled) setLoadError('Failed to load SEO settings.')
239
+ })
240
+ .finally(() => {
241
+ if (!cancelled) setLoading(false)
242
+ })
243
+ return () => {
244
+ cancelled = true
245
+ }
246
+ }, [reloadKey, canEdit])
247
+
248
+ const setField = useCallback(
249
+ <K extends keyof SeoSettingsForm>(key: K, value: SeoSettingsForm[K]) => {
250
+ setForm((prev) => ({ ...prev, [key]: value }))
251
+ setSaveState('idle')
252
+ },
253
+ [],
254
+ )
255
+
256
+ const dirty = useMemo(
257
+ () => (Object.keys(form) as Array<keyof SeoSettingsForm>).some((k) => form[k] !== baseline[k]),
258
+ [form, baseline],
259
+ )
260
+ const issues = useMemo(() => validate(form), [form])
261
+ const hasErrors = issues.some((i) => i.severity === 'error')
262
+
263
+ const refetchSitemap = useCallback(async () => {
264
+ const res = await cmsApi<SitemapSummary>('/seo/sitemap/status')
265
+ if (!res.error && res.data) setSitemap(res.data)
266
+ }, [])
267
+
268
+ const save = useCallback(async () => {
269
+ if (!canEdit || hasErrors) return
270
+ setSaveState('saving')
271
+ setSaveError(null)
272
+
273
+ const siteChanged =
274
+ form.titleTemplate !== baseline.titleTemplate ||
275
+ form.defaultMetaDescription !== baseline.defaultMetaDescription ||
276
+ form.defaultOgImage !== baseline.defaultOgImage ||
277
+ form.defaultSocialImageAssetId !== baseline.defaultSocialImageAssetId ||
278
+ form.googleVerification !== baseline.googleVerification ||
279
+ form.bingVerification !== baseline.bingVerification ||
280
+ form.yandexVerification !== baseline.yandexVerification ||
281
+ form.pinterestVerification !== baseline.pinterestVerification ||
282
+ form.facebookDomainVerification !== baseline.facebookDomainVerification
283
+ const sitemapChanged =
284
+ form.autoGenerateSitemap !== baseline.autoGenerateSitemap ||
285
+ form.pingOnPublish !== baseline.pingOnPublish
286
+
287
+ try {
288
+ // 1. Meta defaults + verification → shared /seo/config (merge over the
289
+ // current override site so General's siteUrl/robots aren't wiped).
290
+ if (siteChanged) {
291
+ const nextSite: Record<string, any> = {
292
+ ...overrideSite,
293
+ titleTemplate: form.titleTemplate.trim() || undefined,
294
+ defaultMetaDescription: form.defaultMetaDescription.trim() || undefined,
295
+ defaultOgImage: form.defaultOgImage.trim() || undefined,
296
+ defaultSocialImageAssetId: form.defaultSocialImageAssetId.trim() || undefined,
297
+ verification: {
298
+ ...(overrideSite.verification ?? {}),
299
+ googleSearchConsole: form.googleVerification.trim() || undefined,
300
+ bingWebmasterTools: form.bingVerification.trim() || undefined,
301
+ yandex: form.yandexVerification.trim() || undefined,
302
+ pinterest: form.pinterestVerification.trim() || undefined,
303
+ facebookDomain: form.facebookDomainVerification.trim() || undefined,
304
+ },
305
+ }
306
+ const res = await cmsApi('/seo/config', {
307
+ method: 'PUT',
308
+ body: JSON.stringify({ site: nextSite }),
309
+ })
310
+ if (res.error) throw new Error(res.error)
311
+ setOverrideSite(nextSite)
312
+ }
313
+
314
+ // 2. Sitemap toggles → shared /seo/crawl-settings (`module`).
315
+ if (sitemapChanged) {
316
+ const res = await cmsApi('/seo/crawl-settings', {
317
+ method: 'PUT',
318
+ body: JSON.stringify({
319
+ autoRegenerateSitemapOnPublish: form.autoGenerateSitemap,
320
+ pingSearchEnginesOnPublish: form.pingOnPublish,
321
+ }),
322
+ })
323
+ if (res.error) throw new Error(res.error)
324
+ }
325
+
326
+ setBaseline(form)
327
+ setSaveState('success')
328
+ if (sitemapChanged) await refetchSitemap()
329
+ } catch (err) {
330
+ setSaveState('error')
331
+ setSaveError(err instanceof Error ? err.message : 'Failed to save SEO settings.')
332
+ }
333
+ }, [canEdit, hasErrors, form, baseline, overrideSite, refetchSitemap])
334
+
335
+ const reset = useCallback(() => {
336
+ setForm(baseline)
337
+ setSaveState('idle')
338
+ setSaveError(null)
339
+ }, [baseline])
340
+
341
+ const refetch = useCallback(() => setReloadKey((k) => k + 1), [])
342
+
343
+ const regenerateSitemap = useCallback(async () => {
344
+ setRegenerating(true)
345
+ try {
346
+ const res = await cmsApi<SitemapSummary>('/seo/sitemap/regenerate', { method: 'POST' })
347
+ if (!res.error && res.data) setSitemap((prev) => ({ ...(prev ?? res.data!), ...res.data! }))
348
+ } finally {
349
+ setRegenerating(false)
350
+ }
351
+ }, [])
352
+
353
+ return {
354
+ loading,
355
+ loadError,
356
+ form,
357
+ setField,
358
+ dirty,
359
+ issues,
360
+ hasErrors,
361
+ sources,
362
+ siteName,
363
+ siteUrl,
364
+ isProduction,
365
+ sitemap,
366
+ saveState,
367
+ saveError,
368
+ save,
369
+ reset,
370
+ refetch,
371
+ regenerateSitemap,
372
+ regenerating,
373
+ }
374
+ }