@actuate-media/cms-admin 0.25.0 → 0.27.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 (101) hide show
  1. package/dist/AdminRoot.d.ts.map +1 -1
  2. package/dist/AdminRoot.js +3 -3
  3. package/dist/AdminRoot.js.map +1 -1
  4. package/dist/__tests__/views/appearance-settings.render.test.d.ts +2 -0
  5. package/dist/__tests__/views/appearance-settings.render.test.d.ts.map +1 -0
  6. package/dist/__tests__/views/appearance-settings.render.test.js +95 -0
  7. package/dist/__tests__/views/appearance-settings.render.test.js.map +1 -0
  8. package/dist/__tests__/views/general-settings.render.test.d.ts +2 -0
  9. package/dist/__tests__/views/general-settings.render.test.d.ts.map +1 -0
  10. package/dist/__tests__/views/general-settings.render.test.js +105 -0
  11. package/dist/__tests__/views/general-settings.render.test.js.map +1 -0
  12. package/dist/actuate-admin.css +1 -1
  13. package/dist/components/ThemeProvider.d.ts +47 -4
  14. package/dist/components/ThemeProvider.d.ts.map +1 -1
  15. package/dist/components/ThemeProvider.js +173 -27
  16. package/dist/components/ThemeProvider.js.map +1 -1
  17. package/dist/components/ui/Badge.d.ts +1 -1
  18. package/dist/components/ui/Button.d.ts +1 -1
  19. package/dist/components/ui/Card.d.ts +2 -2
  20. package/dist/components/ui/Input.d.ts +1 -1
  21. package/dist/components/ui/Select.d.ts +1 -1
  22. package/dist/layout/Sidebar.d.ts.map +1 -1
  23. package/dist/layout/Sidebar.js +47 -8
  24. package/dist/layout/Sidebar.js.map +1 -1
  25. package/dist/views/Settings.d.ts +3 -1
  26. package/dist/views/Settings.d.ts.map +1 -1
  27. package/dist/views/Settings.js +10 -33
  28. package/dist/views/Settings.js.map +1 -1
  29. package/dist/views/settings/AdminThemeCard.d.ts +6 -0
  30. package/dist/views/settings/AdminThemeCard.d.ts.map +1 -0
  31. package/dist/views/settings/AdminThemeCard.js +31 -0
  32. package/dist/views/settings/AdminThemeCard.js.map +1 -0
  33. package/dist/views/settings/AppearanceTab.d.ts +7 -0
  34. package/dist/views/settings/AppearanceTab.d.ts.map +1 -0
  35. package/dist/views/settings/AppearanceTab.js +24 -0
  36. package/dist/views/settings/AppearanceTab.js.map +1 -0
  37. package/dist/views/settings/BrandPreviewCard.d.ts +5 -0
  38. package/dist/views/settings/BrandPreviewCard.d.ts.map +1 -0
  39. package/dist/views/settings/BrandPreviewCard.js +18 -0
  40. package/dist/views/settings/BrandPreviewCard.js.map +1 -0
  41. package/dist/views/settings/BrandingCard.d.ts +6 -0
  42. package/dist/views/settings/BrandingCard.d.ts.map +1 -0
  43. package/dist/views/settings/BrandingCard.js +38 -0
  44. package/dist/views/settings/BrandingCard.js.map +1 -0
  45. package/dist/views/settings/GeneralSettingsTab.d.ts +7 -0
  46. package/dist/views/settings/GeneralSettingsTab.d.ts.map +1 -0
  47. package/dist/views/settings/GeneralSettingsTab.js +19 -0
  48. package/dist/views/settings/GeneralSettingsTab.js.map +1 -0
  49. package/dist/views/settings/LanguageRegionCard.d.ts +10 -0
  50. package/dist/views/settings/LanguageRegionCard.d.ts.map +1 -0
  51. package/dist/views/settings/LanguageRegionCard.js +48 -0
  52. package/dist/views/settings/LanguageRegionCard.js.map +1 -0
  53. package/dist/views/settings/SeoRobotsDefaultsCard.d.ts +12 -0
  54. package/dist/views/settings/SeoRobotsDefaultsCard.d.ts.map +1 -0
  55. package/dist/views/settings/SeoRobotsDefaultsCard.js +45 -0
  56. package/dist/views/settings/SeoRobotsDefaultsCard.js.map +1 -0
  57. package/dist/views/settings/SiteIndexStatusCard.d.ts +12 -0
  58. package/dist/views/settings/SiteIndexStatusCard.d.ts.map +1 -0
  59. package/dist/views/settings/SiteIndexStatusCard.js +51 -0
  60. package/dist/views/settings/SiteIndexStatusCard.js.map +1 -0
  61. package/dist/views/settings/SiteInformationCard.d.ts +11 -0
  62. package/dist/views/settings/SiteInformationCard.d.ts.map +1 -0
  63. package/dist/views/settings/SiteInformationCard.js +21 -0
  64. package/dist/views/settings/SiteInformationCard.js.map +1 -0
  65. package/dist/views/settings/TypographyMotionCard.d.ts +6 -0
  66. package/dist/views/settings/TypographyMotionCard.d.ts.map +1 -0
  67. package/dist/views/settings/TypographyMotionCard.js +8 -0
  68. package/dist/views/settings/TypographyMotionCard.js.map +1 -0
  69. package/dist/views/settings/components.d.ts +38 -0
  70. package/dist/views/settings/components.d.ts.map +1 -0
  71. package/dist/views/settings/components.js +43 -0
  72. package/dist/views/settings/components.js.map +1 -0
  73. package/dist/views/settings/useAppearanceSettings.d.ts +69 -0
  74. package/dist/views/settings/useAppearanceSettings.d.ts.map +1 -0
  75. package/dist/views/settings/useAppearanceSettings.js +344 -0
  76. package/dist/views/settings/useAppearanceSettings.js.map +1 -0
  77. package/dist/views/settings/useGeneralSettings.d.ts +82 -0
  78. package/dist/views/settings/useGeneralSettings.d.ts.map +1 -0
  79. package/dist/views/settings/useGeneralSettings.js +286 -0
  80. package/dist/views/settings/useGeneralSettings.js.map +1 -0
  81. package/package.json +3 -3
  82. package/src/AdminRoot.tsx +5 -3
  83. package/src/__tests__/views/appearance-settings.render.test.tsx +132 -0
  84. package/src/__tests__/views/general-settings.render.test.tsx +135 -0
  85. package/src/components/ThemeProvider.tsx +249 -36
  86. package/src/layout/Sidebar.tsx +81 -13
  87. package/src/styles/theme.css +32 -2
  88. package/src/views/Settings.tsx +19 -155
  89. package/src/views/settings/AdminThemeCard.tsx +145 -0
  90. package/src/views/settings/AppearanceTab.tsx +81 -0
  91. package/src/views/settings/BrandPreviewCard.tsx +125 -0
  92. package/src/views/settings/BrandingCard.tsx +332 -0
  93. package/src/views/settings/GeneralSettingsTab.tsx +126 -0
  94. package/src/views/settings/LanguageRegionCard.tsx +119 -0
  95. package/src/views/settings/SeoRobotsDefaultsCard.tsx +149 -0
  96. package/src/views/settings/SiteIndexStatusCard.tsx +184 -0
  97. package/src/views/settings/SiteInformationCard.tsx +122 -0
  98. package/src/views/settings/TypographyMotionCard.tsx +65 -0
  99. package/src/views/settings/components.tsx +198 -0
  100. package/src/views/settings/useAppearanceSettings.ts +513 -0
  101. package/src/views/settings/useGeneralSettings.ts +379 -0
@@ -0,0 +1,379 @@
1
+ 'use client'
2
+
3
+ import { useCallback, useEffect, useMemo, useState } from 'react'
4
+ import { cmsApi } from '../../lib/api.js'
5
+
6
+ /** Editable + computed shape of the General tab form. */
7
+ export interface GeneralSettingsForm {
8
+ siteTitle: string
9
+ tagline: string
10
+ siteUrl: string
11
+ language: string
12
+ timezone: string
13
+ defaultNoIndex: boolean
14
+ defaultNoFollow: boolean
15
+ noIndexNonProduction: boolean
16
+ }
17
+
18
+ export type SettingsSource = 'config' | 'environment' | 'database' | 'plugin' | 'computed'
19
+
20
+ export interface SettingsSourceMetadata {
21
+ source: SettingsSource
22
+ editable: boolean
23
+ reason?: string
24
+ }
25
+
26
+ export type IndexStatusLevel =
27
+ | 'indexable'
28
+ | 'protected'
29
+ | 'blocked'
30
+ | 'partially_blocked'
31
+ | 'unknown'
32
+
33
+ export interface SiteIndexStatus {
34
+ status: IndexStatusLevel
35
+ severity: 'success' | 'warning' | 'danger' | 'neutral'
36
+ label: string
37
+ description: string
38
+ environment: string
39
+ checks: Array<{
40
+ key: string
41
+ label: string
42
+ value: string
43
+ status: 'pass' | 'warning' | 'fail' | 'unknown'
44
+ }>
45
+ actions: Array<{ label: string; href?: string; action?: string }>
46
+ lastAuditAt: string | null
47
+ }
48
+
49
+ export interface ValidationIssue {
50
+ /** Field key the issue relates to. A plain string so the primitive can be
51
+ * reused by other settings tabs (e.g. Appearance) whose fields aren't part
52
+ * of `GeneralSettingsForm`. */
53
+ field: string
54
+ message: string
55
+ severity: 'error' | 'warning'
56
+ }
57
+
58
+ const EMPTY_FORM: GeneralSettingsForm = {
59
+ siteTitle: '',
60
+ tagline: '',
61
+ siteUrl: '',
62
+ language: 'en',
63
+ timezone: 'UTC',
64
+ defaultNoIndex: false,
65
+ defaultNoFollow: false,
66
+ noIndexNonProduction: false,
67
+ }
68
+
69
+ /** Validate a site URL: absolute http(s), no trailing slash, parseable. */
70
+ export function validateSiteUrl(value: string): { valid: boolean; error?: string } {
71
+ const trimmed = value.trim()
72
+ if (!trimmed) return { valid: true }
73
+ let url: URL
74
+ try {
75
+ url = new URL(trimmed)
76
+ } catch {
77
+ return { valid: false, error: 'Enter a valid absolute URL, e.g. https://example.com' }
78
+ }
79
+ if (url.protocol !== 'http:' && url.protocol !== 'https:') {
80
+ return { valid: false, error: 'Site URL must start with http:// or https://' }
81
+ }
82
+ if (trimmed.endsWith('/')) {
83
+ return { valid: false, error: 'Remove the trailing slash (e.g. https://example.com)' }
84
+ }
85
+ return { valid: true }
86
+ }
87
+
88
+ /** Best-effort IANA timezone check using the Intl API. */
89
+ export function isValidTimeZone(tz: string): boolean {
90
+ if (!tz) return false
91
+ try {
92
+ new Intl.DateTimeFormat('en-US', { timeZone: tz })
93
+ return true
94
+ } catch {
95
+ return false
96
+ }
97
+ }
98
+
99
+ /** Best-effort BCP 47 locale check. */
100
+ export function isValidLocale(locale: string): boolean {
101
+ if (!locale) return false
102
+ try {
103
+ // Throws RangeError on structurally invalid tags.
104
+ new Intl.Locale(locale)
105
+ return /^[a-zA-Z]{2,3}([-_][a-zA-Z0-9]{2,8})*$/.test(locale)
106
+ } catch {
107
+ return false
108
+ }
109
+ }
110
+
111
+ function validate(form: GeneralSettingsForm, isProduction: boolean): ValidationIssue[] {
112
+ const issues: ValidationIssue[] = []
113
+ if (!form.siteTitle.trim()) {
114
+ issues.push({ field: 'siteTitle', message: 'Site Title is required.', severity: 'error' })
115
+ }
116
+ const urlCheck = validateSiteUrl(form.siteUrl)
117
+ if (!urlCheck.valid) {
118
+ issues.push({ field: 'siteUrl', message: urlCheck.error!, severity: 'error' })
119
+ } else if (!form.siteUrl.trim() && isProduction) {
120
+ issues.push({
121
+ field: 'siteUrl',
122
+ message: 'Site URL is not set — canonical, sitemap, and Open Graph URLs may be invalid.',
123
+ severity: 'warning',
124
+ })
125
+ }
126
+ if (!isValidLocale(form.language)) {
127
+ issues.push({
128
+ field: 'language',
129
+ message: 'Language must be a valid locale code, e.g. en-US.',
130
+ severity: 'error',
131
+ })
132
+ }
133
+ if (!isValidTimeZone(form.timezone)) {
134
+ issues.push({
135
+ field: 'timezone',
136
+ message: 'Timezone must be a valid IANA name, e.g. America/Los_Angeles.',
137
+ severity: 'error',
138
+ })
139
+ }
140
+ return issues
141
+ }
142
+
143
+ interface SeoConfigResponse {
144
+ static?: { site?: Record<string, any> | null }
145
+ overrides?: { site?: Record<string, any> | null } | null
146
+ effective?: { site?: Record<string, any> | null }
147
+ }
148
+
149
+ type SaveState = 'idle' | 'saving' | 'success' | 'error'
150
+
151
+ export interface UseGeneralSettings {
152
+ loading: boolean
153
+ loadError: string | null
154
+ form: GeneralSettingsForm
155
+ setField: <K extends keyof GeneralSettingsForm>(key: K, value: GeneralSettingsForm[K]) => void
156
+ dirty: boolean
157
+ issues: ValidationIssue[]
158
+ hasErrors: boolean
159
+ indexStatus: SiteIndexStatus | null
160
+ sources: Record<string, SettingsSourceMetadata>
161
+ isProduction: boolean
162
+ saveState: SaveState
163
+ saveError: string | null
164
+ save: () => Promise<void>
165
+ reset: () => void
166
+ refetch: () => void
167
+ }
168
+
169
+ /**
170
+ * Loads the General tab from its three real backends — the `settings` global
171
+ * (site info / locale), the shared `/seo/config` (site URL + robots defaults),
172
+ * and the computed `/seo/index-status` — tracks a dirty diff, and on save
173
+ * writes each field back to its OWNING store (never a duplicate), preserving
174
+ * unrelated keys.
175
+ */
176
+ export function useGeneralSettings(canEdit: boolean): UseGeneralSettings {
177
+ const [loading, setLoading] = useState(true)
178
+ const [loadError, setLoadError] = useState<string | null>(null)
179
+ const [baseline, setBaseline] = useState<GeneralSettingsForm>(EMPTY_FORM)
180
+ const [form, setForm] = useState<GeneralSettingsForm>(EMPTY_FORM)
181
+ const [indexStatus, setIndexStatus] = useState<SiteIndexStatus | null>(null)
182
+ const [sources, setSources] = useState<Record<string, SettingsSourceMetadata>>({})
183
+ // Full settings-global blob so a partial save never wipes AI/security keys.
184
+ const [settingsBlob, setSettingsBlob] = useState<Record<string, unknown>>({})
185
+ const [overrideSite, setOverrideSite] = useState<Record<string, any>>({})
186
+ const [saveState, setSaveState] = useState<SaveState>('idle')
187
+ const [saveError, setSaveError] = useState<string | null>(null)
188
+ const [reloadKey, setReloadKey] = useState(0)
189
+
190
+ useEffect(() => {
191
+ let cancelled = false
192
+ setLoading(true)
193
+ setLoadError(null)
194
+ Promise.all([
195
+ cmsApi<Record<string, any>>('/globals/settings'),
196
+ cmsApi<SeoConfigResponse>('/seo/config'),
197
+ cmsApi<SiteIndexStatus>('/seo/index-status'),
198
+ ])
199
+ .then(([settingsRes, seoRes, statusRes]) => {
200
+ if (cancelled) return
201
+ if (settingsRes.error && settingsRes.status !== 404) {
202
+ setLoadError(settingsRes.error)
203
+ }
204
+ const settingsData = settingsRes.data ?? {}
205
+ const seo = seoRes.data ?? {}
206
+ const effectiveSite = seo.effective?.site ?? {}
207
+ const staticSite = seo.static?.site ?? {}
208
+ const overSite = seo.overrides?.site ?? {}
209
+ const robots = effectiveSite.robots ?? {}
210
+
211
+ const loaded: GeneralSettingsForm = {
212
+ siteTitle: String(settingsData.siteTitle ?? ''),
213
+ tagline: String(settingsData.tagline ?? ''),
214
+ siteUrl: String(effectiveSite.siteUrl ?? settingsData.siteUrl ?? ''),
215
+ language: String(settingsData.language ?? 'en'),
216
+ timezone: String(settingsData.timezone ?? 'UTC'),
217
+ defaultNoIndex: robots.defaultNoIndex === true,
218
+ defaultNoFollow: robots.defaultNoFollow === true,
219
+ noIndexNonProduction: robots.noIndexNonProduction === true,
220
+ }
221
+
222
+ // Site URL is config-managed when actuate.config.ts supplies it.
223
+ const siteUrlFromConfig = Boolean(staticSite.siteUrl)
224
+ setSources({
225
+ siteUrl: siteUrlFromConfig
226
+ ? { source: 'config', editable: false, reason: 'Set in actuate.config.ts' }
227
+ : { source: 'database', editable: canEdit },
228
+ siteTitle: { source: 'database', editable: canEdit },
229
+ tagline: { source: 'database', editable: canEdit },
230
+ language: { source: 'database', editable: canEdit },
231
+ timezone: { source: 'database', editable: canEdit },
232
+ seoDefaults: {
233
+ source: 'plugin',
234
+ editable: canEdit,
235
+ reason: 'Shared with the SEO section',
236
+ },
237
+ })
238
+
239
+ setBaseline(loaded)
240
+ setForm(loaded)
241
+ setSettingsBlob(settingsData)
242
+ setOverrideSite(overSite ?? {})
243
+ if (!seoRes.error && statusRes.data) setIndexStatus(statusRes.data)
244
+ })
245
+ .catch(() => {
246
+ if (!cancelled) setLoadError('Failed to load settings.')
247
+ })
248
+ .finally(() => {
249
+ if (!cancelled) setLoading(false)
250
+ })
251
+ return () => {
252
+ cancelled = true
253
+ }
254
+ }, [reloadKey, canEdit])
255
+
256
+ const setField = useCallback(
257
+ <K extends keyof GeneralSettingsForm>(key: K, value: GeneralSettingsForm[K]) => {
258
+ setForm((prev) => ({ ...prev, [key]: value }))
259
+ setSaveState('idle')
260
+ },
261
+ [],
262
+ )
263
+
264
+ const isProduction = (indexStatus?.environment ?? '') === 'production'
265
+
266
+ const dirty = useMemo(
267
+ () =>
268
+ (Object.keys(form) as Array<keyof GeneralSettingsForm>).some((k) => form[k] !== baseline[k]),
269
+ [form, baseline],
270
+ )
271
+
272
+ const issues = useMemo(() => validate(form, isProduction), [form, isProduction])
273
+ const hasErrors = issues.some((i) => i.severity === 'error')
274
+
275
+ const refetchStatus = useCallback(async () => {
276
+ const res = await cmsApi<SiteIndexStatus>('/seo/index-status')
277
+ if (!res.error && res.data) setIndexStatus(res.data)
278
+ }, [])
279
+
280
+ const save = useCallback(async () => {
281
+ if (!canEdit || hasErrors) return
282
+ setSaveState('saving')
283
+ setSaveError(null)
284
+
285
+ const siteUrlEditable = sources.siteUrl?.editable !== false
286
+ const siteInfoChanged =
287
+ form.siteTitle !== baseline.siteTitle ||
288
+ form.tagline !== baseline.tagline ||
289
+ form.language !== baseline.language ||
290
+ form.timezone !== baseline.timezone
291
+ const siteUrlChanged = siteUrlEditable && form.siteUrl !== baseline.siteUrl
292
+ const robotsChanged =
293
+ form.defaultNoIndex !== baseline.defaultNoIndex ||
294
+ form.defaultNoFollow !== baseline.defaultNoFollow ||
295
+ form.noIndexNonProduction !== baseline.noIndexNonProduction
296
+
297
+ try {
298
+ // 1. Site info → the `settings` global (preserve AI/security/layout keys,
299
+ // and drop legacy robots/siteUrl duplicates so SEO config owns them).
300
+ // Read-modify-write against a fresh fetch so a concurrent save from the
301
+ // Security/AI tabs (same global blob) is never clobbered.
302
+ if (siteInfoChanged) {
303
+ const current = await cmsApi<Record<string, unknown>>('/globals/settings')
304
+ const blob = { ...(current.data ?? settingsBlob) }
305
+ delete blob.defaultNoIndex
306
+ delete blob.defaultNoFollow
307
+ delete blob.noIndexNonProduction
308
+ delete blob.siteUrl
309
+ const payload = {
310
+ ...blob,
311
+ siteTitle: form.siteTitle.trim(),
312
+ tagline: form.tagline.trim(),
313
+ language: form.language,
314
+ timezone: form.timezone,
315
+ }
316
+ const res = await cmsApi('/globals/settings', {
317
+ method: 'PUT',
318
+ body: JSON.stringify(payload),
319
+ })
320
+ if (res.error) throw new Error(res.error)
321
+ setSettingsBlob(payload)
322
+ }
323
+
324
+ // 2. Site URL + robots defaults → the SHARED SEO config (merge over the
325
+ // current override site so SEO-tab fields aren't lost).
326
+ if (siteUrlChanged || robotsChanged) {
327
+ const nextSite: Record<string, any> = { ...overrideSite }
328
+ if (siteUrlChanged) nextSite.siteUrl = form.siteUrl.trim() || undefined
329
+ if (robotsChanged) {
330
+ nextSite.robots = {
331
+ ...(overrideSite.robots ?? {}),
332
+ defaultNoIndex: form.defaultNoIndex,
333
+ defaultNoFollow: form.defaultNoFollow,
334
+ noIndexNonProduction: form.noIndexNonProduction,
335
+ }
336
+ }
337
+ const res = await cmsApi('/seo/config', {
338
+ method: 'PUT',
339
+ body: JSON.stringify({ site: nextSite }),
340
+ })
341
+ if (res.error) throw new Error(res.error)
342
+ setOverrideSite(nextSite)
343
+ }
344
+
345
+ setBaseline(form)
346
+ setSaveState('success')
347
+ await refetchStatus()
348
+ } catch (err) {
349
+ setSaveState('error')
350
+ setSaveError(err instanceof Error ? err.message : 'Failed to save settings.')
351
+ }
352
+ }, [canEdit, hasErrors, form, baseline, settingsBlob, overrideSite, sources, refetchStatus])
353
+
354
+ const reset = useCallback(() => {
355
+ setForm(baseline)
356
+ setSaveState('idle')
357
+ setSaveError(null)
358
+ }, [baseline])
359
+
360
+ const refetch = useCallback(() => setReloadKey((k) => k + 1), [])
361
+
362
+ return {
363
+ loading,
364
+ loadError,
365
+ form,
366
+ setField,
367
+ dirty,
368
+ issues,
369
+ hasErrors,
370
+ indexStatus,
371
+ sources,
372
+ isProduction,
373
+ saveState,
374
+ saveError,
375
+ save,
376
+ reset,
377
+ refetch,
378
+ }
379
+ }