@actuate-media/cms-admin 0.9.0 → 0.11.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 (294) hide show
  1. package/dist/AdminRoot.d.ts.map +1 -1
  2. package/dist/AdminRoot.js +8 -5
  3. package/dist/AdminRoot.js.map +1 -1
  4. package/dist/__tests__/layout/primitives.test.d.ts +2 -0
  5. package/dist/__tests__/layout/primitives.test.d.ts.map +1 -0
  6. package/dist/__tests__/layout/primitives.test.js +34 -0
  7. package/dist/__tests__/layout/primitives.test.js.map +1 -0
  8. package/dist/__tests__/lib/cv.test.d.ts +2 -0
  9. package/dist/__tests__/lib/cv.test.d.ts.map +1 -0
  10. package/dist/__tests__/lib/cv.test.js +66 -0
  11. package/dist/__tests__/lib/cv.test.js.map +1 -0
  12. package/dist/actuate-admin.css +1 -1
  13. package/dist/assets/actuate-logo.d.ts +36 -0
  14. package/dist/assets/actuate-logo.d.ts.map +1 -0
  15. package/dist/assets/actuate-logo.js +15 -0
  16. package/dist/assets/actuate-logo.js.map +1 -0
  17. package/dist/components/Breadcrumbs.js +2 -2
  18. package/dist/components/CommandPalette.js +10 -10
  19. package/dist/components/ContentOverviewChart.js +3 -3
  20. package/dist/components/ErrorBoundary.js +1 -1
  21. package/dist/components/FocalPointPicker.js +2 -2
  22. package/dist/components/FolderTree.js +20 -20
  23. package/dist/components/LivePreview.js +3 -3
  24. package/dist/components/LocaleSwitcher.js +1 -1
  25. package/dist/components/MediaPickerModal.js +4 -4
  26. package/dist/components/PresenceIndicator.js +1 -1
  27. package/dist/components/SEOConfigPanel.d.ts +2 -0
  28. package/dist/components/SEOConfigPanel.d.ts.map +1 -0
  29. package/dist/components/SEOConfigPanel.js +174 -0
  30. package/dist/components/SEOConfigPanel.js.map +1 -0
  31. package/dist/components/SEOPanel.js +9 -9
  32. package/dist/components/SEOPerformance.js +2 -2
  33. package/dist/components/SchedulePublishDialog.d.ts +18 -0
  34. package/dist/components/SchedulePublishDialog.d.ts.map +1 -0
  35. package/dist/components/SchedulePublishDialog.js +106 -0
  36. package/dist/components/SchedulePublishDialog.js.map +1 -0
  37. package/dist/components/SharePreviewLinkDialog.d.ts +17 -0
  38. package/dist/components/SharePreviewLinkDialog.d.ts.map +1 -0
  39. package/dist/components/SharePreviewLinkDialog.js +83 -0
  40. package/dist/components/SharePreviewLinkDialog.js.map +1 -0
  41. package/dist/components/TipTapEditor.js +5 -5
  42. package/dist/components/VersionHistory.js +2 -2
  43. package/dist/components/ui/Badge.d.ts +33 -3
  44. package/dist/components/ui/Badge.d.ts.map +1 -1
  45. package/dist/components/ui/Badge.js +42 -8
  46. package/dist/components/ui/Badge.js.map +1 -1
  47. package/dist/components/ui/Button.d.ts +19 -8
  48. package/dist/components/ui/Button.d.ts.map +1 -1
  49. package/dist/components/ui/Button.js +35 -14
  50. package/dist/components/ui/Button.js.map +1 -1
  51. package/dist/components/ui/Card.d.ts +26 -0
  52. package/dist/components/ui/Card.d.ts.map +1 -0
  53. package/dist/components/ui/Card.js +45 -0
  54. package/dist/components/ui/Card.js.map +1 -0
  55. package/dist/components/ui/DataTable.js +1 -1
  56. package/dist/components/ui/Input.d.ts +15 -0
  57. package/dist/components/ui/Input.d.ts.map +1 -0
  58. package/dist/components/ui/Input.js +23 -0
  59. package/dist/components/ui/Input.js.map +1 -0
  60. package/dist/components/ui/SearchInput.js +1 -1
  61. package/dist/components/ui/Select.d.ts +16 -0
  62. package/dist/components/ui/Select.d.ts.map +1 -0
  63. package/dist/components/ui/Select.js +25 -0
  64. package/dist/components/ui/Select.js.map +1 -0
  65. package/dist/components/ui/Toast.js +1 -1
  66. package/dist/components/ui/index.d.ts +10 -4
  67. package/dist/components/ui/index.d.ts.map +1 -1
  68. package/dist/components/ui/index.js +5 -2
  69. package/dist/components/ui/index.js.map +1 -1
  70. package/dist/fields/BlockBuilderField.js +3 -3
  71. package/dist/fields/DateField.js +1 -1
  72. package/dist/fields/RelationshipField.js +3 -3
  73. package/dist/fields/TextField.js +1 -1
  74. package/dist/index.d.ts +6 -0
  75. package/dist/index.d.ts.map +1 -1
  76. package/dist/index.js +5 -0
  77. package/dist/index.js.map +1 -1
  78. package/dist/layout/Header.js +1 -1
  79. package/dist/layout/Layout.d.ts +14 -0
  80. package/dist/layout/Layout.d.ts.map +1 -1
  81. package/dist/layout/Layout.js +17 -11
  82. package/dist/layout/Layout.js.map +1 -1
  83. package/dist/layout/Sidebar.d.ts.map +1 -1
  84. package/dist/layout/Sidebar.js +21 -11
  85. package/dist/layout/Sidebar.js.map +1 -1
  86. package/dist/layout/primitives/AdminShell.d.ts +43 -0
  87. package/dist/layout/primitives/AdminShell.d.ts.map +1 -0
  88. package/dist/layout/primitives/AdminShell.js +51 -0
  89. package/dist/layout/primitives/AdminShell.js.map +1 -0
  90. package/dist/layout/primitives/Box.d.ts +19 -0
  91. package/dist/layout/primitives/Box.d.ts.map +1 -0
  92. package/dist/layout/primitives/Box.js +12 -0
  93. package/dist/layout/primitives/Box.js.map +1 -0
  94. package/dist/layout/primitives/Cluster.d.ts +27 -0
  95. package/dist/layout/primitives/Cluster.d.ts.map +1 -0
  96. package/dist/layout/primitives/Cluster.js +37 -0
  97. package/dist/layout/primitives/Cluster.js.map +1 -0
  98. package/dist/layout/primitives/Grid.d.ts +45 -0
  99. package/dist/layout/primitives/Grid.d.ts.map +1 -0
  100. package/dist/layout/primitives/Grid.js +59 -0
  101. package/dist/layout/primitives/Grid.js.map +1 -0
  102. package/dist/layout/primitives/PageContainer.d.ts +36 -0
  103. package/dist/layout/primitives/PageContainer.d.ts.map +1 -0
  104. package/dist/layout/primitives/PageContainer.js +41 -0
  105. package/dist/layout/primitives/PageContainer.js.map +1 -0
  106. package/dist/layout/primitives/Split.d.ts +34 -0
  107. package/dist/layout/primitives/Split.d.ts.map +1 -0
  108. package/dist/layout/primitives/Split.js +27 -0
  109. package/dist/layout/primitives/Split.js.map +1 -0
  110. package/dist/layout/primitives/Stack.d.ts +23 -0
  111. package/dist/layout/primitives/Stack.d.ts.map +1 -0
  112. package/dist/layout/primitives/Stack.js +34 -0
  113. package/dist/layout/primitives/Stack.js.map +1 -0
  114. package/dist/layout/primitives/index.d.ts +30 -0
  115. package/dist/layout/primitives/index.d.ts.map +1 -0
  116. package/dist/layout/primitives/index.js +22 -0
  117. package/dist/layout/primitives/index.js.map +1 -0
  118. package/dist/layout/primitives/tokens.d.ts +48 -0
  119. package/dist/layout/primitives/tokens.d.ts.map +1 -0
  120. package/dist/layout/primitives/tokens.js +54 -0
  121. package/dist/layout/primitives/tokens.js.map +1 -0
  122. package/dist/lib/cv.d.ts +53 -0
  123. package/dist/lib/cv.d.ts.map +1 -0
  124. package/dist/lib/cv.js +39 -0
  125. package/dist/lib/cv.js.map +1 -0
  126. package/dist/views/ApiKeys.d.ts.map +1 -1
  127. package/dist/views/ApiKeys.js +13 -11
  128. package/dist/views/ApiKeys.js.map +1 -1
  129. package/dist/views/CollectionList.js +8 -8
  130. package/dist/views/Dashboard.d.ts.map +1 -1
  131. package/dist/views/Dashboard.js +333 -78
  132. package/dist/views/Dashboard.js.map +1 -1
  133. package/dist/views/DocumentEdit.d.ts.map +1 -1
  134. package/dist/views/DocumentEdit.js +17 -5
  135. package/dist/views/DocumentEdit.js.map +1 -1
  136. package/dist/views/ForgotPassword.js +2 -2
  137. package/dist/views/FormEditor.js +5 -5
  138. package/dist/views/FormSubmissions.js +6 -6
  139. package/dist/views/Forms.js +2 -2
  140. package/dist/views/Login.d.ts +16 -1
  141. package/dist/views/Login.d.ts.map +1 -1
  142. package/dist/views/Login.js +17 -7
  143. package/dist/views/Login.js.map +1 -1
  144. package/dist/views/MediaBrowser.js +16 -16
  145. package/dist/views/PageEditor.js +2 -2
  146. package/dist/views/Pages.js +10 -10
  147. package/dist/views/PostEditor.js +2 -2
  148. package/dist/views/Posts.js +4 -4
  149. package/dist/views/Redirects.js +4 -4
  150. package/dist/views/ResetPassword.js +2 -2
  151. package/dist/views/SEO.js +6 -6
  152. package/dist/views/ScriptTagEditor.js +4 -4
  153. package/dist/views/ScriptTags.js +2 -2
  154. package/dist/views/Settings.d.ts.map +1 -1
  155. package/dist/views/Settings.js +9 -8
  156. package/dist/views/Settings.js.map +1 -1
  157. package/dist/views/SetupWizard.js +2 -2
  158. package/dist/views/Users.js +4 -4
  159. package/dist/views/page-builder/AIBlockAssist.js +1 -1
  160. package/dist/views/page-builder/AIGenerateDialog.js +10 -10
  161. package/dist/views/page-builder/BlockEditor.js +10 -10
  162. package/dist/views/page-builder/BlockPicker.js +4 -4
  163. package/dist/views/page-builder/BottomBar.js +1 -1
  164. package/dist/views/page-builder/BuilderToolbar.js +2 -2
  165. package/dist/views/page-builder/ContextPanel.js +2 -2
  166. package/dist/views/page-builder/DesignScore.js +9 -9
  167. package/dist/views/page-builder/NodeSettings.js +8 -8
  168. package/dist/views/page-builder/PageBuilder.js +3 -3
  169. package/dist/views/page-builder/PageSettings.js +1 -1
  170. package/dist/views/page-builder/PageTemplates.js +2 -2
  171. package/dist/views/page-builder/SEOPanel.js +13 -13
  172. package/dist/views/page-builder/SavedSections.js +5 -5
  173. package/dist/views/page-builder/TemplatePicker.js +2 -2
  174. package/dist/views/page-builder/block-renderers/CTAPreview.js +5 -5
  175. package/dist/views/page-builder/block-renderers/CardsPreview.js +1 -1
  176. package/dist/views/page-builder/block-renderers/CodePreview.js +1 -1
  177. package/dist/views/page-builder/block-renderers/FAQPreview.js +3 -3
  178. package/dist/views/page-builder/block-renderers/FallbackPreview.js +1 -1
  179. package/dist/views/page-builder/block-renderers/FormPreview.js +3 -3
  180. package/dist/views/page-builder/block-renderers/GalleryPreview.js +5 -5
  181. package/dist/views/page-builder/block-renderers/HeroPreview.js +3 -3
  182. package/dist/views/page-builder/block-renderers/ImagePreview.js +3 -3
  183. package/dist/views/page-builder/block-renderers/TextPreview.js +3 -3
  184. package/dist/views/page-builder/block-renderers/VideoPreview.js +4 -4
  185. package/dist/views/page-builder/canvas/BlockRenderer.js +1 -1
  186. package/dist/views/page-builder/canvas/BuilderCanvas.js +3 -3
  187. package/dist/views/page-builder/canvas/ColumnRenderer.js +2 -2
  188. package/dist/views/page-builder/canvas/ContainerRenderer.js +2 -2
  189. package/dist/views/page-builder/canvas/RowRenderer.js +2 -2
  190. package/dist/views/page-builder/canvas/SectionRenderer.js +2 -2
  191. package/package.json +6 -2
  192. package/src/AdminRoot.tsx +21 -11
  193. package/src/__tests__/layout/primitives.test.ts +37 -0
  194. package/src/__tests__/lib/cv.test.ts +74 -0
  195. package/src/assets/actuate-logo.tsx +72 -0
  196. package/src/components/Breadcrumbs.tsx +6 -6
  197. package/src/components/CommandPalette.tsx +34 -34
  198. package/src/components/ContentOverviewChart.tsx +3 -3
  199. package/src/components/ErrorBoundary.tsx +3 -3
  200. package/src/components/FocalPointPicker.tsx +4 -4
  201. package/src/components/FolderTree.tsx +38 -38
  202. package/src/components/LivePreview.tsx +16 -16
  203. package/src/components/LocaleSwitcher.tsx +7 -7
  204. package/src/components/MediaPickerModal.tsx +21 -21
  205. package/src/components/PresenceIndicator.tsx +2 -2
  206. package/src/components/SEOConfigPanel.tsx +582 -0
  207. package/src/components/SEOPanel.tsx +46 -46
  208. package/src/components/SEOPerformance.tsx +21 -21
  209. package/src/components/SchedulePublishDialog.tsx +241 -0
  210. package/src/components/SharePreviewLinkDialog.tsx +227 -0
  211. package/src/components/TipTapEditor.tsx +33 -33
  212. package/src/components/VersionHistory.tsx +16 -16
  213. package/src/components/ui/Badge.tsx +66 -14
  214. package/src/components/ui/Button.tsx +70 -33
  215. package/src/components/ui/Card.tsx +101 -0
  216. package/src/components/ui/DataTable.tsx +1 -1
  217. package/src/components/ui/Input.tsx +35 -0
  218. package/src/components/ui/SearchInput.tsx +4 -4
  219. package/src/components/ui/Select.tsx +56 -0
  220. package/src/components/ui/Toast.tsx +1 -1
  221. package/src/components/ui/index.ts +18 -4
  222. package/src/fields/BlockBuilderField.tsx +3 -3
  223. package/src/fields/DateField.tsx +1 -1
  224. package/src/fields/RelationshipField.tsx +10 -10
  225. package/src/fields/TextField.tsx +1 -1
  226. package/src/index.ts +32 -0
  227. package/src/layout/Header.tsx +28 -28
  228. package/src/layout/Layout.tsx +39 -46
  229. package/src/layout/Sidebar.tsx +37 -64
  230. package/src/layout/primitives/AdminShell.tsx +118 -0
  231. package/src/layout/primitives/Box.tsx +30 -0
  232. package/src/layout/primitives/Cluster.tsx +74 -0
  233. package/src/layout/primitives/Grid.tsx +120 -0
  234. package/src/layout/primitives/PageContainer.tsx +96 -0
  235. package/src/layout/primitives/Split.tsx +73 -0
  236. package/src/layout/primitives/Stack.tsx +67 -0
  237. package/src/layout/primitives/index.ts +36 -0
  238. package/src/layout/primitives/tokens.ts +76 -0
  239. package/src/lib/cv.ts +96 -0
  240. package/src/styles/build-input.css +1 -1
  241. package/src/views/ApiKeys.tsx +57 -57
  242. package/src/views/CollectionList.tsx +30 -30
  243. package/src/views/Dashboard.tsx +737 -186
  244. package/src/views/DocumentEdit.tsx +90 -10
  245. package/src/views/ForgotPassword.tsx +18 -18
  246. package/src/views/FormEditor.tsx +75 -75
  247. package/src/views/FormSubmissions.tsx +76 -76
  248. package/src/views/Forms.tsx +27 -27
  249. package/src/views/Login.tsx +65 -25
  250. package/src/views/MediaBrowser.tsx +127 -127
  251. package/src/views/PageEditor.tsx +25 -25
  252. package/src/views/Pages.tsx +59 -59
  253. package/src/views/PostEditor.tsx +37 -37
  254. package/src/views/Posts.tsx +48 -48
  255. package/src/views/Redirects.tsx +21 -21
  256. package/src/views/ResetPassword.tsx +28 -28
  257. package/src/views/SEO.tsx +144 -144
  258. package/src/views/ScriptTagEditor.tsx +24 -24
  259. package/src/views/ScriptTags.tsx +10 -10
  260. package/src/views/Settings.tsx +88 -80
  261. package/src/views/SetupWizard.tsx +28 -28
  262. package/src/views/Users.tsx +20 -20
  263. package/src/views/page-builder/AIBlockAssist.tsx +1 -1
  264. package/src/views/page-builder/AIGenerateDialog.tsx +63 -63
  265. package/src/views/page-builder/BlockEditor.tsx +26 -26
  266. package/src/views/page-builder/BlockPicker.tsx +22 -22
  267. package/src/views/page-builder/BottomBar.tsx +8 -8
  268. package/src/views/page-builder/BuilderToolbar.tsx +17 -17
  269. package/src/views/page-builder/ContextPanel.tsx +3 -3
  270. package/src/views/page-builder/DesignScore.tsx +21 -21
  271. package/src/views/page-builder/NodeSettings.tsx +27 -27
  272. package/src/views/page-builder/PageBuilder.tsx +11 -11
  273. package/src/views/page-builder/PageSettings.tsx +4 -4
  274. package/src/views/page-builder/PageTemplates.tsx +18 -18
  275. package/src/views/page-builder/SEOPanel.tsx +53 -53
  276. package/src/views/page-builder/SavedSections.tsx +37 -37
  277. package/src/views/page-builder/TemplatePicker.tsx +17 -17
  278. package/src/views/page-builder/block-renderers/CTAPreview.tsx +13 -13
  279. package/src/views/page-builder/block-renderers/CardsPreview.tsx +5 -5
  280. package/src/views/page-builder/block-renderers/CodePreview.tsx +6 -6
  281. package/src/views/page-builder/block-renderers/FAQPreview.tsx +13 -13
  282. package/src/views/page-builder/block-renderers/FallbackPreview.tsx +3 -3
  283. package/src/views/page-builder/block-renderers/FormPreview.tsx +20 -20
  284. package/src/views/page-builder/block-renderers/GalleryPreview.tsx +8 -8
  285. package/src/views/page-builder/block-renderers/HeroPreview.tsx +16 -16
  286. package/src/views/page-builder/block-renderers/ImagePreview.tsx +4 -4
  287. package/src/views/page-builder/block-renderers/TextPreview.tsx +14 -14
  288. package/src/views/page-builder/block-renderers/VideoPreview.tsx +12 -12
  289. package/src/views/page-builder/canvas/BlockRenderer.tsx +4 -4
  290. package/src/views/page-builder/canvas/BuilderCanvas.tsx +6 -6
  291. package/src/views/page-builder/canvas/ColumnRenderer.tsx +3 -3
  292. package/src/views/page-builder/canvas/ContainerRenderer.tsx +2 -2
  293. package/src/views/page-builder/canvas/RowRenderer.tsx +2 -2
  294. package/src/views/page-builder/canvas/SectionRenderer.tsx +2 -2
@@ -0,0 +1,582 @@
1
+ 'use client'
2
+
3
+ /**
4
+ * Admin UI for editing the site-wide + per-collection SEO defaults that are
5
+ * normally defined in `actuate.config.ts`. The CMS merges code-level config
6
+ * with these DB-stored overrides, so any field left blank here falls back to
7
+ * the static config (shown as the placeholder).
8
+ *
9
+ * Wired in `Settings.tsx` as the "SEO" tab. Talks to `/seo/config` (GET + PUT).
10
+ */
11
+
12
+ import { Loader2, RefreshCw, Save, Globe, FileText } from 'lucide-react'
13
+ import { useEffect, useMemo, useState } from 'react'
14
+ import { toast } from 'sonner'
15
+ import { cmsApi } from '../lib/api.js'
16
+
17
+ interface RobotsSettings {
18
+ noIndex?: boolean
19
+ noFollow?: boolean
20
+ noArchive?: boolean
21
+ noSnippet?: boolean
22
+ }
23
+
24
+ interface SiteSEO {
25
+ siteUrl?: string
26
+ siteName?: string
27
+ defaultOgImage?: string
28
+ twitterHandle?: string
29
+ robots?: { blockAIBots?: boolean; disabled?: boolean }
30
+ sitemap?: {
31
+ disabled?: boolean
32
+ defaultChangeFreq?: string
33
+ defaultPriority?: number
34
+ }
35
+ ogImage?: { disabled?: boolean; theme?: 'light' | 'dark' }
36
+ }
37
+
38
+ interface CollectionSEO {
39
+ archivePath?: string
40
+ defaultSchemaType?: string
41
+ sitemapPriority?: number
42
+ sitemapChangeFreq?: string
43
+ excludeFromSitemap?: boolean
44
+ defaultRobots?: RobotsSettings
45
+ }
46
+
47
+ interface ConfigCollection {
48
+ slug: string
49
+ label: string
50
+ type: 'page' | 'post' | string
51
+ urlPrefix?: string
52
+ staticSeo: CollectionSEO | null
53
+ effectiveSeo: CollectionSEO | null
54
+ }
55
+
56
+ interface ConfigResponse {
57
+ static: { site: SiteSEO | null }
58
+ overrides: {
59
+ site?: Partial<SiteSEO>
60
+ collections?: Record<string, Partial<CollectionSEO>>
61
+ } | null
62
+ effective: { site: SiteSEO | null }
63
+ collections: ConfigCollection[]
64
+ }
65
+
66
+ const SCHEMA_TYPES = [
67
+ '',
68
+ 'WebPage',
69
+ 'Article',
70
+ 'BlogPosting',
71
+ 'NewsArticle',
72
+ 'Product',
73
+ 'Service',
74
+ 'LocalBusiness',
75
+ 'FAQPage',
76
+ 'JobPosting',
77
+ 'Event',
78
+ 'Recipe',
79
+ 'Person',
80
+ 'Organization',
81
+ ]
82
+
83
+ const CHANGE_FREQS = ['', 'always', 'hourly', 'daily', 'weekly', 'monthly', 'yearly', 'never']
84
+
85
+ export function SEOConfigPanel() {
86
+ const [data, setData] = useState<ConfigResponse | null>(null)
87
+ const [loading, setLoading] = useState(true)
88
+ const [saving, setSaving] = useState(false)
89
+ const [error, setError] = useState<string | null>(null)
90
+
91
+ const [site, setSite] = useState<Partial<SiteSEO>>({})
92
+ const [collections, setCollections] = useState<Record<string, Partial<CollectionSEO>>>({})
93
+
94
+ async function load() {
95
+ setLoading(true)
96
+ setError(null)
97
+ const res = await cmsApi<ConfigResponse>('/seo/config', { method: 'GET' })
98
+ if (res.error || !res.data) {
99
+ setError(res.error ?? 'Failed to load SEO config')
100
+ setLoading(false)
101
+ return
102
+ }
103
+ setData(res.data)
104
+ setSite(res.data.overrides?.site ?? {})
105
+ setCollections(res.data.overrides?.collections ?? {})
106
+ setLoading(false)
107
+ }
108
+
109
+ useEffect(() => {
110
+ void load()
111
+ }, [])
112
+
113
+ // Field-level placeholder = the static value from actuate.config.ts. The
114
+ // input value is whatever the admin has typed (or the saved override). Empty
115
+ // input + empty override means "fall back to static", which is the desired
116
+ // UX for an additive overrides panel.
117
+ const staticSite = data?.static?.site ?? {}
118
+
119
+ async function handleSave() {
120
+ setSaving(true)
121
+ // Strip empty strings so they unset back to the static default rather
122
+ // than persisting "" — the store treats "" as "no override".
123
+ const cleanSite = stripEmpty(site)
124
+ const cleanCollections: Record<string, Partial<CollectionSEO>> = {}
125
+ for (const [slug, c] of Object.entries(collections)) {
126
+ const cleaned = stripEmpty(c)
127
+ if (Object.keys(cleaned).length > 0) cleanCollections[slug] = cleaned
128
+ }
129
+ const res = await cmsApi('/seo/config', {
130
+ method: 'PUT',
131
+ body: JSON.stringify({ site: cleanSite, collections: cleanCollections }),
132
+ })
133
+ if (res.error) {
134
+ toast.error(res.error)
135
+ } else {
136
+ toast.success('SEO defaults saved')
137
+ await load()
138
+ }
139
+ setSaving(false)
140
+ }
141
+
142
+ function patchSite<K extends keyof SiteSEO>(key: K, value: SiteSEO[K]) {
143
+ setSite((prev) => ({ ...prev, [key]: value }))
144
+ }
145
+ function patchSiteRobots(key: 'blockAIBots' | 'disabled', value: boolean) {
146
+ setSite((prev) => ({ ...prev, robots: { ...(prev.robots ?? {}), [key]: value } }))
147
+ }
148
+ function patchSiteSitemap<K extends keyof NonNullable<SiteSEO['sitemap']>>(
149
+ key: K,
150
+ value: NonNullable<SiteSEO['sitemap']>[K],
151
+ ) {
152
+ setSite((prev) => ({ ...prev, sitemap: { ...(prev.sitemap ?? {}), [key]: value } }))
153
+ }
154
+ function patchSiteOg<K extends keyof NonNullable<SiteSEO['ogImage']>>(
155
+ key: K,
156
+ value: NonNullable<SiteSEO['ogImage']>[K],
157
+ ) {
158
+ setSite((prev) => ({ ...prev, ogImage: { ...(prev.ogImage ?? {}), [key]: value } }))
159
+ }
160
+ function patchCollection<K extends keyof CollectionSEO>(
161
+ slug: string,
162
+ key: K,
163
+ value: CollectionSEO[K],
164
+ ) {
165
+ setCollections((prev) => ({
166
+ ...prev,
167
+ [slug]: { ...(prev[slug] ?? {}), [key]: value },
168
+ }))
169
+ }
170
+ function patchCollectionRobots(slug: string, key: keyof RobotsSettings, value: boolean) {
171
+ setCollections((prev) => ({
172
+ ...prev,
173
+ [slug]: {
174
+ ...(prev[slug] ?? {}),
175
+ defaultRobots: { ...(prev[slug]?.defaultRobots ?? {}), [key]: value },
176
+ },
177
+ }))
178
+ }
179
+
180
+ const updatedAt = useMemo(() => {
181
+ // `updatedAt` is set by the server when overrides are persisted but isn't
182
+ // part of the declared `SeoConfigOverrides` shape. Cast through `unknown`
183
+ // so we can surface it in the UI without widening the type contract.
184
+ const ts = (data?.overrides as { updatedAt?: string } | undefined)?.updatedAt
185
+ if (!ts) return null
186
+ try {
187
+ return new Date(ts).toLocaleString()
188
+ } catch {
189
+ return ts
190
+ }
191
+ }, [data])
192
+
193
+ if (loading) {
194
+ return (
195
+ <div className="flex items-center gap-2 py-8 text-sm text-gray-600">
196
+ <Loader2 className="h-4 w-4 animate-spin" /> Loading SEO defaults…
197
+ </div>
198
+ )
199
+ }
200
+
201
+ if (error) {
202
+ return (
203
+ <div className="rounded-lg border border-red-200 bg-red-50 p-4 text-sm text-red-700">
204
+ <div className="mb-2 font-medium">Failed to load SEO config</div>
205
+ <div className="font-mono text-xs">{error}</div>
206
+ <button
207
+ onClick={() => void load()}
208
+ className="mt-3 inline-flex items-center gap-1.5 rounded-md border border-red-300 bg-white px-3 py-1.5 text-xs font-medium text-red-700 hover:bg-red-100"
209
+ >
210
+ <RefreshCw className="h-3.5 w-3.5" /> Retry
211
+ </button>
212
+ </div>
213
+ )
214
+ }
215
+
216
+ return (
217
+ <div className="space-y-6">
218
+ <div className="flex items-start justify-between gap-4">
219
+ <div>
220
+ <p className="text-sm text-gray-600">
221
+ Edit the SEO defaults that flow into{' '}
222
+ <code className="rounded bg-gray-100 px-1 py-0.5 text-[11px]">/sitemap.xml</code>,{' '}
223
+ <code className="rounded bg-gray-100 px-1 py-0.5 text-[11px]">/robots.txt</code>,{' '}
224
+ <code className="rounded bg-gray-100 px-1 py-0.5 text-[11px]">/og.png</code>, and the{' '}
225
+ <code className="rounded bg-gray-100 px-1 py-0.5 text-[11px]">/resolve</code> meta +
226
+ JSON-LD response. Empty fields fall back to{' '}
227
+ <code className="rounded bg-gray-100 px-1 py-0.5 text-[11px]">actuate.config.ts</code>.
228
+ </p>
229
+ {updatedAt && <p className="mt-1 text-xs text-gray-500">Last saved: {updatedAt}</p>}
230
+ </div>
231
+ <button
232
+ onClick={handleSave}
233
+ disabled={saving}
234
+ className="inline-flex shrink-0 items-center gap-1.5 rounded-md bg-blue-600 px-3 py-1.5 text-sm font-medium text-white shadow-sm hover:bg-blue-700 disabled:opacity-50"
235
+ >
236
+ {saving ? <Loader2 className="h-4 w-4 animate-spin" /> : <Save className="h-4 w-4" />}
237
+ Save SEO defaults
238
+ </button>
239
+ </div>
240
+
241
+ {/* Site-wide section */}
242
+ <section className="rounded-lg border border-gray-200 bg-white">
243
+ <header className="flex items-center gap-2 border-b border-gray-200 px-4 py-3">
244
+ <Globe className="h-4 w-4 text-blue-600" />
245
+ <h3 className="text-sm font-semibold text-gray-900">Site-wide defaults</h3>
246
+ </header>
247
+ <div className="grid grid-cols-1 gap-4 p-4 md:grid-cols-2">
248
+ <Field
249
+ label="Site URL"
250
+ placeholder={staticSite.siteUrl ?? 'https://example.com'}
251
+ value={site.siteUrl ?? ''}
252
+ onChange={(v) => patchSite('siteUrl', v)}
253
+ hint="Canonical origin used in sitemap URLs and JSON-LD."
254
+ />
255
+ <Field
256
+ label="Site name"
257
+ placeholder={staticSite.siteName ?? 'My Site'}
258
+ value={site.siteName ?? ''}
259
+ onChange={(v) => patchSite('siteName', v)}
260
+ hint="Brand name in OG cards and Schema.org Organization."
261
+ />
262
+ <Field
263
+ label="Default OG image"
264
+ placeholder={staticSite.defaultOgImage ?? '/og-default.png'}
265
+ value={site.defaultOgImage ?? ''}
266
+ onChange={(v) => patchSite('defaultOgImage', v)}
267
+ hint="Used when a document doesn't supply its own."
268
+ />
269
+ <Field
270
+ label="Twitter handle"
271
+ placeholder={staticSite.twitterHandle ?? '@yourbrand'}
272
+ value={site.twitterHandle ?? ''}
273
+ onChange={(v) => patchSite('twitterHandle', v)}
274
+ hint="Including the leading @."
275
+ />
276
+ </div>
277
+
278
+ <div className="grid grid-cols-1 gap-4 border-t border-gray-100 p-4 md:grid-cols-3">
279
+ <Select
280
+ label="Default change frequency"
281
+ value={site.sitemap?.defaultChangeFreq ?? ''}
282
+ options={CHANGE_FREQS}
283
+ onChange={(v) => patchSiteSitemap('defaultChangeFreq', v as any)}
284
+ placeholderLabel={`default: ${staticSite.sitemap?.defaultChangeFreq ?? 'weekly'}`}
285
+ hint="Applied per-URL in sitemap.xml when a collection doesn't override."
286
+ />
287
+ <NumberField
288
+ label="Default priority"
289
+ value={site.sitemap?.defaultPriority}
290
+ placeholder={`default: ${staticSite.sitemap?.defaultPriority ?? 0.6}`}
291
+ onChange={(v) => patchSiteSitemap('defaultPriority', v)}
292
+ min={0}
293
+ max={1}
294
+ step={0.1}
295
+ hint="Between 0.0 and 1.0."
296
+ />
297
+ <Select
298
+ label="OG image theme"
299
+ value={site.ogImage?.theme ?? ''}
300
+ options={['', 'light', 'dark']}
301
+ onChange={(v) => patchSiteOg('theme', (v || undefined) as 'light' | 'dark' | undefined)}
302
+ placeholderLabel={`default: ${staticSite.ogImage?.theme ?? 'light'}`}
303
+ hint="Applied by the built-in /og.png renderer."
304
+ />
305
+ </div>
306
+
307
+ <div className="flex flex-wrap gap-6 border-t border-gray-100 p-4">
308
+ <Toggle
309
+ label="Block known AI bots in robots.txt"
310
+ checked={!!site.robots?.blockAIBots}
311
+ onChange={(v) => patchSiteRobots('blockAIBots', v)}
312
+ hint="GPTBot, ClaudeBot, anthropic-ai, Bytespider, etc."
313
+ />
314
+ <Toggle
315
+ label="Disable robots.txt route"
316
+ checked={!!site.robots?.disabled}
317
+ onChange={(v) => patchSiteRobots('disabled', v)}
318
+ />
319
+ <Toggle
320
+ label="Disable sitemap.xml route"
321
+ checked={!!site.sitemap?.disabled}
322
+ onChange={(v) => patchSiteSitemap('disabled', v)}
323
+ />
324
+ <Toggle
325
+ label="Disable /og.png route"
326
+ checked={!!site.ogImage?.disabled}
327
+ onChange={(v) => patchSiteOg('disabled', v)}
328
+ />
329
+ </div>
330
+ </section>
331
+
332
+ {/* Per-collection section */}
333
+ <section className="rounded-lg border border-gray-200 bg-white">
334
+ <header className="flex items-center gap-2 border-b border-gray-200 px-4 py-3">
335
+ <FileText className="h-4 w-4 text-blue-600" />
336
+ <h3 className="text-sm font-semibold text-gray-900">Per-collection defaults</h3>
337
+ <span className="text-xs text-gray-500">
338
+ — shown placeholders are the static defaults
339
+ </span>
340
+ </header>
341
+ <div className="divide-y divide-gray-100">
342
+ {data?.collections.map((col) => {
343
+ const override = collections[col.slug] ?? {}
344
+ const staticC = col.staticSeo ?? {}
345
+ return (
346
+ <div key={col.slug} className="p-4">
347
+ <div className="mb-3 flex items-baseline justify-between gap-4">
348
+ <div>
349
+ <h4 className="text-sm font-semibold text-gray-900">{col.label}</h4>
350
+ <p className="text-xs text-gray-500">
351
+ <code className="rounded bg-gray-100 px-1 py-0.5">{col.slug}</code>
352
+ {col.urlPrefix !== undefined && (
353
+ <>
354
+ {' '}
355
+ prefix:{' '}
356
+ <code className="rounded bg-gray-100 px-1 py-0.5">/{col.urlPrefix}</code>
357
+ </>
358
+ )}{' '}
359
+ — {col.type}
360
+ </p>
361
+ </div>
362
+ </div>
363
+
364
+ <div className="grid grid-cols-1 gap-3 md:grid-cols-3">
365
+ <Select
366
+ label="Default Schema.org type"
367
+ value={override.defaultSchemaType ?? ''}
368
+ options={SCHEMA_TYPES}
369
+ onChange={(v) => patchCollection(col.slug, 'defaultSchemaType', v || undefined)}
370
+ placeholderLabel={`default: ${staticC.defaultSchemaType ?? 'auto-detect'}`}
371
+ />
372
+ <Field
373
+ label="Archive path"
374
+ placeholder={staticC.archivePath ?? '/blog'}
375
+ value={override.archivePath ?? ''}
376
+ onChange={(v) => patchCollection(col.slug, 'archivePath', v)}
377
+ />
378
+ <Select
379
+ label="Sitemap change frequency"
380
+ value={override.sitemapChangeFreq ?? ''}
381
+ options={CHANGE_FREQS}
382
+ onChange={(v) => patchCollection(col.slug, 'sitemapChangeFreq', v as any)}
383
+ placeholderLabel={`default: ${staticC.sitemapChangeFreq ?? 'weekly'}`}
384
+ />
385
+ <NumberField
386
+ label="Sitemap priority"
387
+ value={override.sitemapPriority}
388
+ placeholder={`default: ${staticC.sitemapPriority ?? (col.type === 'page' ? 0.8 : 0.6)}`}
389
+ onChange={(v) => patchCollection(col.slug, 'sitemapPriority', v)}
390
+ min={0}
391
+ max={1}
392
+ step={0.1}
393
+ />
394
+ </div>
395
+
396
+ <div className="mt-3 flex flex-wrap gap-4">
397
+ <Toggle
398
+ label="Exclude from sitemap"
399
+ checked={!!override.excludeFromSitemap}
400
+ onChange={(v) => patchCollection(col.slug, 'excludeFromSitemap', v)}
401
+ />
402
+ <Toggle
403
+ label="Default: noindex"
404
+ checked={!!override.defaultRobots?.noIndex}
405
+ onChange={(v) => patchCollectionRobots(col.slug, 'noIndex', v)}
406
+ />
407
+ <Toggle
408
+ label="Default: nofollow"
409
+ checked={!!override.defaultRobots?.noFollow}
410
+ onChange={(v) => patchCollectionRobots(col.slug, 'noFollow', v)}
411
+ />
412
+ </div>
413
+ </div>
414
+ )
415
+ })}
416
+ {data?.collections.length === 0 && (
417
+ <div className="p-4 text-sm text-gray-500">
418
+ No collections configured in <code>actuate.config.ts</code>.
419
+ </div>
420
+ )}
421
+ </div>
422
+ </section>
423
+
424
+ <div className="flex justify-end">
425
+ <button
426
+ onClick={handleSave}
427
+ disabled={saving}
428
+ className="inline-flex items-center gap-1.5 rounded-md bg-blue-600 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-blue-700 disabled:opacity-50"
429
+ >
430
+ {saving ? <Loader2 className="h-4 w-4 animate-spin" /> : <Save className="h-4 w-4" />}
431
+ Save SEO defaults
432
+ </button>
433
+ </div>
434
+ </div>
435
+ )
436
+ }
437
+
438
+ function stripEmpty<T extends Record<string, any>>(obj: T): Partial<T> {
439
+ const out: Record<string, any> = {}
440
+ for (const [k, v] of Object.entries(obj)) {
441
+ if (v === undefined || v === null) continue
442
+ if (typeof v === 'string' && v.trim() === '') continue
443
+ if (typeof v === 'object' && !Array.isArray(v)) {
444
+ const nested = stripEmpty(v as Record<string, any>)
445
+ if (Object.keys(nested).length > 0) out[k] = nested
446
+ continue
447
+ }
448
+ out[k] = v
449
+ }
450
+ return out as Partial<T>
451
+ }
452
+
453
+ function Field({
454
+ label,
455
+ value,
456
+ placeholder,
457
+ onChange,
458
+ hint,
459
+ }: {
460
+ label: string
461
+ value: string
462
+ placeholder?: string
463
+ onChange: (v: string) => void
464
+ hint?: string
465
+ }) {
466
+ return (
467
+ <div>
468
+ <label className="mb-1 block text-xs font-medium text-gray-700">{label}</label>
469
+ <input
470
+ type="text"
471
+ value={value}
472
+ placeholder={placeholder}
473
+ onChange={(e) => onChange(e.target.value)}
474
+ className="w-full rounded-md border border-gray-300 px-2.5 py-1.5 text-sm text-gray-900 placeholder-gray-400 focus:border-blue-500 focus:ring-1 focus:ring-blue-500 focus:outline-none"
475
+ />
476
+ {hint && <p className="mt-1 text-[11px] text-gray-500">{hint}</p>}
477
+ </div>
478
+ )
479
+ }
480
+
481
+ function NumberField({
482
+ label,
483
+ value,
484
+ placeholder,
485
+ onChange,
486
+ min,
487
+ max,
488
+ step,
489
+ hint,
490
+ }: {
491
+ label: string
492
+ value: number | undefined
493
+ placeholder?: string
494
+ onChange: (v: number | undefined) => void
495
+ min?: number
496
+ max?: number
497
+ step?: number
498
+ hint?: string
499
+ }) {
500
+ return (
501
+ <div>
502
+ <label className="mb-1 block text-xs font-medium text-gray-700">{label}</label>
503
+ <input
504
+ type="number"
505
+ value={value ?? ''}
506
+ placeholder={placeholder}
507
+ min={min}
508
+ max={max}
509
+ step={step}
510
+ onChange={(e) => {
511
+ const raw = e.target.value
512
+ if (raw === '') return onChange(undefined)
513
+ const n = Number(raw)
514
+ if (Number.isFinite(n)) onChange(n)
515
+ }}
516
+ className="w-full rounded-md border border-gray-300 px-2.5 py-1.5 text-sm text-gray-900 placeholder-gray-400 focus:border-blue-500 focus:ring-1 focus:ring-blue-500 focus:outline-none"
517
+ />
518
+ {hint && <p className="mt-1 text-[11px] text-gray-500">{hint}</p>}
519
+ </div>
520
+ )
521
+ }
522
+
523
+ function Select({
524
+ label,
525
+ value,
526
+ options,
527
+ onChange,
528
+ placeholderLabel,
529
+ hint,
530
+ }: {
531
+ label: string
532
+ value: string
533
+ options: string[]
534
+ onChange: (v: string) => void
535
+ placeholderLabel?: string
536
+ hint?: string
537
+ }) {
538
+ return (
539
+ <div>
540
+ <label className="mb-1 block text-xs font-medium text-gray-700">{label}</label>
541
+ <select
542
+ value={value}
543
+ onChange={(e) => onChange(e.target.value)}
544
+ className="w-full rounded-md border border-gray-300 bg-white px-2.5 py-1.5 text-sm text-gray-900 focus:border-blue-500 focus:ring-1 focus:ring-blue-500 focus:outline-none"
545
+ >
546
+ {options.map((opt) => (
547
+ <option key={opt} value={opt}>
548
+ {opt === '' ? (placeholderLabel ?? '— use default —') : opt}
549
+ </option>
550
+ ))}
551
+ </select>
552
+ {hint && <p className="mt-1 text-[11px] text-gray-500">{hint}</p>}
553
+ </div>
554
+ )
555
+ }
556
+
557
+ function Toggle({
558
+ label,
559
+ checked,
560
+ onChange,
561
+ hint,
562
+ }: {
563
+ label: string
564
+ checked: boolean
565
+ onChange: (v: boolean) => void
566
+ hint?: string
567
+ }) {
568
+ return (
569
+ <label className="flex cursor-pointer items-center gap-2 text-sm text-gray-700">
570
+ <input
571
+ type="checkbox"
572
+ checked={checked}
573
+ onChange={(e) => onChange(e.target.checked)}
574
+ className="h-4 w-4 rounded border-gray-300 text-blue-600 focus:ring-blue-500"
575
+ />
576
+ <span>
577
+ {label}
578
+ {hint && <span className="ml-1 text-[11px] text-gray-500">— {hint}</span>}
579
+ </span>
580
+ </label>
581
+ )
582
+ }