@actuate-media/cms-admin 0.10.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 (284) 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.js +1 -1
  34. package/dist/components/SharePreviewLinkDialog.js +1 -1
  35. package/dist/components/TipTapEditor.js +5 -5
  36. package/dist/components/VersionHistory.js +2 -2
  37. package/dist/components/ui/Badge.d.ts +33 -3
  38. package/dist/components/ui/Badge.d.ts.map +1 -1
  39. package/dist/components/ui/Badge.js +42 -8
  40. package/dist/components/ui/Badge.js.map +1 -1
  41. package/dist/components/ui/Button.d.ts +19 -8
  42. package/dist/components/ui/Button.d.ts.map +1 -1
  43. package/dist/components/ui/Button.js +35 -14
  44. package/dist/components/ui/Button.js.map +1 -1
  45. package/dist/components/ui/Card.d.ts +26 -0
  46. package/dist/components/ui/Card.d.ts.map +1 -0
  47. package/dist/components/ui/Card.js +45 -0
  48. package/dist/components/ui/Card.js.map +1 -0
  49. package/dist/components/ui/DataTable.js +1 -1
  50. package/dist/components/ui/Input.d.ts +15 -0
  51. package/dist/components/ui/Input.d.ts.map +1 -0
  52. package/dist/components/ui/Input.js +23 -0
  53. package/dist/components/ui/Input.js.map +1 -0
  54. package/dist/components/ui/SearchInput.js +1 -1
  55. package/dist/components/ui/Select.d.ts +16 -0
  56. package/dist/components/ui/Select.d.ts.map +1 -0
  57. package/dist/components/ui/Select.js +25 -0
  58. package/dist/components/ui/Select.js.map +1 -0
  59. package/dist/components/ui/Toast.js +1 -1
  60. package/dist/components/ui/index.d.ts +10 -4
  61. package/dist/components/ui/index.d.ts.map +1 -1
  62. package/dist/components/ui/index.js +5 -2
  63. package/dist/components/ui/index.js.map +1 -1
  64. package/dist/fields/BlockBuilderField.js +3 -3
  65. package/dist/fields/DateField.js +1 -1
  66. package/dist/fields/RelationshipField.js +3 -3
  67. package/dist/fields/TextField.js +1 -1
  68. package/dist/index.d.ts +2 -0
  69. package/dist/index.d.ts.map +1 -1
  70. package/dist/index.js +3 -0
  71. package/dist/index.js.map +1 -1
  72. package/dist/layout/Header.js +1 -1
  73. package/dist/layout/Layout.d.ts +14 -0
  74. package/dist/layout/Layout.d.ts.map +1 -1
  75. package/dist/layout/Layout.js +17 -11
  76. package/dist/layout/Layout.js.map +1 -1
  77. package/dist/layout/Sidebar.d.ts.map +1 -1
  78. package/dist/layout/Sidebar.js +21 -11
  79. package/dist/layout/Sidebar.js.map +1 -1
  80. package/dist/layout/primitives/AdminShell.d.ts +43 -0
  81. package/dist/layout/primitives/AdminShell.d.ts.map +1 -0
  82. package/dist/layout/primitives/AdminShell.js +51 -0
  83. package/dist/layout/primitives/AdminShell.js.map +1 -0
  84. package/dist/layout/primitives/Box.d.ts +19 -0
  85. package/dist/layout/primitives/Box.d.ts.map +1 -0
  86. package/dist/layout/primitives/Box.js +12 -0
  87. package/dist/layout/primitives/Box.js.map +1 -0
  88. package/dist/layout/primitives/Cluster.d.ts +27 -0
  89. package/dist/layout/primitives/Cluster.d.ts.map +1 -0
  90. package/dist/layout/primitives/Cluster.js +37 -0
  91. package/dist/layout/primitives/Cluster.js.map +1 -0
  92. package/dist/layout/primitives/Grid.d.ts +45 -0
  93. package/dist/layout/primitives/Grid.d.ts.map +1 -0
  94. package/dist/layout/primitives/Grid.js +59 -0
  95. package/dist/layout/primitives/Grid.js.map +1 -0
  96. package/dist/layout/primitives/PageContainer.d.ts +36 -0
  97. package/dist/layout/primitives/PageContainer.d.ts.map +1 -0
  98. package/dist/layout/primitives/PageContainer.js +41 -0
  99. package/dist/layout/primitives/PageContainer.js.map +1 -0
  100. package/dist/layout/primitives/Split.d.ts +34 -0
  101. package/dist/layout/primitives/Split.d.ts.map +1 -0
  102. package/dist/layout/primitives/Split.js +27 -0
  103. package/dist/layout/primitives/Split.js.map +1 -0
  104. package/dist/layout/primitives/Stack.d.ts +23 -0
  105. package/dist/layout/primitives/Stack.d.ts.map +1 -0
  106. package/dist/layout/primitives/Stack.js +34 -0
  107. package/dist/layout/primitives/Stack.js.map +1 -0
  108. package/dist/layout/primitives/index.d.ts +30 -0
  109. package/dist/layout/primitives/index.d.ts.map +1 -0
  110. package/dist/layout/primitives/index.js +22 -0
  111. package/dist/layout/primitives/index.js.map +1 -0
  112. package/dist/layout/primitives/tokens.d.ts +48 -0
  113. package/dist/layout/primitives/tokens.d.ts.map +1 -0
  114. package/dist/layout/primitives/tokens.js +54 -0
  115. package/dist/layout/primitives/tokens.js.map +1 -0
  116. package/dist/lib/cv.d.ts +53 -0
  117. package/dist/lib/cv.d.ts.map +1 -0
  118. package/dist/lib/cv.js +39 -0
  119. package/dist/lib/cv.js.map +1 -0
  120. package/dist/views/ApiKeys.js +7 -7
  121. package/dist/views/CollectionList.js +8 -8
  122. package/dist/views/Dashboard.d.ts.map +1 -1
  123. package/dist/views/Dashboard.js +333 -78
  124. package/dist/views/Dashboard.js.map +1 -1
  125. package/dist/views/DocumentEdit.js +3 -3
  126. package/dist/views/ForgotPassword.js +2 -2
  127. package/dist/views/FormEditor.js +5 -5
  128. package/dist/views/FormSubmissions.js +6 -6
  129. package/dist/views/Forms.js +2 -2
  130. package/dist/views/Login.d.ts +16 -1
  131. package/dist/views/Login.d.ts.map +1 -1
  132. package/dist/views/Login.js +17 -7
  133. package/dist/views/Login.js.map +1 -1
  134. package/dist/views/MediaBrowser.js +16 -16
  135. package/dist/views/PageEditor.js +2 -2
  136. package/dist/views/Pages.js +10 -10
  137. package/dist/views/PostEditor.js +2 -2
  138. package/dist/views/Posts.js +4 -4
  139. package/dist/views/Redirects.js +4 -4
  140. package/dist/views/ResetPassword.js +2 -2
  141. package/dist/views/SEO.js +6 -6
  142. package/dist/views/ScriptTagEditor.js +4 -4
  143. package/dist/views/ScriptTags.js +2 -2
  144. package/dist/views/Settings.d.ts.map +1 -1
  145. package/dist/views/Settings.js +9 -8
  146. package/dist/views/Settings.js.map +1 -1
  147. package/dist/views/SetupWizard.js +2 -2
  148. package/dist/views/Users.js +4 -4
  149. package/dist/views/page-builder/AIBlockAssist.js +1 -1
  150. package/dist/views/page-builder/AIGenerateDialog.js +10 -10
  151. package/dist/views/page-builder/BlockEditor.js +10 -10
  152. package/dist/views/page-builder/BlockPicker.js +4 -4
  153. package/dist/views/page-builder/BottomBar.js +1 -1
  154. package/dist/views/page-builder/BuilderToolbar.js +2 -2
  155. package/dist/views/page-builder/ContextPanel.js +2 -2
  156. package/dist/views/page-builder/DesignScore.js +9 -9
  157. package/dist/views/page-builder/NodeSettings.js +8 -8
  158. package/dist/views/page-builder/PageBuilder.js +3 -3
  159. package/dist/views/page-builder/PageSettings.js +1 -1
  160. package/dist/views/page-builder/PageTemplates.js +2 -2
  161. package/dist/views/page-builder/SEOPanel.js +13 -13
  162. package/dist/views/page-builder/SavedSections.js +5 -5
  163. package/dist/views/page-builder/TemplatePicker.js +2 -2
  164. package/dist/views/page-builder/block-renderers/CTAPreview.js +5 -5
  165. package/dist/views/page-builder/block-renderers/CardsPreview.js +1 -1
  166. package/dist/views/page-builder/block-renderers/CodePreview.js +1 -1
  167. package/dist/views/page-builder/block-renderers/FAQPreview.js +3 -3
  168. package/dist/views/page-builder/block-renderers/FallbackPreview.js +1 -1
  169. package/dist/views/page-builder/block-renderers/FormPreview.js +3 -3
  170. package/dist/views/page-builder/block-renderers/GalleryPreview.js +5 -5
  171. package/dist/views/page-builder/block-renderers/HeroPreview.js +3 -3
  172. package/dist/views/page-builder/block-renderers/ImagePreview.js +3 -3
  173. package/dist/views/page-builder/block-renderers/TextPreview.js +3 -3
  174. package/dist/views/page-builder/block-renderers/VideoPreview.js +4 -4
  175. package/dist/views/page-builder/canvas/BlockRenderer.js +1 -1
  176. package/dist/views/page-builder/canvas/BuilderCanvas.js +3 -3
  177. package/dist/views/page-builder/canvas/ColumnRenderer.js +2 -2
  178. package/dist/views/page-builder/canvas/ContainerRenderer.js +2 -2
  179. package/dist/views/page-builder/canvas/RowRenderer.js +2 -2
  180. package/dist/views/page-builder/canvas/SectionRenderer.js +2 -2
  181. package/package.json +6 -2
  182. package/src/AdminRoot.tsx +21 -11
  183. package/src/__tests__/layout/primitives.test.ts +37 -0
  184. package/src/__tests__/lib/cv.test.ts +74 -0
  185. package/src/assets/actuate-logo.tsx +72 -0
  186. package/src/components/Breadcrumbs.tsx +6 -6
  187. package/src/components/CommandPalette.tsx +34 -34
  188. package/src/components/ContentOverviewChart.tsx +3 -3
  189. package/src/components/ErrorBoundary.tsx +3 -3
  190. package/src/components/FocalPointPicker.tsx +4 -4
  191. package/src/components/FolderTree.tsx +38 -38
  192. package/src/components/LivePreview.tsx +16 -16
  193. package/src/components/LocaleSwitcher.tsx +7 -7
  194. package/src/components/MediaPickerModal.tsx +21 -21
  195. package/src/components/PresenceIndicator.tsx +2 -2
  196. package/src/components/SEOConfigPanel.tsx +582 -0
  197. package/src/components/SEOPanel.tsx +46 -46
  198. package/src/components/SEOPerformance.tsx +21 -21
  199. package/src/components/SchedulePublishDialog.tsx +4 -4
  200. package/src/components/SharePreviewLinkDialog.tsx +1 -1
  201. package/src/components/TipTapEditor.tsx +33 -33
  202. package/src/components/VersionHistory.tsx +16 -16
  203. package/src/components/ui/Badge.tsx +66 -14
  204. package/src/components/ui/Button.tsx +70 -33
  205. package/src/components/ui/Card.tsx +101 -0
  206. package/src/components/ui/DataTable.tsx +1 -1
  207. package/src/components/ui/Input.tsx +35 -0
  208. package/src/components/ui/SearchInput.tsx +4 -4
  209. package/src/components/ui/Select.tsx +56 -0
  210. package/src/components/ui/Toast.tsx +1 -1
  211. package/src/components/ui/index.ts +18 -4
  212. package/src/fields/BlockBuilderField.tsx +3 -3
  213. package/src/fields/DateField.tsx +1 -1
  214. package/src/fields/RelationshipField.tsx +10 -10
  215. package/src/fields/TextField.tsx +1 -1
  216. package/src/index.ts +28 -0
  217. package/src/layout/Header.tsx +28 -28
  218. package/src/layout/Layout.tsx +39 -46
  219. package/src/layout/Sidebar.tsx +37 -64
  220. package/src/layout/primitives/AdminShell.tsx +118 -0
  221. package/src/layout/primitives/Box.tsx +30 -0
  222. package/src/layout/primitives/Cluster.tsx +74 -0
  223. package/src/layout/primitives/Grid.tsx +120 -0
  224. package/src/layout/primitives/PageContainer.tsx +96 -0
  225. package/src/layout/primitives/Split.tsx +73 -0
  226. package/src/layout/primitives/Stack.tsx +67 -0
  227. package/src/layout/primitives/index.ts +36 -0
  228. package/src/layout/primitives/tokens.ts +76 -0
  229. package/src/lib/cv.ts +96 -0
  230. package/src/styles/build-input.css +1 -1
  231. package/src/views/ApiKeys.tsx +57 -57
  232. package/src/views/CollectionList.tsx +30 -30
  233. package/src/views/Dashboard.tsx +737 -186
  234. package/src/views/DocumentEdit.tsx +9 -9
  235. package/src/views/ForgotPassword.tsx +18 -18
  236. package/src/views/FormEditor.tsx +75 -75
  237. package/src/views/FormSubmissions.tsx +76 -76
  238. package/src/views/Forms.tsx +27 -27
  239. package/src/views/Login.tsx +65 -25
  240. package/src/views/MediaBrowser.tsx +127 -127
  241. package/src/views/PageEditor.tsx +25 -25
  242. package/src/views/Pages.tsx +59 -59
  243. package/src/views/PostEditor.tsx +37 -37
  244. package/src/views/Posts.tsx +48 -48
  245. package/src/views/Redirects.tsx +21 -21
  246. package/src/views/ResetPassword.tsx +28 -28
  247. package/src/views/SEO.tsx +144 -144
  248. package/src/views/ScriptTagEditor.tsx +24 -24
  249. package/src/views/ScriptTags.tsx +10 -10
  250. package/src/views/Settings.tsx +88 -80
  251. package/src/views/SetupWizard.tsx +28 -28
  252. package/src/views/Users.tsx +20 -20
  253. package/src/views/page-builder/AIBlockAssist.tsx +1 -1
  254. package/src/views/page-builder/AIGenerateDialog.tsx +63 -63
  255. package/src/views/page-builder/BlockEditor.tsx +26 -26
  256. package/src/views/page-builder/BlockPicker.tsx +22 -22
  257. package/src/views/page-builder/BottomBar.tsx +8 -8
  258. package/src/views/page-builder/BuilderToolbar.tsx +17 -17
  259. package/src/views/page-builder/ContextPanel.tsx +3 -3
  260. package/src/views/page-builder/DesignScore.tsx +21 -21
  261. package/src/views/page-builder/NodeSettings.tsx +27 -27
  262. package/src/views/page-builder/PageBuilder.tsx +11 -11
  263. package/src/views/page-builder/PageSettings.tsx +4 -4
  264. package/src/views/page-builder/PageTemplates.tsx +18 -18
  265. package/src/views/page-builder/SEOPanel.tsx +53 -53
  266. package/src/views/page-builder/SavedSections.tsx +37 -37
  267. package/src/views/page-builder/TemplatePicker.tsx +17 -17
  268. package/src/views/page-builder/block-renderers/CTAPreview.tsx +13 -13
  269. package/src/views/page-builder/block-renderers/CardsPreview.tsx +5 -5
  270. package/src/views/page-builder/block-renderers/CodePreview.tsx +6 -6
  271. package/src/views/page-builder/block-renderers/FAQPreview.tsx +13 -13
  272. package/src/views/page-builder/block-renderers/FallbackPreview.tsx +3 -3
  273. package/src/views/page-builder/block-renderers/FormPreview.tsx +20 -20
  274. package/src/views/page-builder/block-renderers/GalleryPreview.tsx +8 -8
  275. package/src/views/page-builder/block-renderers/HeroPreview.tsx +16 -16
  276. package/src/views/page-builder/block-renderers/ImagePreview.tsx +4 -4
  277. package/src/views/page-builder/block-renderers/TextPreview.tsx +14 -14
  278. package/src/views/page-builder/block-renderers/VideoPreview.tsx +12 -12
  279. package/src/views/page-builder/canvas/BlockRenderer.tsx +4 -4
  280. package/src/views/page-builder/canvas/BuilderCanvas.tsx +6 -6
  281. package/src/views/page-builder/canvas/ColumnRenderer.tsx +3 -3
  282. package/src/views/page-builder/canvas/ContainerRenderer.tsx +2 -2
  283. package/src/views/page-builder/canvas/RowRenderer.tsx +2 -2
  284. package/src/views/page-builder/canvas/SectionRenderer.tsx +2 -2
@@ -1,22 +1,41 @@
1
1
  'use client'
2
2
 
3
+ /**
4
+ * Dashboard
5
+ * ----------
6
+ * Single-file, lean dashboard implementation. Two API calls only
7
+ * (`/stats` + `/health`, both shared with the rest of the admin), everything
8
+ * else derived client-side via `useMemo`. No charting libraries on this view
9
+ * (the design uses plain progress bars + colored dots), keeping the bundle
10
+ * and TTI cost low.
11
+ *
12
+ * Responsive layout:
13
+ * < 640px (mobile): stat cards 2-col, main grid stacks, quick actions scroll-x
14
+ * 640-1023 (tablet): stat cards 3-col, main grid stacks
15
+ * >= 1024 (desktop): stat cards 5-col, main grid is `1fr 320px`
16
+ */
17
+
18
+ import { useMemo, useState } from 'react'
3
19
  import {
4
20
  FileText,
5
- File,
6
- Image,
7
- Users,
21
+ File as FileIcon,
22
+ Image as ImageIcon,
8
23
  ClipboardList,
9
24
  Search,
10
- Loader2,
25
+ Plus,
26
+ Upload,
27
+ Globe,
28
+ ExternalLink,
29
+ Activity,
11
30
  AlertTriangle,
31
+ Clock,
32
+ Zap,
12
33
  Database,
13
- ChevronLeft,
14
34
  ChevronRight,
35
+ Loader2,
36
+ type LucideIcon,
15
37
  } from 'lucide-react'
16
- import { useState } from 'react'
17
38
  import { useApiData } from '../lib/useApiData.js'
18
- import { ContentOverviewChart } from '../components/ContentOverviewChart.js'
19
- import { SEOPerformance } from '../components/SEOPerformance.js'
20
39
 
21
40
  interface DashboardStats {
22
41
  totalDocuments: number
@@ -24,6 +43,8 @@ interface DashboardStats {
24
43
  totalUsers: number
25
44
  formCount: number
26
45
  avgSeoScore: number
46
+ webhookCount: number
47
+ webhookActiveCount: number
27
48
  collectionCounts: Record<string, number>
28
49
  statusCounts: Record<string, number>
29
50
  recentDocuments: {
@@ -38,10 +59,9 @@ interface DashboardStats {
38
59
 
39
60
  interface HealthData {
40
61
  status: 'healthy' | 'degraded'
41
- version: string
62
+ databaseConnected: boolean
42
63
  secretConfigured: boolean
43
64
  models: Record<string, boolean>
44
- databaseConnected: boolean
45
65
  }
46
66
 
47
67
  interface CollectionMeta {
@@ -56,124 +76,354 @@ export interface DashboardProps {
56
76
  onNavigate?: (path: string) => void
57
77
  }
58
78
 
79
+ // ─── helpers (kept top-level so they aren't re-created on every render) ──────
80
+
59
81
  function resolveCollections(config: any): CollectionMeta[] {
60
82
  if (!config?.collections) return []
61
83
  const raw = config.collections
62
84
  const list: any[] = Array.isArray(raw) ? raw : Object.values(raw)
63
85
  return list
64
- .filter((c) => !c.admin?.hidden)
86
+ .filter((c) => !c?.admin?.hidden)
65
87
  .map((c) => ({ slug: c.slug, type: c.type, labels: c.labels }))
66
88
  }
67
89
 
68
90
  function collectionLabel(col: CollectionMeta, plural = true): string {
69
- if (plural) return col.labels?.plural ?? col.slug.charAt(0).toUpperCase() + col.slug.slice(1)
70
- return col.labels?.singular ?? col.slug.charAt(0).toUpperCase() + col.slug.slice(1)
91
+ const fallback = col.slug.charAt(0).toUpperCase() + col.slug.slice(1)
92
+ if (plural) return col.labels?.plural ?? fallback
93
+ return col.labels?.singular ?? fallback
71
94
  }
72
95
 
73
96
  function relativeTime(dateStr: string): string {
74
- const now = Date.now()
75
97
  const then = new Date(dateStr).getTime()
76
- const diff = now - then
77
- const mins = Math.floor(diff / 60000)
78
- if (mins < 1) return 'just now'
79
- if (mins < 60) return `${mins} min ago`
98
+ const diff = Date.now() - then
99
+ if (diff < 60_000) return 'just now'
100
+ const mins = Math.floor(diff / 60_000)
101
+ if (mins < 60) return `${mins}m ago`
80
102
  const hours = Math.floor(mins / 60)
81
- if (hours < 24) return `${hours} hour${hours !== 1 ? 's' : ''} ago`
103
+ if (hours < 24) return `${hours}h ago`
82
104
  const days = Math.floor(hours / 24)
83
- if (days < 30) return `${days} day${days !== 1 ? 's' : ''} ago`
105
+ if (days === 1) return 'Yesterday'
106
+ if (days < 30) return `${days}d ago`
84
107
  return new Date(dateStr).toLocaleDateString()
85
108
  }
86
109
 
87
- function statusColor(status: string): string {
88
- switch (status) {
89
- case 'PUBLISHED':
90
- return 'bg-green-100 text-green-800'
91
- case 'DRAFT':
92
- return 'bg-gray-100 text-gray-700'
93
- case 'SCHEDULED':
94
- return 'bg-purple-100 text-purple-800'
95
- case 'IN_REVIEW':
96
- return 'bg-blue-100 text-blue-800'
97
- default:
98
- return 'bg-gray-100 text-gray-700'
99
- }
110
+ function timeOfDayGreeting(): string {
111
+ const h = new Date().getHours()
112
+ if (h < 12) return 'Good morning'
113
+ if (h < 17) return 'Good afternoon'
114
+ return 'Good evening'
115
+ }
116
+
117
+ function todayDateString(): string {
118
+ return new Date().toLocaleDateString('en-US', {
119
+ weekday: 'long',
120
+ month: 'long',
121
+ day: 'numeric',
122
+ year: 'numeric',
123
+ })
124
+ }
125
+
126
+ // Deterministic colour per author so avatars stay stable across renders
127
+ // without storing anything in state or hitting the server.
128
+ const AVATAR_COLORS = [
129
+ '#7C3AED', // purple
130
+ '#E11D48', // rose
131
+ '#059669', // emerald
132
+ '#0891B2', // cyan
133
+ '#D97706', // amber
134
+ '#4F46E5', // indigo
135
+ '#DC2626', // red
136
+ ] as const
137
+
138
+ function hashString(s: string): number {
139
+ let h = 0
140
+ for (let i = 0; i < s.length; i++) h = (h * 31 + s.charCodeAt(i)) | 0
141
+ return Math.abs(h)
100
142
  }
101
143
 
102
- function statusLabel(status: string): string {
144
+ function authorAvatar(name: string | null | undefined): { color: string; initials: string } {
145
+ const safe = (name ?? '').trim() || 'User'
146
+ const parts = safe.split(/\s+/).filter(Boolean)
147
+ const initials =
148
+ parts.length >= 2
149
+ ? `${parts[0]![0]}${parts[parts.length - 1]![0]}`.toUpperCase()
150
+ : safe.slice(0, 2).toUpperCase()
151
+ const color = AVATAR_COLORS[hashString(safe) % AVATAR_COLORS.length]!
152
+ return { color, initials }
153
+ }
154
+
155
+ function statusBadge(status: string): {
156
+ label: string
157
+ cls: string
158
+ } {
103
159
  switch (status) {
104
160
  case 'PUBLISHED':
105
- return 'Published'
161
+ return {
162
+ label: 'Published',
163
+ cls: 'bg-emerald-100 text-emerald-800 dark:bg-emerald-950/60 dark:text-emerald-300',
164
+ }
106
165
  case 'DRAFT':
107
- return 'Draft'
166
+ return {
167
+ label: 'Draft',
168
+ cls: 'bg-gray-100 text-gray-700 dark:bg-gray-800 dark:text-gray-300',
169
+ }
108
170
  case 'SCHEDULED':
109
- return 'Scheduled'
171
+ return {
172
+ label: 'Scheduled',
173
+ cls: 'bg-amber-100 text-amber-800 dark:bg-amber-950/60 dark:text-amber-300',
174
+ }
110
175
  case 'IN_REVIEW':
111
- return 'In Review'
176
+ return {
177
+ label: 'In Review',
178
+ cls: 'bg-blue-100 text-blue-800 dark:bg-blue-950/60 dark:text-blue-300',
179
+ }
112
180
  default:
113
- return status
181
+ return { label: status, cls: 'bg-gray-100 text-gray-700 dark:bg-gray-800 dark:text-gray-300' }
114
182
  }
115
183
  }
116
184
 
117
- const STAT_COLORS = [
118
- { bg: 'bg-blue-50', icon: 'bg-blue-100', text: 'text-blue-600' },
119
- { bg: 'bg-purple-50', icon: 'bg-purple-100', text: 'text-purple-600' },
120
- { bg: 'bg-teal-50', icon: 'bg-teal-100', text: 'text-teal-600' },
121
- { bg: 'bg-green-50', icon: 'bg-green-100', text: 'text-green-600' },
122
- { bg: 'bg-amber-50', icon: 'bg-amber-100', text: 'text-amber-600' },
123
- ]
185
+ interface StatCardData {
186
+ label: string
187
+ value: string
188
+ unit?: string
189
+ hint?: string
190
+ hintUp?: boolean
191
+ icon: LucideIcon
192
+ iconBg: string
193
+ iconColor: string
194
+ href?: string
195
+ }
196
+
197
+ // ─── Dashboard ───────────────────────────────────────────────────────────────
124
198
 
125
199
  export function Dashboard({ config, session, onNavigate }: DashboardProps) {
126
200
  const nav = (path: string) => onNavigate?.(path)
127
- const { data, loading, error, exhausted, refetch } = useApiData<DashboardStats>('/stats')
201
+ const { data: stats, loading, error, exhausted, refetch } = useApiData<DashboardStats>('/stats')
128
202
  const { data: health } = useApiData<HealthData>('/health')
129
- const [activityPage, setActivityPage] = useState(0)
130
203
 
131
- const collections = resolveCollections(config)
132
- const userName = session?.name ?? session?.email?.split('@')[0] ?? 'Admin'
204
+ const greeting = useMemo(() => timeOfDayGreeting(), [])
205
+ const dateStr = useMemo(() => todayDateString(), [])
206
+ const userName = session?.name ?? session?.email?.split('@')[0] ?? 'there'
207
+
208
+ const collections = useMemo(() => resolveCollections(config), [config])
133
209
 
134
- const collectionCounts = data?.collectionCounts ?? {}
135
- const statusCounts = data?.statusCounts ?? {}
136
- const recentDocs = data?.recentDocuments ?? []
210
+ // ── Stat cards ──────────────────────────────────────────────────────────
211
+ const statCards: StatCardData[] = useMemo(() => {
212
+ const counts = stats?.collectionCounts ?? {}
137
213
 
138
- const perPage = 5
139
- const totalActivityPages = Math.max(1, Math.ceil(recentDocs.length / perPage))
140
- const visibleDocs = recentDocs.slice(activityPage * perPage, (activityPage + 1) * perPage)
214
+ // Prefer two real collections (the user's primary content types) over
215
+ // hard-coded Posts/Pages so the dashboard adapts to admin-managed types.
216
+ const primary: CollectionMeta[] = []
217
+ const posts = collections.find((c) => c.slug === 'posts' || c.type === 'post')
218
+ const pages = collections.find((c) => c.slug === 'pages' || c.type === 'page')
219
+ if (posts) primary.push(posts)
220
+ if (pages) primary.push(pages)
221
+ if (primary.length < 2) {
222
+ for (const c of collections) {
223
+ if (primary.length >= 2) break
224
+ if (!primary.includes(c)) primary.push(c)
225
+ }
226
+ }
141
227
 
142
- const statCards: { label: string; value: number; icon: typeof FileText }[] = []
228
+ const cards: StatCardData[] = []
143
229
 
144
- if (collections.length > 0) {
145
- for (const col of collections.slice(0, 2)) {
146
- statCards.push({
147
- label: collectionLabel(col),
148
- value: collectionCounts[col.slug] ?? 0,
149
- icon: col.type === 'page' ? File : FileText,
230
+ if (primary[0]) {
231
+ cards.push({
232
+ label: collectionLabel(primary[0]),
233
+ value: String(counts[primary[0].slug] ?? 0),
234
+ icon: primary[0].type === 'page' ? FileIcon : FileText,
235
+ iconBg: 'bg-violet-100 dark:bg-violet-950/60',
236
+ iconColor: 'text-violet-600 dark:text-violet-300',
237
+ href: `/${primary[0].slug}`,
238
+ })
239
+ } else {
240
+ cards.push({
241
+ label: 'Documents',
242
+ value: String(stats?.totalDocuments ?? 0),
243
+ icon: FileText,
244
+ iconBg: 'bg-violet-100 dark:bg-violet-950/60',
245
+ iconColor: 'text-violet-600 dark:text-violet-300',
150
246
  })
151
247
  }
152
- } else {
153
- statCards.push({ label: 'Pages', value: collectionCounts['pages'] ?? 0, icon: File })
154
- statCards.push({ label: 'Posts', value: collectionCounts['posts'] ?? 0, icon: FileText })
155
- }
156
248
 
157
- statCards.push({ label: 'Forms', value: data?.formCount ?? 0, icon: ClipboardList })
158
- statCards.push({ label: 'Media', value: data?.totalMedia ?? 0, icon: Image })
159
- statCards.push({ label: 'Avg. SEO Rating', value: data?.avgSeoScore ?? 0, icon: Search })
249
+ if (primary[1]) {
250
+ cards.push({
251
+ label: collectionLabel(primary[1]),
252
+ value: String(counts[primary[1].slug] ?? 0),
253
+ icon: primary[1].type === 'page' ? FileIcon : FileText,
254
+ iconBg: 'bg-cyan-100 dark:bg-cyan-950/60',
255
+ iconColor: 'text-cyan-600 dark:text-cyan-300',
256
+ href: `/${primary[1].slug}`,
257
+ })
258
+ }
160
259
 
161
- if (loading) {
260
+ cards.push({
261
+ label: 'Media',
262
+ value: String(stats?.totalMedia ?? 0),
263
+ hint:
264
+ stats && stats.totalMedia > 0
265
+ ? `${stats.totalMedia} file${stats.totalMedia === 1 ? '' : 's'}`
266
+ : undefined,
267
+ icon: ImageIcon,
268
+ iconBg: 'bg-emerald-100 dark:bg-emerald-950/60',
269
+ iconColor: 'text-emerald-600 dark:text-emerald-300',
270
+ href: '/media',
271
+ })
272
+
273
+ // `formCount` in /stats is the *submission* count; the number of
274
+ // forms is the count of `collection: forms` documents.
275
+ const formsCount = counts['forms'] ?? 0
276
+ const submissions = stats?.formCount ?? 0
277
+ cards.push({
278
+ label: 'Forms',
279
+ value: String(formsCount),
280
+ hint: submissions > 0 ? `${submissions} response${submissions === 1 ? '' : 's'}` : undefined,
281
+ icon: ClipboardList,
282
+ iconBg: 'bg-amber-100 dark:bg-amber-950/60',
283
+ iconColor: 'text-amber-600 dark:text-amber-300',
284
+ href: '/forms',
285
+ })
286
+
287
+ const seo = stats?.avgSeoScore ?? 0
288
+ cards.push({
289
+ label: 'SEO Score',
290
+ value: seo > 0 ? String(seo) : '—',
291
+ unit: seo > 0 ? '/100' : undefined,
292
+ hint: seo > 0 ? (seo >= 70 ? 'Good' : seo >= 40 ? 'Fair' : 'Needs work') : 'No content yet',
293
+ hintUp: seo >= 70,
294
+ icon: Search,
295
+ iconBg: 'bg-indigo-100 dark:bg-indigo-950/60',
296
+ iconColor: 'text-indigo-600 dark:text-indigo-300',
297
+ href: '/seo',
298
+ })
299
+
300
+ return cards
301
+ }, [stats, collections])
302
+
303
+ // ── Quick actions ───────────────────────────────────────────────────────
304
+ // "New Post" lives only in the hero CTA above; this row is the secondary
305
+ // surface and intentionally omits the primary action to avoid the obvious
306
+ // duplication. Order mirrors the natural authoring flow.
307
+ const quickActions = useMemo(() => {
308
+ const pages = collections.find((c) => c.slug === 'pages' || c.type === 'page')
309
+ const items: { label: string; icon: LucideIcon; onClick: () => void }[] = []
310
+ if (pages)
311
+ items.push({ label: 'New Page', icon: Plus, onClick: () => nav(`/${pages.slug}/new`) })
312
+ items.push({ label: 'Upload Media', icon: Upload, onClick: () => nav('/media') })
313
+ items.push({ label: 'New Form', icon: Plus, onClick: () => nav('/forms') })
314
+ items.push({ label: 'Manage SEO', icon: Search, onClick: () => nav('/seo') })
315
+ items.push({ label: 'View API', icon: Globe, onClick: () => nav('/api-keys') })
316
+ return items
317
+ }, [collections, onNavigate]) // eslint-disable-line react-hooks/exhaustive-deps
318
+
319
+ // ── Recent activity ─────────────────────────────────────────────────────
320
+ const [activityLimit, setActivityLimit] = useState(8)
321
+ const activity = useMemo(() => {
322
+ const docs = stats?.recentDocuments ?? []
323
+ return docs.slice(0, activityLimit).map((d) => {
324
+ const col = collections.find((c) => c.slug === d.collection)
325
+ return {
326
+ ...d,
327
+ typeLabel: col ? collectionLabel(col, false) : d.collection,
328
+ relTime: relativeTime(d.updatedAt),
329
+ avatar: authorAvatar(d.author),
330
+ statusInfo: statusBadge(d.status),
331
+ }
332
+ })
333
+ }, [stats, collections, activityLimit])
334
+
335
+ // ── Publishing queue (scheduled docs only) ──────────────────────────────
336
+ const publishQueue = useMemo(() => {
337
+ const docs = stats?.recentDocuments ?? []
338
+ return docs
339
+ .filter((d) => d.status === 'SCHEDULED')
340
+ .slice(0, 5)
341
+ .map((d) => {
342
+ const col = collections.find((c) => c.slug === d.collection)
343
+ return {
344
+ id: d.id,
345
+ collection: d.collection,
346
+ title: d.title || 'Untitled',
347
+ type: col ? collectionLabel(col, false) : d.collection,
348
+ date: relativeTime(d.updatedAt),
349
+ author: d.author,
350
+ }
351
+ })
352
+ }, [stats, collections])
353
+
354
+ // ── Content health summary (derived from /stats) ────────────────────────
355
+ const contentHealth = useMemo(() => {
356
+ const score = stats?.avgSeoScore ?? 0
357
+ const counts = stats?.statusCounts ?? {}
358
+ const totalDrafts = counts['DRAFT'] ?? 0
359
+ const totalScheduled = counts['SCHEDULED'] ?? 0
360
+ const totalInReview = counts['IN_REVIEW'] ?? 0
361
+ const issues: { label: string; count: number; tone: 'warn' | 'err' | 'muted' }[] = []
362
+ if (totalDrafts > 0) {
363
+ issues.push({ label: 'Drafts pending publish', count: totalDrafts, tone: 'warn' })
364
+ }
365
+ if (totalInReview > 0) {
366
+ issues.push({ label: 'Awaiting review', count: totalInReview, tone: 'warn' })
367
+ }
368
+ if (totalScheduled > 0) {
369
+ issues.push({ label: 'Scheduled to publish', count: totalScheduled, tone: 'muted' })
370
+ }
371
+ if (score > 0 && score < 70) {
372
+ issues.push({
373
+ label: 'Pages with weak SEO',
374
+ count: Math.max(1, Math.round((70 - score) / 10)),
375
+ tone: score < 40 ? 'err' : 'warn',
376
+ })
377
+ }
378
+ return {
379
+ score,
380
+ label: score >= 70 ? 'Good' : score >= 40 ? 'Fair' : score > 0 ? 'Poor' : 'No data',
381
+ tone: score >= 70 ? 'ok' : score >= 40 ? 'warn' : score > 0 ? 'err' : 'muted',
382
+ issues,
383
+ } as const
384
+ }, [stats])
385
+
386
+ // ── Content delivery tiles ──────────────────────────────────────────────
387
+ const delivery = useMemo(() => {
388
+ const sched = stats?.statusCounts?.['SCHEDULED'] ?? 0
389
+ return {
390
+ totalDocs: stats?.totalDocuments ?? 0,
391
+ forms: stats?.formCount ?? 0,
392
+ scheduled: sched,
393
+ webhooks: {
394
+ total: stats?.webhookCount ?? 0,
395
+ active: stats?.webhookActiveCount ?? 0,
396
+ },
397
+ }
398
+ }, [stats])
399
+
400
+ const totalIssues = contentHealth.issues.reduce((s, i) => s + i.count, 0)
401
+
402
+ // ── Render ──────────────────────────────────────────────────────────────
403
+
404
+ if (loading && !stats) {
162
405
  return (
163
- <div className="p-4 sm:p-6 flex items-center justify-center h-64">
164
- <Loader2 className="w-6 h-6 animate-spin text-blue-600" />
406
+ <div className="flex h-64 items-center justify-center p-4 sm:p-6">
407
+ <Loader2 className="h-6 w-6 animate-spin text-violet-600" />
165
408
  </div>
166
409
  )
167
410
  }
168
411
 
412
+ const heroPostSlug =
413
+ collections.find((c) => c.slug === 'posts' || c.type === 'post')?.slug ?? 'posts'
414
+ const siteUrl = config?.site?.url ?? config?.seo?.siteUrl ?? null
415
+
169
416
  return (
170
- <div className="p-4 sm:p-6 space-y-6">
417
+ <div className="w-full space-y-5 p-4 sm:p-6 lg:px-8">
418
+ {/* Health banners ────────────────────────────────────────────── */}
171
419
  {health && health.status === 'degraded' && (
172
- <div className="flex items-center gap-3 rounded-lg border border-blue-200 bg-blue-50 p-3">
173
- <Database className="w-5 h-5 text-blue-600 shrink-0" />
174
- <div className="flex-1">
175
- <span className="text-sm font-medium text-blue-900">Database Setup Required</span>
176
- <p className="text-xs text-blue-700 mt-0.5">
420
+ <div className="flex items-start gap-3 rounded-lg border border-blue-200 bg-blue-50 p-3 dark:border-blue-900 dark:bg-blue-950/40">
421
+ <Database className="mt-0.5 h-5 w-5 shrink-0 text-blue-600 dark:text-blue-400" />
422
+ <div className="min-w-0 flex-1">
423
+ <span className="text-sm font-medium text-blue-900 dark:text-blue-200">
424
+ Database setup required
425
+ </span>
426
+ <p className="mt-0.5 text-xs text-blue-700 dark:text-blue-300">
177
427
  {!health.databaseConnected
178
428
  ? 'Cannot connect to the database. Check your DATABASE_URL environment variable.'
179
429
  : !health.secretConfigured
@@ -188,143 +438,444 @@ export function Dashboard({ config, session, onNavigate }: DashboardProps) {
188
438
  )}
189
439
 
190
440
  {error && exhausted && (
191
- <div className="flex items-center gap-3 rounded-lg border border-amber-200 bg-amber-50 p-3">
192
- <AlertTriangle className="w-5 h-5 text-amber-600 shrink-0" />
193
- <span className="text-sm text-amber-800 flex-1">
441
+ <div className="flex items-center gap-3 rounded-lg border border-amber-200 bg-amber-50 p-3 dark:border-amber-900 dark:bg-amber-950/40">
442
+ <AlertTriangle className="h-5 w-5 shrink-0 text-amber-600 dark:text-amber-400" />
443
+ <span className="flex-1 text-sm text-amber-800 dark:text-amber-200">
194
444
  Some dashboard data may be unavailable.
195
445
  </span>
196
446
  <button
197
447
  onClick={refetch}
198
- className="px-3 py-1 text-sm text-amber-700 border border-amber-300 rounded-lg hover:bg-amber-100 transition-colors"
448
+ className="rounded-lg border border-amber-300 px-3 py-1 text-sm text-amber-700 transition-colors hover:bg-amber-100 dark:border-amber-700 dark:text-amber-200 dark:hover:bg-amber-900/40"
199
449
  >
200
450
  Retry
201
451
  </button>
202
452
  </div>
203
453
  )}
204
454
 
205
- {/* Header */}
206
- <div>
207
- <h1 className="text-xl sm:text-2xl font-semibold text-gray-900">
208
- Welcome back, {userName}
209
- </h1>
210
- <p className="text-sm text-gray-500 mt-0.5">
211
- Here&apos;s what&apos;s happening with your content today
212
- </p>
455
+ {/* Header ─────────────────────────────────────────────────────── */}
456
+ <div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
457
+ <div className="min-w-0">
458
+ <h1 className="text-foreground text-xl font-semibold tracking-tight sm:text-2xl">
459
+ {greeting}, {userName} <span aria-hidden>👋</span>
460
+ </h1>
461
+ <p className="text-muted-foreground mt-0.5 text-sm">
462
+ {dateStr}
463
+ {totalIssues > 0 && (
464
+ <span className="hidden sm:inline">
465
+ {' · '}
466
+ {totalIssues} content item{totalIssues === 1 ? '' : 's'} need attention
467
+ </span>
468
+ )}
469
+ </p>
470
+ </div>
471
+ <div className="flex shrink-0 items-center gap-2">
472
+ <button
473
+ onClick={() => nav(`/${heroPostSlug}/new`)}
474
+ className="inline-flex items-center gap-1.5 rounded-lg bg-violet-600 px-3.5 py-2 text-sm font-medium text-white transition-colors hover:bg-violet-700"
475
+ >
476
+ <Plus className="h-3.5 w-3.5" /> New Post
477
+ </button>
478
+ {siteUrl && (
479
+ <a
480
+ href={siteUrl}
481
+ target="_blank"
482
+ rel="noopener noreferrer"
483
+ className="border-border bg-card hover:bg-accent hover:text-accent-foreground hidden items-center gap-1.5 rounded-lg border px-3 py-2 text-sm transition-colors sm:inline-flex"
484
+ >
485
+ <ExternalLink className="h-3.5 w-3.5" /> View Site
486
+ </a>
487
+ )}
488
+ </div>
213
489
  </div>
214
490
 
215
- {/* Stat cards */}
216
- <div className="grid grid-cols-2 md:grid-cols-3 xl:grid-cols-5 gap-4">
217
- {statCards.map((card, i) => {
218
- const colors = STAT_COLORS[i % STAT_COLORS.length]!
219
- const Icon = card.icon
220
- return (
221
- <div key={card.label} className="bg-white rounded-xl border border-gray-200 p-4">
222
- <div className="flex items-center justify-between mb-3">
223
- <div
224
- className={`w-9 h-9 rounded-lg flex items-center justify-center ${colors.icon}`}
225
- >
226
- <Icon className={`w-4.5 h-4.5 ${colors.text}`} />
227
- </div>
491
+ {/* Quick actions ──────────────────────────────────────────────── */}
492
+ <div className="-mx-1 flex gap-2 overflow-x-auto px-1 pb-1 [scrollbar-width:thin]">
493
+ {quickActions.map((a) => (
494
+ <button
495
+ key={a.label}
496
+ onClick={a.onClick}
497
+ className="border-border bg-card inline-flex shrink-0 items-center gap-1.5 rounded-lg border px-3.5 py-2 text-sm shadow-sm transition-colors hover:border-violet-400 hover:bg-violet-50 hover:text-violet-700 dark:hover:bg-violet-950/40 dark:hover:text-violet-300"
498
+ >
499
+ <a.icon className="h-3.5 w-3.5" />
500
+ {a.label}
501
+ </button>
502
+ ))}
503
+ </div>
504
+
505
+ {/* Stat cards ─────────────────────────────────────────────────── */}
506
+ <div className="grid grid-cols-2 gap-3 sm:grid-cols-3 lg:grid-cols-5">
507
+ {statCards.map((card) => {
508
+ const Inner = (
509
+ <>
510
+ <div
511
+ className={`mb-3 flex h-8 w-8 items-center justify-center rounded-lg ${card.iconBg}`}
512
+ >
513
+ <card.icon className={`h-4 w-4 ${card.iconColor}`} />
228
514
  </div>
229
- <p className="text-2xl font-semibold text-gray-900">{card.value.toLocaleString()}</p>
230
- <p className="text-xs text-gray-500 mt-0.5">{card.label}</p>
515
+ <div className="flex items-baseline gap-1">
516
+ <span className="text-foreground text-2xl leading-none font-semibold tracking-tight">
517
+ {card.value}
518
+ </span>
519
+ {card.unit && (
520
+ <span className="text-muted-foreground text-sm font-medium">{card.unit}</span>
521
+ )}
522
+ </div>
523
+ <p className="text-muted-foreground mt-1 text-xs">{card.label}</p>
524
+ {card.hint && (
525
+ <p
526
+ className={`mt-1.5 text-[11px] ${card.hintUp ? 'text-emerald-600 dark:text-emerald-400' : 'text-muted-foreground'}`}
527
+ >
528
+ {card.hint}
529
+ </p>
530
+ )}
531
+ </>
532
+ )
533
+
534
+ return card.href ? (
535
+ <button
536
+ key={card.label}
537
+ type="button"
538
+ onClick={() => nav(card.href!)}
539
+ className="bg-card border-border rounded-xl border p-4 text-left shadow-sm transition-shadow hover:shadow-md"
540
+ >
541
+ {Inner}
542
+ </button>
543
+ ) : (
544
+ <div key={card.label} className="bg-card border-border rounded-xl border p-4 shadow-sm">
545
+ {Inner}
231
546
  </div>
232
547
  )
233
548
  })}
234
549
  </div>
235
550
 
236
- {/* Middle row: Recent Activity + Content Overview */}
237
- <div className="grid grid-cols-1 lg:grid-cols-12 gap-4">
238
- {/* Recent Activity */}
239
- <div className="lg:col-span-8 bg-white rounded-xl border border-gray-200">
240
- <div className="p-4 border-b border-gray-200">
241
- <h2 className="text-sm font-semibold text-gray-900">Recent Activity</h2>
242
- </div>
243
- <div className="divide-y divide-gray-100">
244
- {visibleDocs.length === 0 ? (
245
- <div className="p-8 text-center">
246
- <p className="text-sm text-gray-400">No content yet</p>
247
- </div>
248
- ) : (
249
- visibleDocs.map((doc) => {
250
- const colMeta = collections.find((c) => c.slug === doc.collection)
251
- const typeLabel = colMeta ? collectionLabel(colMeta, false) : doc.collection
252
- return (
253
- <div key={doc.id} className="px-4 py-3 hover:bg-gray-50 transition-colors">
254
- <div className="flex items-center justify-between gap-3">
255
- <div className="flex-1 min-w-0">
256
- <div className="flex items-center gap-2 mb-0.5">
257
- <h3 className="text-sm font-medium text-gray-900 truncate">
258
- {doc.title ?? 'Untitled'}
259
- </h3>
260
- </div>
261
- <div className="flex items-center gap-2 text-xs text-gray-500">
262
- <span className="capitalize">{typeLabel}</span>
263
- <span>&middot;</span>
264
- <span>{doc.author}</span>
265
- <span>&middot;</span>
266
- <span>{relativeTime(doc.updatedAt)}</span>
267
- </div>
551
+ {/* Main grid ──────────────────────────────────────────────────── */}
552
+ <div className="grid grid-cols-1 gap-4 lg:grid-cols-[minmax(0,1fr)_320px]">
553
+ {/* Recent Activity ───────────────────────────────────────── */}
554
+ <section className="bg-card border-border overflow-hidden rounded-xl border shadow-sm">
555
+ <header className="border-border flex items-center justify-between border-b px-4 py-3">
556
+ <div className="min-w-0">
557
+ <h2 className="text-foreground text-sm font-semibold">Recent Activity</h2>
558
+ <p className="text-muted-foreground mt-0.5 text-xs">Last 7 days across all content</p>
559
+ </div>
560
+ <button
561
+ className="text-xs font-medium text-violet-600 hover:underline dark:text-violet-400"
562
+ onClick={() => setActivityLimit((n) => (n >= 20 ? 8 : 20))}
563
+ >
564
+ {activity.length >= 20 ? 'Show less' : 'View all'}
565
+ </button>
566
+ </header>
567
+ {activity.length === 0 ? (
568
+ <EmptyState
569
+ icon={Activity}
570
+ title="No activity yet"
571
+ subtitle="Content changes will appear here as your team works."
572
+ />
573
+ ) : (
574
+ <ul role="list" className="divide-border divide-y">
575
+ {activity.map((it) => (
576
+ <li
577
+ key={it.id}
578
+ className="hover:bg-accent/50 cursor-pointer px-4 py-3 transition-colors"
579
+ onClick={() => nav(`/${it.collection}/${it.id}`)}
580
+ >
581
+ <div className="flex min-w-0 items-start gap-3">
582
+ <div
583
+ className="mt-0.5 flex h-7 w-7 shrink-0 items-center justify-center rounded-full text-[11px] font-bold text-white"
584
+ style={{ background: it.avatar.color }}
585
+ aria-hidden
586
+ >
587
+ {it.avatar.initials}
588
+ </div>
589
+ <div className="min-w-0 flex-1">
590
+ <p className="text-foreground truncate text-sm leading-snug">
591
+ <span className="font-semibold">
592
+ &ldquo;{it.title || 'Untitled'}&rdquo;
593
+ </span>{' '}
594
+ <span className="text-muted-foreground">— {it.typeLabel}</span>
595
+ </p>
596
+ <div className="text-muted-foreground mt-1 flex flex-wrap items-center gap-2 text-[11px]">
597
+ <span
598
+ className={`inline-flex items-center rounded px-1.5 py-0.5 text-[10px] font-medium ${it.statusInfo.cls}`}
599
+ >
600
+ {it.statusInfo.label}
601
+ </span>
602
+ <span className="max-w-[120px] truncate">{it.author}</span>
603
+ <span aria-hidden>·</span>
604
+ <span>{it.relTime}</span>
268
605
  </div>
269
- <span
270
- className={`px-2.5 py-0.5 rounded-full text-xs font-medium whitespace-nowrap ${statusColor(doc.status)}`}
271
- >
272
- {statusLabel(doc.status)}
273
- </span>
274
606
  </div>
275
607
  </div>
276
- )
277
- })
608
+ </li>
609
+ ))}
610
+ </ul>
611
+ )}
612
+ </section>
613
+
614
+ {/* Right column ─────────────────────────────────────────── */}
615
+ <aside className="flex min-w-0 flex-col gap-4">
616
+ {/* Publishing Queue */}
617
+ <section className="bg-card border-border overflow-hidden rounded-xl border shadow-sm">
618
+ <header className="border-border flex items-center justify-between border-b px-4 py-3">
619
+ <div className="min-w-0">
620
+ <h2 className="text-foreground text-sm font-semibold">Publishing Queue</h2>
621
+ <p className="text-muted-foreground mt-0.5 text-xs">
622
+ {publishQueue.length} item{publishQueue.length === 1 ? '' : 's'} scheduled
623
+ </p>
624
+ </div>
625
+ <button
626
+ className="text-xs font-medium text-violet-600 hover:underline disabled:cursor-default disabled:no-underline disabled:opacity-50 dark:text-violet-400"
627
+ disabled={publishQueue.length === 0}
628
+ onClick={() => {
629
+ // We don't have a dedicated "scheduled" admin page, so deep-
630
+ // link to the first scheduled item — that's where authors
631
+ // typically need to land to reschedule or cancel.
632
+ const first = publishQueue[0]
633
+ if (first) nav(`/${first.collection}/${first.id}`)
634
+ }}
635
+ >
636
+ Manage
637
+ </button>
638
+ </header>
639
+ {publishQueue.length === 0 ? (
640
+ <EmptyState
641
+ icon={Clock}
642
+ title="No scheduled content"
643
+ subtitle="Scheduled posts and pages appear here."
644
+ compact
645
+ />
646
+ ) : (
647
+ <ul role="list" className="divide-border divide-y">
648
+ {publishQueue.map((q) => (
649
+ <li
650
+ key={q.id}
651
+ className="hover:bg-accent/50 flex min-w-0 cursor-pointer items-center gap-2.5 px-4 py-2.5 transition-colors"
652
+ onClick={() => nav(`/${q.collection}/${q.id}`)}
653
+ >
654
+ <span className="h-2 w-2 shrink-0 rounded-full bg-amber-500" aria-hidden />
655
+ <div className="min-w-0 flex-1">
656
+ <p className="text-foreground truncate text-sm font-medium">{q.title}</p>
657
+ <p className="text-muted-foreground truncate text-[11px]">
658
+ {q.date} · {q.author}
659
+ </p>
660
+ </div>
661
+ <span className="border-border bg-background text-muted-foreground shrink-0 rounded border px-1.5 py-0.5 text-[10px] capitalize">
662
+ {q.type}
663
+ </span>
664
+ </li>
665
+ ))}
666
+ </ul>
278
667
  )}
279
- </div>
280
- {recentDocs.length > perPage && (
281
- <div className="px-4 py-3 border-t border-gray-100 flex items-center justify-between text-xs text-gray-500">
282
- <span>
283
- Showing {activityPage * perPage + 1}-
284
- {Math.min((activityPage + 1) * perPage, recentDocs.length)} of {recentDocs.length}
285
- </span>
286
- <div className="flex items-center gap-1">
287
- <button
288
- onClick={() => setActivityPage(Math.max(0, activityPage - 1))}
289
- disabled={activityPage === 0}
290
- className="p-1 rounded hover:bg-gray-100 disabled:opacity-30"
668
+ </section>
669
+
670
+ {/* Content Health */}
671
+ <section className="bg-card border-border overflow-hidden rounded-xl border shadow-sm">
672
+ <header className="border-border flex items-center justify-between border-b px-4 py-3">
673
+ <div className="min-w-0">
674
+ <h2 className="text-foreground text-sm font-semibold">Content Health</h2>
675
+ <p className="text-muted-foreground mt-0.5 text-xs">SEO &amp; quality issues</p>
676
+ </div>
677
+ <button
678
+ className="text-xs font-medium text-violet-600 hover:underline dark:text-violet-400"
679
+ onClick={() => nav('/seo')}
680
+ >
681
+ Fix issues
682
+ </button>
683
+ </header>
684
+ <div className="px-4 pt-3.5 pb-2.5">
685
+ <div className="mb-1.5 flex items-baseline justify-between">
686
+ <p className="text-foreground text-2xl leading-none font-semibold tracking-tight">
687
+ {contentHealth.score > 0 ? contentHealth.score : '—'}
688
+ {contentHealth.score > 0 && (
689
+ <span className="text-muted-foreground ml-0.5 text-sm font-normal">/100</span>
690
+ )}
691
+ </p>
692
+ <span
693
+ className={`text-xs font-medium ${
694
+ contentHealth.tone === 'ok'
695
+ ? 'text-emerald-600 dark:text-emerald-400'
696
+ : contentHealth.tone === 'warn'
697
+ ? 'text-amber-600 dark:text-amber-400'
698
+ : contentHealth.tone === 'err'
699
+ ? 'text-red-600 dark:text-red-400'
700
+ : 'text-muted-foreground'
701
+ }`}
291
702
  >
292
- <ChevronLeft className="w-3.5 h-3.5" />
293
- </button>
294
- <span>
295
- Page {activityPage + 1} of {totalActivityPages}
703
+ {contentHealth.label}
296
704
  </span>
297
- <button
298
- onClick={() =>
299
- setActivityPage(Math.min(totalActivityPages - 1, activityPage + 1))
300
- }
301
- disabled={activityPage >= totalActivityPages - 1}
302
- className="p-1 rounded hover:bg-gray-100 disabled:opacity-30"
303
- >
304
- <ChevronRight className="w-3.5 h-3.5" />
305
- </button>
705
+ </div>
706
+ <div className="bg-muted h-1.5 overflow-hidden rounded-full">
707
+ <div
708
+ className={`h-full rounded-full transition-all ${
709
+ contentHealth.tone === 'ok'
710
+ ? 'bg-emerald-500'
711
+ : contentHealth.tone === 'warn'
712
+ ? 'bg-amber-500'
713
+ : contentHealth.tone === 'err'
714
+ ? 'bg-red-500'
715
+ : 'bg-muted-foreground/40'
716
+ }`}
717
+ style={{ width: `${Math.max(0, Math.min(100, contentHealth.score))}%` }}
718
+ />
306
719
  </div>
307
720
  </div>
308
- )}
309
- </div>
721
+ {contentHealth.issues.length === 0 ? (
722
+ <div className="text-muted-foreground px-4 pt-1 pb-4 text-xs">
723
+ No outstanding issues. Nice work.
724
+ </div>
725
+ ) : (
726
+ <ul role="list" className="divide-border divide-y">
727
+ {contentHealth.issues.map((iss, i) => (
728
+ <li
729
+ key={i}
730
+ className="hover:bg-accent/50 flex cursor-pointer items-center gap-2.5 px-4 py-2 transition-colors"
731
+ onClick={() => nav('/seo')}
732
+ >
733
+ <span
734
+ className={`h-1.5 w-1.5 shrink-0 rounded-full ${
735
+ iss.tone === 'err'
736
+ ? 'bg-red-500'
737
+ : iss.tone === 'warn'
738
+ ? 'bg-amber-500'
739
+ : 'bg-muted-foreground'
740
+ }`}
741
+ aria-hidden
742
+ />
743
+ <span className="text-muted-foreground flex-1 truncate text-xs">
744
+ {iss.label}
745
+ </span>
746
+ <span
747
+ className={`shrink-0 text-sm font-semibold ${
748
+ iss.tone === 'err'
749
+ ? 'text-red-600 dark:text-red-400'
750
+ : iss.tone === 'warn'
751
+ ? 'text-amber-600 dark:text-amber-400'
752
+ : 'text-muted-foreground'
753
+ }`}
754
+ >
755
+ {iss.count}
756
+ </span>
757
+ <ChevronRight className="text-muted-foreground/60 h-3.5 w-3.5 shrink-0" />
758
+ </li>
759
+ ))}
760
+ </ul>
761
+ )}
762
+ </section>
763
+ </aside>
764
+ </div>
310
765
 
311
- {/* Content Overview */}
312
- <div className="lg:col-span-4 bg-white rounded-xl border border-gray-200">
313
- <div className="p-4 border-b border-gray-200">
314
- <h2 className="text-sm font-semibold text-gray-900">Content Overview</h2>
315
- </div>
316
- <div className="p-4 flex items-center justify-center min-h-[220px]">
317
- <ContentOverviewChart
318
- published={statusCounts['PUBLISHED'] ?? 0}
319
- drafts={statusCounts['DRAFT'] ?? 0}
320
- scheduled={statusCounts['SCHEDULED'] ?? 0}
321
- />
766
+ {/* Content Delivery ──────────────────────────────────────────── */}
767
+ <section className="bg-card border-border overflow-hidden rounded-xl border shadow-sm">
768
+ <header className="border-border flex items-center justify-between border-b px-4 py-3">
769
+ <div className="min-w-0">
770
+ <h2 className="text-foreground text-sm font-semibold">Content Delivery</h2>
771
+ <p className="text-muted-foreground mt-0.5 text-xs">Real-time platform activity</p>
322
772
  </div>
773
+ <button
774
+ className="inline-flex items-center gap-1 text-xs font-medium text-violet-600 hover:underline dark:text-violet-400"
775
+ onClick={() => nav('/api-keys')}
776
+ >
777
+ API docs <ExternalLink className="h-3 w-3" />
778
+ </button>
779
+ </header>
780
+ <div className="divide-border grid grid-cols-2 divide-x divide-y lg:grid-cols-4 lg:divide-y-0">
781
+ <DeliveryTile
782
+ icon={Activity}
783
+ label="Total Documents"
784
+ value={delivery.totalDocs.toLocaleString()}
785
+ sub="across all collections"
786
+ />
787
+ <DeliveryTile
788
+ icon={Clock}
789
+ label="Scheduled Posts"
790
+ value={delivery.scheduled.toLocaleString()}
791
+ sub={delivery.scheduled > 0 ? 'in publishing queue' : 'none queued'}
792
+ tone={delivery.scheduled > 0 ? 'ok' : 'muted'}
793
+ />
794
+ <DeliveryTile
795
+ icon={ClipboardList}
796
+ label="Form Responses"
797
+ value={delivery.forms.toLocaleString()}
798
+ sub={delivery.forms > 0 ? 'total received' : 'none yet'}
799
+ tone={delivery.forms > 0 ? 'ok' : 'muted'}
800
+ />
801
+ <DeliveryTile
802
+ icon={Zap}
803
+ label="Active Webhooks"
804
+ value={`${delivery.webhooks.active} / ${delivery.webhooks.total || 0}`}
805
+ sub={
806
+ delivery.webhooks.total === 0
807
+ ? 'none configured'
808
+ : delivery.webhooks.active === delivery.webhooks.total
809
+ ? 'all active'
810
+ : `${delivery.webhooks.total - delivery.webhooks.active} paused`
811
+ }
812
+ tone={
813
+ delivery.webhooks.total === 0
814
+ ? 'muted'
815
+ : delivery.webhooks.active === delivery.webhooks.total
816
+ ? 'ok'
817
+ : 'warn'
818
+ }
819
+ />
323
820
  </div>
324
- </div>
821
+ </section>
822
+ </div>
823
+ )
824
+ }
825
+
826
+ // ─── Sub-components (kept in-file: smaller bundle, no extra module hops) ────
325
827
 
326
- {/* SEO Performance */}
327
- <SEOPerformance onNavigate={nav} />
828
+ function EmptyState({
829
+ icon: Icon,
830
+ title,
831
+ subtitle,
832
+ compact = false,
833
+ }: {
834
+ icon: LucideIcon
835
+ title: string
836
+ subtitle: string
837
+ compact?: boolean
838
+ }) {
839
+ return (
840
+ <div
841
+ className={`flex flex-col items-center justify-center gap-1.5 text-center ${compact ? 'px-4 py-6' : 'px-6 py-10'}`}
842
+ >
843
+ <Icon className="text-muted-foreground/50 mb-1 h-7 w-7" aria-hidden />
844
+ <p className="text-foreground text-sm font-semibold">{title}</p>
845
+ <p className="text-muted-foreground max-w-xs text-xs">{subtitle}</p>
846
+ </div>
847
+ )
848
+ }
849
+
850
+ function DeliveryTile({
851
+ icon: Icon,
852
+ label,
853
+ value,
854
+ sub,
855
+ tone = 'muted',
856
+ }: {
857
+ icon: LucideIcon
858
+ label: string
859
+ value: string
860
+ sub?: string
861
+ tone?: 'ok' | 'warn' | 'err' | 'muted'
862
+ }) {
863
+ const subTone =
864
+ tone === 'ok'
865
+ ? 'text-emerald-600 dark:text-emerald-400'
866
+ : tone === 'warn'
867
+ ? 'text-amber-600 dark:text-amber-400'
868
+ : tone === 'err'
869
+ ? 'text-red-600 dark:text-red-400'
870
+ : 'text-muted-foreground'
871
+ return (
872
+ <div className="px-4 py-3.5">
873
+ <div className="text-muted-foreground mb-1 flex items-center gap-1.5">
874
+ <Icon className="h-3.5 w-3.5" aria-hidden />
875
+ <span className="text-[11px] font-medium">{label}</span>
876
+ </div>
877
+ <p className="text-foreground text-xl leading-tight font-semibold tracking-tight">{value}</p>
878
+ {sub && <p className={`mt-0.5 text-[11px] ${subTone}`}>{sub}</p>}
328
879
  </div>
329
880
  )
330
881
  }