@actuate-media/cms-admin 0.73.0 → 0.75.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 (209) hide show
  1. package/CHANGELOG.md +28 -0
  2. package/dist/AdminRoot.d.ts.map +1 -1
  3. package/dist/AdminRoot.js +3 -2
  4. package/dist/AdminRoot.js.map +1 -1
  5. package/dist/LoginRoot.d.ts.map +1 -1
  6. package/dist/LoginRoot.js +2 -1
  7. package/dist/LoginRoot.js.map +1 -1
  8. package/dist/__tests__/components/theme-provider-accent.test.d.ts +2 -0
  9. package/dist/__tests__/components/theme-provider-accent.test.d.ts.map +1 -0
  10. package/dist/__tests__/components/theme-provider-accent.test.js +64 -0
  11. package/dist/__tests__/components/theme-provider-accent.test.js.map +1 -0
  12. package/dist/__tests__/views/portal-container.test.js +13 -0
  13. package/dist/__tests__/views/portal-container.test.js.map +1 -1
  14. package/dist/__tests__/views/post-fields-modal.render.test.js +3 -6
  15. package/dist/__tests__/views/post-fields-modal.render.test.js.map +1 -1
  16. package/dist/__tests__/views/redirects-pane.render.test.js +6 -0
  17. package/dist/__tests__/views/redirects-pane.render.test.js.map +1 -1
  18. package/dist/__tests__/views/seo-content-pane.render.test.js +15 -4
  19. package/dist/__tests__/views/seo-content-pane.render.test.js.map +1 -1
  20. package/dist/actuate-admin.css +1 -1
  21. package/dist/components/CoauthorResultPopover.js +1 -1
  22. package/dist/components/EditorSeoAside.d.ts +23 -0
  23. package/dist/components/EditorSeoAside.d.ts.map +1 -0
  24. package/dist/components/EditorSeoAside.js +21 -0
  25. package/dist/components/EditorSeoAside.js.map +1 -0
  26. package/dist/components/MediaPickerModal.js +1 -1
  27. package/dist/components/SEOConfigPanel.js +1 -1
  28. package/dist/components/SchedulePublishDialog.js +1 -1
  29. package/dist/components/SharePreviewLinkDialog.js +1 -1
  30. package/dist/components/ThemeProvider.d.ts.map +1 -1
  31. package/dist/components/ThemeProvider.js +2 -1
  32. package/dist/components/ThemeProvider.js.map +1 -1
  33. package/dist/components/VersionHistory.js +1 -1
  34. package/dist/components/seo/EditorSeoScoreBadge.d.ts +7 -0
  35. package/dist/components/seo/EditorSeoScoreBadge.d.ts.map +1 -0
  36. package/dist/components/seo/EditorSeoScoreBadge.js +9 -0
  37. package/dist/components/seo/EditorSeoScoreBadge.js.map +1 -0
  38. package/dist/components/seo/SeoAccordion.d.ts +16 -0
  39. package/dist/components/seo/SeoAccordion.d.ts.map +1 -0
  40. package/dist/components/seo/SeoAccordion.js +21 -0
  41. package/dist/components/seo/SeoAccordion.js.map +1 -0
  42. package/dist/components/seo/SeoAnalysisList.d.ts +7 -0
  43. package/dist/components/seo/SeoAnalysisList.d.ts.map +1 -0
  44. package/dist/components/seo/SeoAnalysisList.js +32 -0
  45. package/dist/components/seo/SeoAnalysisList.js.map +1 -0
  46. package/dist/components/seo/SeoEditorPane.d.ts +15 -4
  47. package/dist/components/seo/SeoEditorPane.d.ts.map +1 -1
  48. package/dist/components/seo/SeoEditorPane.js +155 -35
  49. package/dist/components/seo/SeoEditorPane.js.map +1 -1
  50. package/dist/components/seo/SeoInsightsMetrics.d.ts +6 -0
  51. package/dist/components/seo/SeoInsightsMetrics.d.ts.map +1 -0
  52. package/dist/components/seo/SeoInsightsMetrics.js +16 -0
  53. package/dist/components/seo/SeoInsightsMetrics.js.map +1 -0
  54. package/dist/components/seo/SeoIssueFixPanel.d.ts.map +1 -1
  55. package/dist/components/seo/SeoIssueFixPanel.js +13 -3
  56. package/dist/components/seo/SeoIssueFixPanel.js.map +1 -1
  57. package/dist/components/seo/SeoLinkingSection.d.ts +9 -0
  58. package/dist/components/seo/SeoLinkingSection.d.ts.map +1 -0
  59. package/dist/components/seo/SeoLinkingSection.js +44 -0
  60. package/dist/components/seo/SeoLinkingSection.js.map +1 -0
  61. package/dist/components/seo/primitives.d.ts.map +1 -1
  62. package/dist/components/seo/primitives.js +3 -3
  63. package/dist/components/seo/primitives.js.map +1 -1
  64. package/dist/components/ui/Card.js +5 -5
  65. package/dist/components/ui/Card.js.map +1 -1
  66. package/dist/components/ui/InspectorPane.js +3 -3
  67. package/dist/components/ui/InspectorPane.js.map +1 -1
  68. package/dist/components/ui/PanelSection.d.ts +16 -0
  69. package/dist/components/ui/PanelSection.d.ts.map +1 -0
  70. package/dist/components/ui/PanelSection.js +15 -0
  71. package/dist/components/ui/PanelSection.js.map +1 -0
  72. package/dist/lib/admin-root-element.d.ts +9 -0
  73. package/dist/lib/admin-root-element.d.ts.map +1 -0
  74. package/dist/lib/admin-root-element.js +14 -0
  75. package/dist/lib/admin-root-element.js.map +1 -0
  76. package/dist/lib/editor-seo-content.d.ts +16 -0
  77. package/dist/lib/editor-seo-content.d.ts.map +1 -0
  78. package/dist/lib/editor-seo-content.js +139 -0
  79. package/dist/lib/editor-seo-content.js.map +1 -0
  80. package/dist/lib/portal-container.d.ts.map +1 -1
  81. package/dist/lib/portal-container.js +2 -3
  82. package/dist/lib/portal-container.js.map +1 -1
  83. package/dist/lib/seo-service.d.ts +91 -0
  84. package/dist/lib/seo-service.d.ts.map +1 -1
  85. package/dist/lib/seo-service.js +55 -2
  86. package/dist/lib/seo-service.js.map +1 -1
  87. package/dist/views/ApiKeys.js +2 -2
  88. package/dist/views/Dashboard.d.ts.map +1 -1
  89. package/dist/views/Dashboard.js +12 -14
  90. package/dist/views/Dashboard.js.map +1 -1
  91. package/dist/views/MediaBrowser.d.ts.map +1 -1
  92. package/dist/views/MediaBrowser.js +35 -6
  93. package/dist/views/MediaBrowser.js.map +1 -1
  94. package/dist/views/PageEditor.js +1 -1
  95. package/dist/views/Posts/PostTypeEditor.js +3 -3
  96. package/dist/views/SEO.d.ts.map +1 -1
  97. package/dist/views/SEO.js +4 -2
  98. package/dist/views/SEO.js.map +1 -1
  99. package/dist/views/ScriptTags.js +1 -1
  100. package/dist/views/ScriptTags.js.map +1 -1
  101. package/dist/views/Users.js +1 -1
  102. package/dist/views/page-editor/EditorTopBar.d.ts +6 -1
  103. package/dist/views/page-editor/EditorTopBar.d.ts.map +1 -1
  104. package/dist/views/page-editor/EditorTopBar.js +5 -2
  105. package/dist/views/page-editor/EditorTopBar.js.map +1 -1
  106. package/dist/views/page-editor/PageSectionEditor.d.ts.map +1 -1
  107. package/dist/views/page-editor/PageSectionEditor.js +36 -1
  108. package/dist/views/page-editor/PageSectionEditor.js.map +1 -1
  109. package/dist/views/page-editor/PageSettingsModal.d.ts +1 -1
  110. package/dist/views/page-editor/PageSettingsModal.d.ts.map +1 -1
  111. package/dist/views/page-editor/PageSettingsModal.js +2 -13
  112. package/dist/views/page-editor/PageSettingsModal.js.map +1 -1
  113. package/dist/views/page-editor/SectionInspector.js +2 -2
  114. package/dist/views/page-editor/SectionsPanel.js +1 -1
  115. package/dist/views/post-editor/PostFieldsModal.d.ts +1 -1
  116. package/dist/views/post-editor/PostFieldsModal.d.ts.map +1 -1
  117. package/dist/views/post-editor/PostFieldsModal.js +17 -79
  118. package/dist/views/post-editor/PostFieldsModal.js.map +1 -1
  119. package/dist/views/post-editor/PostHeaderPanel.js +1 -1
  120. package/dist/views/post-editor/PostSectionEditor.d.ts.map +1 -1
  121. package/dist/views/post-editor/PostSectionEditor.js +37 -1
  122. package/dist/views/post-editor/PostSectionEditor.js.map +1 -1
  123. package/dist/views/seo/ContentTab.d.ts.map +1 -1
  124. package/dist/views/seo/ContentTab.js +7 -1
  125. package/dist/views/seo/ContentTab.js.map +1 -1
  126. package/dist/views/seo/LinksTab.d.ts +4 -0
  127. package/dist/views/seo/LinksTab.d.ts.map +1 -0
  128. package/dist/views/seo/LinksTab.js +111 -0
  129. package/dist/views/seo/LinksTab.js.map +1 -0
  130. package/dist/views/seo/OverviewTab.js +1 -1
  131. package/dist/views/seo/OverviewTab.js.map +1 -1
  132. package/dist/views/seo/RedirectsTab.d.ts.map +1 -1
  133. package/dist/views/seo/RedirectsTab.js +39 -6
  134. package/dist/views/seo/RedirectsTab.js.map +1 -1
  135. package/dist/views/seo/TechnicalTab.js +2 -2
  136. package/dist/views/settings/AISettingsTab.js +1 -1
  137. package/dist/views/settings/AiFeaturesCard.js +1 -1
  138. package/dist/views/settings/AiFeaturesCard.js.map +1 -1
  139. package/dist/views/settings/AppearanceTab.js +1 -1
  140. package/dist/views/settings/BrandVoiceCard.js +1 -1
  141. package/dist/views/settings/EmailSettingsTab.js +2 -2
  142. package/dist/views/settings/GeneralSettingsTab.js +1 -1
  143. package/dist/views/settings/SecurityStatusCard.js +1 -1
  144. package/dist/views/settings/SeoSettingsTab.js +4 -4
  145. package/dist/views/settings/UpdatesTab.js +2 -2
  146. package/dist/views/settings/components.js +3 -3
  147. package/dist/views/settings/components.js.map +1 -1
  148. package/package.json +2 -2
  149. package/src/AdminRoot.tsx +3 -2
  150. package/src/LoginRoot.tsx +2 -1
  151. package/src/__tests__/components/theme-provider-accent.test.tsx +84 -0
  152. package/src/__tests__/views/portal-container.test.tsx +14 -0
  153. package/src/__tests__/views/post-fields-modal.render.test.tsx +3 -9
  154. package/src/__tests__/views/redirects-pane.render.test.tsx +6 -0
  155. package/src/__tests__/views/seo-content-pane.render.test.tsx +16 -4
  156. package/src/components/CoauthorResultPopover.tsx +1 -1
  157. package/src/components/EditorSeoAside.tsx +68 -0
  158. package/src/components/MediaPickerModal.tsx +1 -1
  159. package/src/components/SEOConfigPanel.tsx +2 -2
  160. package/src/components/SchedulePublishDialog.tsx +1 -1
  161. package/src/components/SharePreviewLinkDialog.tsx +1 -1
  162. package/src/components/ThemeProvider.tsx +3 -1
  163. package/src/components/VersionHistory.tsx +1 -1
  164. package/src/components/seo/EditorSeoScoreBadge.tsx +26 -0
  165. package/src/components/seo/SeoAccordion.tsx +76 -0
  166. package/src/components/seo/SeoAnalysisList.tsx +113 -0
  167. package/src/components/seo/SeoEditorPane.tsx +532 -250
  168. package/src/components/seo/SeoInsightsMetrics.tsx +109 -0
  169. package/src/components/seo/SeoIssueFixPanel.tsx +16 -4
  170. package/src/components/seo/SeoLinkingSection.tsx +151 -0
  171. package/src/components/seo/primitives.tsx +7 -3
  172. package/src/components/ui/Card.tsx +5 -5
  173. package/src/components/ui/InspectorPane.tsx +3 -3
  174. package/src/components/ui/PanelSection.tsx +53 -0
  175. package/src/lib/admin-root-element.ts +15 -0
  176. package/src/lib/editor-seo-content.ts +144 -0
  177. package/src/lib/portal-container.ts +3 -2
  178. package/src/lib/seo-service.ts +150 -2
  179. package/src/views/ApiKeys.tsx +2 -2
  180. package/src/views/Dashboard.tsx +40 -53
  181. package/src/views/MediaBrowser.tsx +62 -12
  182. package/src/views/PageEditor.tsx +1 -1
  183. package/src/views/Posts/PostTypeEditor.tsx +3 -3
  184. package/src/views/SEO.tsx +6 -0
  185. package/src/views/ScriptTags.tsx +2 -2
  186. package/src/views/Users.tsx +1 -1
  187. package/src/views/page-editor/EditorTopBar.tsx +27 -0
  188. package/src/views/page-editor/PageSectionEditor.tsx +58 -3
  189. package/src/views/page-editor/PageSettingsModal.tsx +1 -45
  190. package/src/views/page-editor/SectionInspector.tsx +2 -2
  191. package/src/views/page-editor/SectionsPanel.tsx +1 -1
  192. package/src/views/post-editor/PostFieldsModal.tsx +19 -138
  193. package/src/views/post-editor/PostHeaderPanel.tsx +1 -1
  194. package/src/views/post-editor/PostSectionEditor.tsx +60 -3
  195. package/src/views/seo/ContentTab.tsx +11 -5
  196. package/src/views/seo/LinksTab.tsx +462 -0
  197. package/src/views/seo/OverviewTab.tsx +1 -1
  198. package/src/views/seo/RedirectsTab.tsx +97 -4
  199. package/src/views/seo/TechnicalTab.tsx +2 -2
  200. package/src/views/settings/AISettingsTab.tsx +3 -3
  201. package/src/views/settings/AiFeaturesCard.tsx +1 -1
  202. package/src/views/settings/AppearanceTab.tsx +1 -1
  203. package/src/views/settings/BrandVoiceCard.tsx +2 -2
  204. package/src/views/settings/EmailSettingsTab.tsx +2 -2
  205. package/src/views/settings/GeneralSettingsTab.tsx +3 -3
  206. package/src/views/settings/SecurityStatusCard.tsx +1 -1
  207. package/src/views/settings/SeoSettingsTab.tsx +6 -6
  208. package/src/views/settings/UpdatesTab.tsx +3 -3
  209. package/src/views/settings/components.tsx +3 -3
@@ -9,6 +9,7 @@
9
9
  * `packages/cms-core/src/api/handlers.ts` across phases 2-3 and 6.
10
10
  */
11
11
  import type { BrandAlignmentScoreResult } from '@actuate-media/cms-core/brand-voice'
12
+ import type { SEOAnalysisResult } from '@actuate-media/cms-core/seo/analysis'
12
13
  import { cmsApi } from './api.js'
13
14
 
14
15
  /**
@@ -163,7 +164,12 @@ export interface SeoContentRecord {
163
164
  title: string
164
165
  url: string
165
166
  canonicalUrl: string | null
167
+ /** Rendered public title (after site title template). */
166
168
  metaTitle: string | null
169
+ /** Stored page-specific portion for the `%page%` token. */
170
+ metaTitlePage?: string | null
171
+ siteName?: string | null
172
+ titleTemplate?: string | null
167
173
  metaDescription: string | null
168
174
  focusKeyword: string | null
169
175
  keyTakeaway: string | null
@@ -234,6 +240,12 @@ export interface SeoFieldPayload {
234
240
  llmsOptional?: boolean
235
241
  }
236
242
 
243
+ export async function fetchDocumentSeoAnalysis(documentId: string): Promise<SEOAnalysisResult> {
244
+ const res = await cmsApi<SEOAnalysisResult>(`/seo/analysis/${encodeURIComponent(documentId)}`)
245
+ throwIfError(res)
246
+ return res.data ?? { score: 0, checks: [] }
247
+ }
248
+
237
249
  export async function updateSeoFields(
238
250
  entityType: string,
239
251
  entityId: string,
@@ -368,6 +380,7 @@ export interface SeoFixFieldChange {
368
380
  export interface SeoIssueFixSuggestion {
369
381
  autoFixable: boolean
370
382
  source?: 'deterministic' | 'ai'
383
+ fixStrategy?: 'redirect' | 'fix-link'
371
384
  aiUnavailable?: boolean
372
385
  fingerprint: string
373
386
  patch: Record<string, unknown>
@@ -447,6 +460,7 @@ export const SEO_INLINE_FIX_ISSUE_TYPES = new Set([
447
460
  'duplicate-meta-title',
448
461
  'duplicate-meta-description',
449
462
  'missing-structured-data',
463
+ 'broken-internal-link',
450
464
  ])
451
465
 
452
466
  export function isInlineSeoFixIssue(type: string): boolean {
@@ -890,11 +904,39 @@ export async function fetchRedirectSuggestions(): Promise<RedirectSuggestion[]>
890
904
  return res.data?.suggestions ?? []
891
905
  }
892
906
 
893
- export async function generate404RecoverySuggestions(): Promise<{ error?: string }> {
894
- const res = await cmsApi('/seo/redirects/suggest', { method: 'POST' })
907
+ export interface RedirectRecoverySettings {
908
+ aiRerank: boolean
909
+ autoApply: boolean
910
+ autoApplyMinConfidence: number
911
+ }
912
+
913
+ export async function fetchRedirectRecoverySettings(): Promise<RedirectRecoverySettings | null> {
914
+ const res = await cmsApi<RedirectRecoverySettings>('/seo/redirects/recovery-settings')
915
+ throwIfError(res)
916
+ return res.data ?? null
917
+ }
918
+
919
+ export async function saveRedirectRecoverySettings(
920
+ settings: Partial<RedirectRecoverySettings>,
921
+ ): Promise<{ error?: string }> {
922
+ const res = await cmsApi<RedirectRecoverySettings>('/seo/redirects/recovery-settings', {
923
+ method: 'PUT',
924
+ body: JSON.stringify(settings),
925
+ })
895
926
  return res.error ? { error: res.error } : {}
896
927
  }
897
928
 
929
+ export async function generate404RecoverySuggestions(): Promise<{
930
+ error?: string
931
+ autoApplied?: number
932
+ }> {
933
+ const res = await cmsApi<{ created?: number; autoApplied?: number }>('/seo/redirects/suggest', {
934
+ method: 'POST',
935
+ })
936
+ if (res.error) return { error: res.error }
937
+ return { autoApplied: res.data?.autoApplied ?? 0 }
938
+ }
939
+
898
940
  export async function acceptRedirectSuggestion(suggestionId: string): Promise<{ error?: string }> {
899
941
  const res = await cmsApi(
900
942
  `/seo/redirects/suggestions/${encodeURIComponent(suggestionId)}/accept`,
@@ -995,3 +1037,109 @@ export async function summarizeSeoAudit(): Promise<SeoAiResult> {
995
1037
  if (isAiUnavailable(res.status)) return { unavailable: true }
996
1038
  return res.data ?? {}
997
1039
  }
1040
+
1041
+ // ─── Link graph & AI suggestions ───────────────────────────────────────────
1042
+
1043
+ export interface LinkGraphEntityRef {
1044
+ id: string
1045
+ title: string
1046
+ url: string
1047
+ path: string
1048
+ collection: string
1049
+ entityType: 'page' | 'post'
1050
+ isHome: boolean
1051
+ }
1052
+
1053
+ export interface LinkGraphSnapshot {
1054
+ scannedAt: string
1055
+ postsCrawled: number
1056
+ internalLinks: number
1057
+ externalLinks: number
1058
+ internalRatio: number
1059
+ orphanCount: number
1060
+ orphaned: LinkGraphEntityRef[]
1061
+ linkCoveragePercent: number
1062
+ underLinked: Array<LinkGraphEntityRef & { outboundInternal: number }>
1063
+ brokenLinkCount: number
1064
+ aiIndexLastRunAt: string | null
1065
+ aiIndexReady: boolean
1066
+ }
1067
+
1068
+ export interface LinkHealthIssue {
1069
+ id: string
1070
+ page: string
1071
+ url: string
1072
+ status: number
1073
+ type: string
1074
+ }
1075
+
1076
+ export interface LinkSuggestion {
1077
+ sourceEntityId: string
1078
+ sourceTitle: string
1079
+ sourceUrl: string
1080
+ anchorText: string
1081
+ confidence: number
1082
+ excerpt: string
1083
+ }
1084
+
1085
+ export async function fetchLinkGraphSnapshot(): Promise<LinkGraphSnapshot> {
1086
+ const res = await cmsApi<LinkGraphSnapshot>('/seo/links/snapshot')
1087
+ throwIfError(res)
1088
+ if (!res.data) throw new Error('No link snapshot data')
1089
+ return res.data
1090
+ }
1091
+
1092
+ export async function runLinkAiIndex(): Promise<{
1093
+ data?: {
1094
+ indexedAt: string
1095
+ entitiesIndexed: number
1096
+ aiAvailable: boolean
1097
+ message: string
1098
+ }
1099
+ error?: string
1100
+ }> {
1101
+ const res = await cmsApi<{
1102
+ indexedAt: string
1103
+ entitiesIndexed: number
1104
+ aiAvailable: boolean
1105
+ message: string
1106
+ }>('/seo/links/ai-index', { method: 'POST' })
1107
+ if (res.error) return { error: res.error }
1108
+ return { data: res.data }
1109
+ }
1110
+
1111
+ export async function fetchLinkSuggestions(input: {
1112
+ targetEntityId: string
1113
+ targetEntityType: 'page' | 'post'
1114
+ limit?: number
1115
+ }): Promise<{
1116
+ data?: {
1117
+ target: { id: string; title: string; url: string }
1118
+ suggestions: LinkSuggestion[]
1119
+ }
1120
+ error?: string
1121
+ }> {
1122
+ const res = await cmsApi<{
1123
+ target: { id: string; title: string; url: string }
1124
+ suggestions: LinkSuggestion[]
1125
+ }>('/seo/links/suggest', { method: 'POST', body: JSON.stringify(input) })
1126
+ if (res.error) return { error: res.error }
1127
+ return { data: res.data }
1128
+ }
1129
+
1130
+ export async function fetchLinkHealthIssues(): Promise<{
1131
+ data?: { issues: LinkHealthIssue[]; checked: number; truncated: boolean }
1132
+ error?: string
1133
+ }> {
1134
+ const res = await cmsApi<{ truncated: boolean; checked: number; issues: LinkHealthIssue[] }>(
1135
+ '/seo/link-health',
1136
+ )
1137
+ if (res.error) return { error: res.error }
1138
+ return {
1139
+ data: {
1140
+ issues: res.data?.issues ?? [],
1141
+ checked: res.data?.checked ?? 0,
1142
+ truncated: res.data?.truncated ?? false,
1143
+ },
1144
+ }
1145
+ }
@@ -134,7 +134,7 @@ export function ApiKeys({ embedded = false }: ApiKeysProps = {}) {
134
134
  {error && (
135
135
  <div
136
136
  role="alert"
137
- className="border-destructive/30 bg-destructive/10 text-destructive mb-4 flex items-center gap-3 rounded-lg border p-3 text-sm"
137
+ className="border-destructive/30 bg-destructive/10 text-destructive mb-4 flex items-center gap-3 rounded-lg border p-4 text-sm"
138
138
  >
139
139
  <AlertTriangle size={20} className="shrink-0" aria-hidden="true" />
140
140
  <span className="flex-1">{error}</span>
@@ -506,7 +506,7 @@ function RevealKeyDialog({
506
506
  Copy this key now — it will not be shown again. Treat it like a password.
507
507
  </Dialog.Description>
508
508
 
509
- <div className="border-warning/30 bg-warning/10 mb-4 flex items-start gap-2 rounded-lg border p-3">
509
+ <div className="border-warning/30 bg-warning/10 mb-4 flex items-start gap-2 rounded-lg border p-4">
510
510
  <AlertTriangle className="text-warning mt-0.5 h-4 w-4 shrink-0" aria-hidden="true" />
511
511
  <p className="text-foreground text-xs">
512
512
  We only store a hash of this key. If you lose it, you&apos;ll need to revoke and
@@ -42,6 +42,7 @@ import {
42
42
  import { useApiData } from '../lib/useApiData.js'
43
43
  import { editPathForDoc } from '../lib/collection-routes.js'
44
44
  import { authorAvatar } from '../components/ui/AuthorAvatar.js'
45
+ import { PanelSection, PANEL_LIST_ROW_CLASS } from '../components/ui/PanelSection.js'
45
46
  import { OnboardingBlock, shouldShowOnboarding } from './dashboard/OnboardingBlock.js'
46
47
  import type { OnboardingSignals } from './dashboard/OnboardingBlock.js'
47
48
 
@@ -542,7 +543,7 @@ export function Dashboard({ config, session, onNavigate }: DashboardProps) {
542
543
  <div className="w-full space-y-5 p-4 sm:p-6 lg:px-8">
543
544
  {/* Health banners ────────────────────────────────────────────── */}
544
545
  {health && health.status === 'degraded' && (
545
- <div className="border-info/30 bg-info/10 flex items-start gap-3 rounded-lg border p-3">
546
+ <div className="border-info/30 bg-info/10 flex items-start gap-3 rounded-lg border p-4">
546
547
  <Database className="text-info mt-0.5 h-5 w-5 shrink-0" />
547
548
  <div className="min-w-0 flex-1">
548
549
  <span className="text-info text-sm font-medium">Database setup required</span>
@@ -561,7 +562,7 @@ export function Dashboard({ config, session, onNavigate }: DashboardProps) {
561
562
  )}
562
563
 
563
564
  {error && exhausted && (
564
- <div className="border-warning/30 bg-warning/10 flex items-center gap-3 rounded-lg border p-3">
565
+ <div className="border-warning/30 bg-warning/10 flex items-center gap-3 rounded-lg border p-4">
565
566
  <AlertTriangle className="text-warning h-5 w-5 shrink-0" />
566
567
  <span className="text-warning flex-1 text-sm">
567
568
  Some dashboard data may be unavailable.
@@ -685,23 +686,20 @@ export function Dashboard({ config, session, onNavigate }: DashboardProps) {
685
686
  ) : (
686
687
  <div className="grid grid-cols-1 gap-4 lg:grid-cols-[minmax(0,1fr)_320px]">
687
688
  {/* Recent Activity ───────────────────────────────────────── */}
688
- <section className="bg-card border-border overflow-hidden rounded-xl border shadow-sm">
689
- <header className="border-border flex items-center justify-between border-b px-4 py-3">
690
- <div className="min-w-0">
691
- <h2 className="text-foreground text-sm font-medium">Recent Activity</h2>
692
- <p className="text-muted-foreground mt-0.5 text-xs">
693
- Last 7 days across all content
694
- </p>
695
- </div>
696
- {(stats?.recentDocuments?.length ?? 0) > 8 && (
689
+ <PanelSection
690
+ title="Recent Activity"
691
+ description="Last 7 days across all content"
692
+ action={
693
+ (stats?.recentDocuments?.length ?? 0) > 8 ? (
697
694
  <button
698
695
  className="text-brand text-xs font-medium hover:underline"
699
696
  onClick={() => setActivityLimit((n) => (n >= 20 ? 8 : 20))}
700
697
  >
701
698
  {activityLimit >= 20 ? 'Show less' : 'Show more'}
702
699
  </button>
703
- )}
704
- </header>
700
+ ) : undefined
701
+ }
702
+ >
705
703
  {activity.length === 0 ? (
706
704
  <EmptyState
707
705
  icon={Activity}
@@ -715,7 +713,7 @@ export function Dashboard({ config, session, onNavigate }: DashboardProps) {
715
713
  <button
716
714
  type="button"
717
715
  onClick={() => nav(it.editPath)}
718
- className="hover:bg-accent/50 focus-visible:bg-accent/50 w-full cursor-pointer px-4 py-3 text-left transition-colors"
716
+ className={PANEL_LIST_ROW_CLASS}
719
717
  >
720
718
  <div className="flex min-w-0 items-start gap-3">
721
719
  <div
@@ -749,33 +747,27 @@ export function Dashboard({ config, session, onNavigate }: DashboardProps) {
749
747
  ))}
750
748
  </ul>
751
749
  )}
752
- </section>
750
+ </PanelSection>
753
751
 
754
752
  {/* Right column ─────────────────────────────────────────── */}
755
753
  <aside className="flex min-w-0 flex-col gap-4">
756
754
  {/* Publishing Queue */}
757
- <section className="bg-card border-border overflow-hidden rounded-xl border shadow-sm">
758
- <header className="border-border flex items-center justify-between border-b px-4 py-3">
759
- <div className="min-w-0">
760
- <h2 className="text-foreground text-sm font-medium">Publishing Queue</h2>
761
- <p className="text-muted-foreground mt-0.5 text-xs">
762
- {publishQueue.length} item{publishQueue.length === 1 ? '' : 's'} scheduled
763
- </p>
764
- </div>
755
+ <PanelSection
756
+ title="Publishing Queue"
757
+ description={`${publishQueue.length} item${publishQueue.length === 1 ? '' : 's'} scheduled`}
758
+ action={
765
759
  <button
766
760
  className="text-brand text-xs font-medium hover:underline disabled:cursor-default disabled:no-underline disabled:opacity-50"
767
761
  disabled={publishQueue.length === 0}
768
762
  onClick={() => {
769
- // We don't have a dedicated "scheduled" admin page, so deep-
770
- // link to the first scheduled item — that's where authors
771
- // typically need to land to reschedule or cancel.
772
763
  const first = publishQueue[0]
773
764
  if (first) nav(first.editPath)
774
765
  }}
775
766
  >
776
767
  Manage
777
768
  </button>
778
- </header>
769
+ }
770
+ >
779
771
  {publishQueue.length === 0 ? (
780
772
  <EmptyState
781
773
  icon={Clock}
@@ -790,7 +782,7 @@ export function Dashboard({ config, session, onNavigate }: DashboardProps) {
790
782
  <button
791
783
  type="button"
792
784
  onClick={() => nav(q.editPath)}
793
- className="hover:bg-accent/50 focus-visible:bg-accent/50 flex w-full min-w-0 cursor-pointer items-center gap-2.5 px-4 py-2.5 text-left transition-colors"
785
+ className={`${PANEL_LIST_ROW_CLASS} flex min-w-0 cursor-pointer items-center gap-2.5`}
794
786
  >
795
787
  <span className="bg-warning h-2 w-2 shrink-0 rounded-full" aria-hidden />
796
788
  <div className="min-w-0 flex-1">
@@ -807,23 +799,21 @@ export function Dashboard({ config, session, onNavigate }: DashboardProps) {
807
799
  ))}
808
800
  </ul>
809
801
  )}
810
- </section>
802
+ </PanelSection>
811
803
 
812
- {/* Content Health */}
813
- <section className="bg-card border-border overflow-hidden rounded-xl border shadow-sm">
814
- <header className="border-border flex items-center justify-between border-b px-4 py-3">
815
- <div className="min-w-0">
816
- <h2 className="text-foreground text-sm font-medium">Content Health</h2>
817
- <p className="text-muted-foreground mt-0.5 text-xs">SEO &amp; quality issues</p>
818
- </div>
804
+ <PanelSection
805
+ title="Content Health"
806
+ description="SEO & quality issues"
807
+ action={
819
808
  <button
820
809
  className="text-brand text-xs font-medium hover:underline"
821
810
  onClick={() => nav('/seo')}
822
811
  >
823
812
  Fix issues
824
813
  </button>
825
- </header>
826
- <div className="px-4 pt-3.5 pb-2.5">
814
+ }
815
+ >
816
+ <div className="px-6 pt-4 pb-2.5">
827
817
  <div className="mb-1.5 flex items-baseline justify-between">
828
818
  <p className="text-foreground text-2xl leading-none font-medium tracking-tight">
829
819
  {contentHealth.score > 0 ? contentHealth.score : '—'}
@@ -861,7 +851,7 @@ export function Dashboard({ config, session, onNavigate }: DashboardProps) {
861
851
  </div>
862
852
  </div>
863
853
  {contentHealth.issues.length === 0 ? (
864
- <div className="text-muted-foreground px-4 pt-1 pb-4 text-xs">
854
+ <div className="text-muted-foreground px-6 pt-1 pb-4 text-xs">
865
855
  No outstanding issues. Nice work.
866
856
  </div>
867
857
  ) : (
@@ -871,7 +861,7 @@ export function Dashboard({ config, session, onNavigate }: DashboardProps) {
871
861
  <button
872
862
  type="button"
873
863
  onClick={() => nav('/seo')}
874
- className="hover:bg-accent/50 focus-visible:bg-accent/50 flex w-full items-center gap-2.5 px-4 py-2 text-left transition-colors"
864
+ className={`${PANEL_LIST_ROW_CLASS} flex items-center gap-2.5 py-3.5`}
875
865
  >
876
866
  <span
877
867
  className={`h-1.5 w-1.5 shrink-0 rounded-full ${
@@ -903,7 +893,7 @@ export function Dashboard({ config, session, onNavigate }: DashboardProps) {
903
893
  ))}
904
894
  </ul>
905
895
  )}
906
- </section>
896
+ </PanelSection>
907
897
  </aside>
908
898
  </div>
909
899
  )}
@@ -916,23 +906,20 @@ export function Dashboard({ config, session, onNavigate }: DashboardProps) {
916
906
  collected enough data yet" from "we have data and it shows
917
907
  0" so editors don't think the dashboard is broken on a
918
908
  quiet weekend. */}
919
- <section className="bg-card border-border overflow-hidden rounded-xl border shadow-sm">
920
- <header className="border-border flex items-center justify-between border-b px-4 py-3">
921
- <div className="min-w-0">
922
- <h2 className="text-foreground text-sm font-medium">Delivery API</h2>
923
- <p className="text-muted-foreground mt-0.5 text-xs">
924
- Headless content delivery · last 24 hours
925
- </p>
926
- </div>
909
+ <PanelSection
910
+ title="Delivery API"
911
+ description="Headless content delivery · last 24 hours"
912
+ action={
927
913
  <button
928
914
  className="text-brand inline-flex items-center gap-1 text-xs font-medium hover:underline"
929
915
  onClick={() => nav('/settings?tab=api-keys')}
930
916
  >
931
917
  API docs <ExternalLink className="h-3 w-3" />
932
918
  </button>
933
- </header>
919
+ }
920
+ >
934
921
  <DeliveryGrid delivery={delivery} loading={deliveryLoading} />
935
- </section>
922
+ </PanelSection>
936
923
  </div>
937
924
  )
938
925
  }
@@ -947,7 +934,7 @@ function DeliveryGrid({ delivery, loading }: { delivery: DeliveryStats | null; l
947
934
  aria-busy
948
935
  >
949
936
  {[0, 1, 2, 3].map((i) => (
950
- <div key={i} className="px-4 py-3.5">
937
+ <div key={i} className="px-6 py-4">
951
938
  <div className="bg-muted mb-2 h-3 w-24 rounded" />
952
939
  <div className="bg-muted mb-1.5 h-7 w-20 rounded" />
953
940
  <div className="bg-muted h-3 w-28 rounded" />
@@ -1087,7 +1074,7 @@ function DeliveryTile({
1087
1074
  ? 'text-destructive dark:text-red-400'
1088
1075
  : 'text-muted-foreground'
1089
1076
  return (
1090
- <div className="px-4 py-3.5">
1077
+ <div className="px-6 py-4">
1091
1078
  <div className="text-muted-foreground mb-1 flex items-center gap-1.5">
1092
1079
  <Icon className="h-3.5 w-3.5" aria-hidden />
1093
1080
  <span className="text-[11px] font-medium">{label}</span>
@@ -47,6 +47,8 @@ interface MediaItem {
47
47
  dimensions?: string
48
48
  format?: string
49
49
  altTag?: string
50
+ /** Pending AI alt suggestion (`aiAltText` column) awaiting editor approval. */
51
+ aiAltSuggestion?: string
50
52
  title?: string
51
53
  caption?: string
52
54
  description?: string
@@ -321,10 +323,13 @@ export function MediaBrowser({ onNavigate, currentPath }: MediaBrowserProps) {
321
323
  return
322
324
  }
323
325
 
324
- const res = await cmsApi<{ alt?: string; title?: string; alternatives?: string[] }>(
325
- '/ai/media-metadata',
326
- { method: 'POST', body: JSON.stringify({ mediaId: activeItem.id }) },
327
- )
326
+ const res = await cmsApi<{
327
+ alt?: string
328
+ title?: string
329
+ alternatives?: string[]
330
+ isDecorative?: boolean
331
+ usedVision?: boolean
332
+ }>('/ai/media-metadata', { method: 'POST', body: JSON.stringify({ mediaId: activeItem.id }) })
328
333
 
329
334
  if (res.error) {
330
335
  // AI plugin/provider not configured (e.g. local dev): fall back to a
@@ -344,8 +349,17 @@ export function MediaBrowser({ onNavigate, currentPath }: MediaBrowserProps) {
344
349
  }
345
350
 
346
351
  if (field === 'alt') {
347
- setEditAlt(res.data?.alt ?? '')
348
- toast.success('Alt tag generated by AI')
352
+ if (res.data?.isDecorative) {
353
+ setEditAlt('')
354
+ toast.success('AI marked this image as decorative (empty alt)')
355
+ } else {
356
+ setEditAlt(res.data?.alt ?? '')
357
+ toast.success(
358
+ res.data?.usedVision
359
+ ? 'Alt tag generated from image analysis'
360
+ : 'Alt tag suggested — review before saving',
361
+ )
362
+ }
349
363
  } else {
350
364
  setEditTitle(res.data?.title ?? '')
351
365
  toast.success('Title generated by AI')
@@ -395,6 +409,24 @@ export function MediaBrowser({ onNavigate, currentPath }: MediaBrowserProps) {
395
409
  toast.success(`Uploaded ${file.name}`)
396
410
  }
397
411
  successCount++
412
+
413
+ const uploaded = res.data
414
+ const isImage =
415
+ file.type.startsWith('image/') || (uploaded?.type ?? '').startsWith('image/')
416
+ if (isImage && uploaded?.id) {
417
+ void cmsApi<{ alt?: string; isDecorative?: boolean }>('/ai/media-metadata', {
418
+ method: 'POST',
419
+ body: JSON.stringify({ mediaId: String(uploaded.id) }),
420
+ }).then((altRes) => {
421
+ if (altRes.error) return
422
+ if (altRes.data?.isDecorative) {
423
+ toast.info(`${file.name}: AI suggests decorative (empty alt). Open to review.`)
424
+ } else if (altRes.data?.alt) {
425
+ toast.info(`${file.name}: AI alt text ready. Open the item to review and save.`)
426
+ }
427
+ refetch()
428
+ })
429
+ }
398
430
  }
399
431
  }
400
432
 
@@ -467,7 +499,7 @@ export function MediaBrowser({ onNavigate, currentPath }: MediaBrowserProps) {
467
499
  return (
468
500
  <div className="flex h-full flex-col p-3 pr-6 sm:p-4 sm:pr-8">
469
501
  {error && (
470
- <div className="border-destructive/30 bg-destructive/10 mb-4 flex items-center gap-3 rounded-lg border p-3">
502
+ <div className="border-destructive/30 bg-destructive/10 mb-4 flex items-center gap-3 rounded-lg border p-4">
471
503
  <AlertTriangle className="text-destructive h-5 w-5 shrink-0" />
472
504
  <span className="text-destructive flex-1 text-sm">{error}</span>
473
505
  <button
@@ -635,7 +667,7 @@ export function MediaBrowser({ onNavigate, currentPath }: MediaBrowserProps) {
635
667
  </div>
636
668
 
637
669
  {selectedMedia.length > 0 && (
638
- <div className="border-primary/30 bg-primary/10 mb-4 rounded-lg border p-3">
670
+ <div className="border-primary/30 bg-primary/10 mb-4 rounded-lg border p-4">
639
671
  <div className="flex items-center justify-between">
640
672
  <span className="text-primary text-sm">
641
673
  {selectedMedia.length} file{selectedMedia.length !== 1 ? 's' : ''} selected
@@ -901,7 +933,7 @@ export function MediaBrowser({ onNavigate, currentPath }: MediaBrowserProps) {
901
933
  </div>
902
934
 
903
935
  {issues.length > 0 && (
904
- <div className="border-warning/30 bg-warning/10 mx-4 mt-4 rounded-lg border p-3">
936
+ <div className="border-warning/30 bg-warning/10 mx-4 mt-4 rounded-lg border p-4">
905
937
  <div className="flex items-start gap-2">
906
938
  <AlertTriangle className="text-warning mt-0.5 h-4 w-4 shrink-0" />
907
939
  <div>
@@ -1006,6 +1038,24 @@ export function MediaBrowser({ onNavigate, currentPath }: MediaBrowserProps) {
1006
1038
  rows={2}
1007
1039
  className="border-input focus:ring-ring w-full resize-none rounded-lg border px-3 py-2 text-sm focus:ring-2 focus:outline-none"
1008
1040
  />
1041
+ {activeItem.aiAltSuggestion !== undefined &&
1042
+ editAlt !== activeItem.aiAltSuggestion && (
1043
+ <div className="border-warning/40 bg-warning/10 mt-2 flex items-start justify-between gap-2 rounded-lg border p-4 text-xs">
1044
+ <span className="text-foreground">
1045
+ AI suggestion:{' '}
1046
+ {activeItem.aiAltSuggestion.trim()
1047
+ ? activeItem.aiAltSuggestion
1048
+ : '(decorative — empty alt)'}
1049
+ </span>
1050
+ <button
1051
+ type="button"
1052
+ onClick={() => setEditAlt(activeItem.aiAltSuggestion ?? '')}
1053
+ className="text-primary shrink-0 font-medium hover:opacity-80"
1054
+ >
1055
+ Apply
1056
+ </button>
1057
+ </div>
1058
+ )}
1009
1059
  {!editAlt && (
1010
1060
  <p className="text-destructive mt-1 text-xs">
1011
1061
  Required for accessibility and SEO
@@ -1121,7 +1171,7 @@ export function MediaBrowser({ onNavigate, currentPath }: MediaBrowserProps) {
1121
1171
  ))}
1122
1172
  </div>
1123
1173
  ) : (
1124
- <div className="border-warning/30 bg-warning/10 rounded-lg border p-3">
1174
+ <div className="border-warning/30 bg-warning/10 rounded-lg border p-4">
1125
1175
  <div className="flex items-start gap-2">
1126
1176
  <AlertTriangle className="text-warning mt-0.5 h-4 w-4 shrink-0" />
1127
1177
  <div>
@@ -1144,7 +1194,7 @@ export function MediaBrowser({ onNavigate, currentPath }: MediaBrowserProps) {
1144
1194
  type="button"
1145
1195
  onClick={() => handleAiGenerate('optimize')}
1146
1196
  disabled={aiGenerating === 'optimize'}
1147
- className="border-primary/30 bg-primary/10 hover:bg-primary/15 flex w-full items-center gap-2 rounded-lg border p-3 text-left transition-colors disabled:opacity-50"
1197
+ className="border-primary/30 bg-primary/10 hover:bg-primary/15 flex w-full items-center gap-2 rounded-lg border p-4 text-left transition-colors disabled:opacity-50"
1148
1198
  >
1149
1199
  <Sparkles
1150
1200
  className={`text-primary h-5 w-5 shrink-0 ${aiGenerating === 'optimize' ? 'animate-spin' : ''}`}
@@ -1160,7 +1210,7 @@ export function MediaBrowser({ onNavigate, currentPath }: MediaBrowserProps) {
1160
1210
  </button>
1161
1211
  <button
1162
1212
  type="button"
1163
- className="border-border hover:bg-muted flex w-full items-center gap-2 rounded-lg border p-3 text-left transition-colors"
1213
+ className="border-border hover:bg-muted flex w-full items-center gap-2 rounded-lg border p-4 text-left transition-colors"
1164
1214
  >
1165
1215
  <Bot className="text-muted-foreground h-5 w-5 shrink-0" />
1166
1216
  <div className="flex-1">
@@ -100,7 +100,7 @@ export function PageEditor({ id, onNavigate }: PageEditorProps) {
100
100
  </div>
101
101
  )}
102
102
 
103
- <div className="border-border border-b px-4 py-3">
103
+ <div className="border-border border-b px-6 py-4">
104
104
  <div className="flex items-center justify-between">
105
105
  <button
106
106
  onClick={() => onNavigate?.('/pages')}
@@ -136,7 +136,7 @@ function EditorToolbar({
136
136
  }) {
137
137
  const palette = paletteFor(detail.color)
138
138
  return (
139
- <div className="border-border bg-card flex flex-wrap items-center gap-3 border-b px-4 py-3 sm:px-6">
139
+ <div className="border-border bg-card flex flex-wrap items-center gap-3 border-b px-6 py-4 sm:px-6">
140
140
  <nav aria-label="Breadcrumb" className="flex min-w-0 items-center gap-1.5 text-sm">
141
141
  <button
142
142
  type="button"
@@ -284,7 +284,7 @@ function FieldBuilder({ detail }: { detail: PostTypeDetail }) {
284
284
  function FieldList({ detail }: { detail: PostTypeDetail }) {
285
285
  return (
286
286
  <div className="bg-card border-border rounded-xl border shadow-sm">
287
- <div className="border-border flex items-center gap-2 border-b px-4 py-3">
287
+ <div className="border-border flex items-center gap-2 border-b px-6 py-4">
288
288
  <Lock className="text-muted-foreground h-3.5 w-3.5" aria-hidden />
289
289
  <span className="text-foreground text-sm font-medium">Fields</span>
290
290
  <span className="bg-muted text-muted-foreground ml-auto rounded-full px-2 py-0.5 text-xs font-medium">
@@ -525,7 +525,7 @@ function AddFieldCard({ detail }: { detail: PostTypeDetail }) {
525
525
  function EditorSkeleton() {
526
526
  return (
527
527
  <div className="flex h-full flex-col">
528
- <div className="border-border bg-card border-b px-4 py-3 sm:px-6">
528
+ <div className="border-border bg-card border-b px-6 py-4 sm:px-6">
529
529
  <div className="bg-muted h-5 w-48 animate-pulse rounded" />
530
530
  </div>
531
531
  <div className="flex flex-col gap-6 p-4 sm:p-6 lg:flex-row">
package/src/views/SEO.tsx CHANGED
@@ -15,6 +15,7 @@ import {
15
15
  ClipboardCheck,
16
16
  RefreshCw,
17
17
  Bot,
18
+ Link2,
18
19
  } from 'lucide-react'
19
20
  import { useCallback, useEffect, useMemo, useState } from 'react'
20
21
  import { toast } from 'sonner'
@@ -30,6 +31,7 @@ import { ContentTab } from './seo/ContentTab.js'
30
31
  import { TechnicalTab } from './seo/TechnicalTab.js'
31
32
  import { RedirectsTab } from './seo/RedirectsTab.js'
32
33
  import { AuditTab } from './seo/AuditTab.js'
34
+ import { LinksTab } from './seo/LinksTab.js'
33
35
 
34
36
  export interface SEOProps {
35
37
  onNavigate?: (path: string) => void
@@ -39,6 +41,7 @@ export interface SEOProps {
39
41
  const TABS = [
40
42
  { id: 'overview', label: 'Overview', icon: LayoutDashboard },
41
43
  { id: 'content', label: 'Content SEO', icon: FileText },
44
+ { id: 'links', label: 'Links', icon: Link2 },
42
45
  { id: 'technical', label: 'Technical', icon: Settings2 },
43
46
  { id: 'redirects', label: 'Redirects', icon: ArrowRightLeft },
44
47
  { id: 'audit', label: 'Audit', icon: ClipboardCheck },
@@ -212,6 +215,9 @@ export function SEO({ onNavigate, initialTab = 'overview' }: SEOProps) {
212
215
  />
213
216
  )}
214
217
  </Tabs.Content>
218
+ <Tabs.Content value="links" tabIndex={-1}>
219
+ {activeTab === 'links' && <LinksTab onNavigate={onNavigate} />}
220
+ </Tabs.Content>
215
221
  <Tabs.Content value="technical" tabIndex={-1}>
216
222
  {activeTab === 'technical' && <TechnicalTab />}
217
223
  </Tabs.Content>