@actuate-media/cms-admin 0.64.0 → 0.66.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 (196) hide show
  1. package/CHANGELOG.md +28 -0
  2. package/dist/__tests__/components/ui-primitives.test.d.ts +2 -0
  3. package/dist/__tests__/components/ui-primitives.test.d.ts.map +1 -0
  4. package/dist/__tests__/components/ui-primitives.test.js +45 -0
  5. package/dist/__tests__/components/ui-primitives.test.js.map +1 -0
  6. package/dist/__tests__/views/updates-tab.render.test.js +1 -1
  7. package/dist/__tests__/views/updates-tab.render.test.js.map +1 -1
  8. package/dist/actuate-admin.css +1 -1
  9. package/dist/components/SEOPanel.d.ts.map +1 -1
  10. package/dist/components/SEOPanel.js +2 -1
  11. package/dist/components/SEOPanel.js.map +1 -1
  12. package/dist/components/forms/primitives.d.ts.map +1 -1
  13. package/dist/components/forms/primitives.js +5 -8
  14. package/dist/components/forms/primitives.js.map +1 -1
  15. package/dist/components/posts/badges.d.ts.map +1 -1
  16. package/dist/components/posts/badges.js +7 -13
  17. package/dist/components/posts/badges.js.map +1 -1
  18. package/dist/components/seo/RedirectEditorDrawer.d.ts.map +1 -1
  19. package/dist/components/seo/RedirectEditorDrawer.js +2 -1
  20. package/dist/components/seo/RedirectEditorDrawer.js.map +1 -1
  21. package/dist/components/seo/SeoEditorDrawer.d.ts.map +1 -1
  22. package/dist/components/seo/SeoEditorDrawer.js +3 -2
  23. package/dist/components/seo/SeoEditorDrawer.js.map +1 -1
  24. package/dist/components/seo/primitives.d.ts.map +1 -1
  25. package/dist/components/seo/primitives.js +9 -14
  26. package/dist/components/seo/primitives.js.map +1 -1
  27. package/dist/components/ui/FilterPill.d.ts +14 -0
  28. package/dist/components/ui/FilterPill.d.ts.map +1 -0
  29. package/dist/components/ui/FilterPill.js +8 -0
  30. package/dist/components/ui/FilterPill.js.map +1 -0
  31. package/dist/components/ui/RowActions.d.ts +19 -0
  32. package/dist/components/ui/RowActions.d.ts.map +1 -0
  33. package/dist/components/ui/RowActions.js +16 -0
  34. package/dist/components/ui/RowActions.js.map +1 -0
  35. package/dist/components/ui/StatPill.d.ts +18 -0
  36. package/dist/components/ui/StatPill.d.ts.map +1 -0
  37. package/dist/components/ui/StatPill.js +5 -0
  38. package/dist/components/ui/StatPill.js.map +1 -0
  39. package/dist/components/ui/StatusBadge.d.ts +25 -0
  40. package/dist/components/ui/StatusBadge.d.ts.map +1 -0
  41. package/dist/components/ui/StatusBadge.js +41 -0
  42. package/dist/components/ui/StatusBadge.js.map +1 -0
  43. package/dist/components/ui/index.d.ts +8 -0
  44. package/dist/components/ui/index.d.ts.map +1 -1
  45. package/dist/components/ui/index.js +4 -0
  46. package/dist/components/ui/index.js.map +1 -1
  47. package/dist/fields/PropInput.d.ts.map +1 -1
  48. package/dist/fields/PropInput.js +2 -1
  49. package/dist/fields/PropInput.js.map +1 -1
  50. package/dist/fields/ToggleField.d.ts.map +1 -1
  51. package/dist/fields/ToggleField.js +2 -1
  52. package/dist/fields/ToggleField.js.map +1 -1
  53. package/dist/layout/Header.js +1 -1
  54. package/dist/layout/Header.js.map +1 -1
  55. package/dist/layout/Layout.js +8 -38
  56. package/dist/layout/Layout.js.map +1 -1
  57. package/dist/layout/primitives/PageContainer.d.ts.map +1 -1
  58. package/dist/layout/primitives/PageContainer.js +1 -1
  59. package/dist/layout/primitives/PageContainer.js.map +1 -1
  60. package/dist/lib/pages-service.d.ts +9 -0
  61. package/dist/lib/pages-service.d.ts.map +1 -1
  62. package/dist/lib/pages-service.js +14 -0
  63. package/dist/lib/pages-service.js.map +1 -1
  64. package/dist/lib/posts-service.d.ts +11 -0
  65. package/dist/lib/posts-service.d.ts.map +1 -1
  66. package/dist/lib/posts-service.js +13 -0
  67. package/dist/lib/posts-service.js.map +1 -1
  68. package/dist/lib/seo-service.d.ts +3 -0
  69. package/dist/lib/seo-service.d.ts.map +1 -1
  70. package/dist/lib/seo-service.js.map +1 -1
  71. package/dist/views/Dashboard.d.ts.map +1 -1
  72. package/dist/views/Dashboard.js +9 -1
  73. package/dist/views/Dashboard.js.map +1 -1
  74. package/dist/views/FormEditor.d.ts.map +1 -1
  75. package/dist/views/FormEditor.js +2 -1
  76. package/dist/views/FormEditor.js.map +1 -1
  77. package/dist/views/FormEntries.js +1 -1
  78. package/dist/views/FormEntries.js.map +1 -1
  79. package/dist/views/FormSubmissions.js +1 -1
  80. package/dist/views/FormSubmissions.js.map +1 -1
  81. package/dist/views/Forms.js +1 -1
  82. package/dist/views/Forms.js.map +1 -1
  83. package/dist/views/MediaBrowser.d.ts.map +1 -1
  84. package/dist/views/MediaBrowser.js +30 -2
  85. package/dist/views/MediaBrowser.js.map +1 -1
  86. package/dist/views/Pages/PageTagManager.d.ts.map +1 -1
  87. package/dist/views/Pages/PageTagManager.js +1 -1
  88. package/dist/views/Pages/PageTagManager.js.map +1 -1
  89. package/dist/views/Pages/PagesListView.d.ts.map +1 -1
  90. package/dist/views/Pages/PagesListView.js +59 -10
  91. package/dist/views/Pages/PagesListView.js.map +1 -1
  92. package/dist/views/Pages.js +1 -1
  93. package/dist/views/Pages.js.map +1 -1
  94. package/dist/views/Posts/NewPostPicker.d.ts.map +1 -1
  95. package/dist/views/Posts/NewPostPicker.js +1 -1
  96. package/dist/views/Posts/NewPostPicker.js.map +1 -1
  97. package/dist/views/Posts/NewPostTypeInstructions.d.ts.map +1 -1
  98. package/dist/views/Posts/NewPostTypeInstructions.js +1 -1
  99. package/dist/views/Posts/NewPostTypeInstructions.js.map +1 -1
  100. package/dist/views/Posts/PostTypesView.d.ts.map +1 -1
  101. package/dist/views/Posts/PostTypesView.js +1 -1
  102. package/dist/views/Posts/PostTypesView.js.map +1 -1
  103. package/dist/views/Posts/PostsListView.d.ts.map +1 -1
  104. package/dist/views/Posts/PostsListView.js +66 -9
  105. package/dist/views/Posts/PostsListView.js.map +1 -1
  106. package/dist/views/Profile.js +1 -1
  107. package/dist/views/Profile.js.map +1 -1
  108. package/dist/views/Redirects.js +1 -1
  109. package/dist/views/Redirects.js.map +1 -1
  110. package/dist/views/SEO.js +1 -1
  111. package/dist/views/SEO.js.map +1 -1
  112. package/dist/views/ScriptTagEditor.d.ts.map +1 -1
  113. package/dist/views/ScriptTagEditor.js +1 -1
  114. package/dist/views/ScriptTagEditor.js.map +1 -1
  115. package/dist/views/ScriptTags.d.ts.map +1 -1
  116. package/dist/views/ScriptTags.js +2 -1
  117. package/dist/views/ScriptTags.js.map +1 -1
  118. package/dist/views/Settings.js +1 -1
  119. package/dist/views/Settings.js.map +1 -1
  120. package/dist/views/Users.d.ts.map +1 -1
  121. package/dist/views/Users.js +2 -6
  122. package/dist/views/Users.js.map +1 -1
  123. package/dist/views/page-builder/BlockEditor.d.ts.map +1 -1
  124. package/dist/views/page-builder/BlockEditor.js +2 -2
  125. package/dist/views/page-builder/BlockEditor.js.map +1 -1
  126. package/dist/views/page-builder/NodeSettings.js +3 -3
  127. package/dist/views/page-builder/NodeSettings.js.map +1 -1
  128. package/dist/views/page-builder/PageTemplates.js +1 -1
  129. package/dist/views/page-builder/PageTemplates.js.map +1 -1
  130. package/dist/views/page-builder/SavedSections.js +2 -2
  131. package/dist/views/page-builder/SavedSections.js.map +1 -1
  132. package/dist/views/page-builder/Widgets.js +1 -1
  133. package/dist/views/page-builder/Widgets.js.map +1 -1
  134. package/dist/views/page-editor/SectionInspector.d.ts.map +1 -1
  135. package/dist/views/page-editor/SectionInspector.js +2 -1
  136. package/dist/views/page-editor/SectionInspector.js.map +1 -1
  137. package/dist/views/post-editor/PostHeaderPanel.d.ts.map +1 -1
  138. package/dist/views/post-editor/PostHeaderPanel.js +2 -1
  139. package/dist/views/post-editor/PostHeaderPanel.js.map +1 -1
  140. package/dist/views/seo/TechnicalTab.d.ts.map +1 -1
  141. package/dist/views/seo/TechnicalTab.js +15 -0
  142. package/dist/views/seo/TechnicalTab.js.map +1 -1
  143. package/dist/views/settings/components.d.ts.map +1 -1
  144. package/dist/views/settings/components.js +2 -1
  145. package/dist/views/settings/components.js.map +1 -1
  146. package/package.json +2 -2
  147. package/src/__tests__/components/ui-primitives.test.tsx +58 -0
  148. package/src/__tests__/views/updates-tab.render.test.tsx +2 -1
  149. package/src/components/SEOPanel.tsx +2 -11
  150. package/src/components/forms/primitives.tsx +6 -16
  151. package/src/components/posts/badges.tsx +8 -21
  152. package/src/components/seo/RedirectEditorDrawer.tsx +12 -16
  153. package/src/components/seo/SeoEditorDrawer.tsx +16 -24
  154. package/src/components/seo/primitives.tsx +10 -27
  155. package/src/components/ui/FilterPill.tsx +43 -0
  156. package/src/components/ui/RowActions.tsx +50 -0
  157. package/src/components/ui/StatPill.tsx +37 -0
  158. package/src/components/ui/StatusBadge.tsx +66 -0
  159. package/src/components/ui/index.ts +8 -0
  160. package/src/fields/PropInput.tsx +6 -15
  161. package/src/fields/ToggleField.tsx +3 -15
  162. package/src/layout/Header.tsx +2 -2
  163. package/src/layout/Layout.tsx +8 -80
  164. package/src/layout/primitives/PageContainer.tsx +1 -5
  165. package/src/lib/pages-service.ts +18 -0
  166. package/src/lib/posts-service.ts +26 -0
  167. package/src/lib/seo-service.ts +3 -0
  168. package/src/views/Dashboard.tsx +48 -18
  169. package/src/views/FormEditor.tsx +6 -9
  170. package/src/views/FormEntries.tsx +1 -1
  171. package/src/views/FormSubmissions.tsx +1 -1
  172. package/src/views/Forms.tsx +1 -1
  173. package/src/views/MediaBrowser.tsx +36 -8
  174. package/src/views/Pages/PageTagManager.tsx +1 -3
  175. package/src/views/Pages/PagesListView.tsx +115 -10
  176. package/src/views/Pages.tsx +1 -1
  177. package/src/views/Posts/NewPostPicker.tsx +1 -3
  178. package/src/views/Posts/NewPostTypeInstructions.tsx +1 -3
  179. package/src/views/Posts/PostTypesView.tsx +1 -3
  180. package/src/views/Posts/PostsListView.tsx +135 -10
  181. package/src/views/Profile.tsx +1 -1
  182. package/src/views/Redirects.tsx +1 -1
  183. package/src/views/SEO.tsx +1 -1
  184. package/src/views/ScriptTagEditor.tsx +1 -3
  185. package/src/views/ScriptTags.tsx +17 -1
  186. package/src/views/Settings.tsx +1 -1
  187. package/src/views/Users.tsx +6 -18
  188. package/src/views/page-builder/BlockEditor.tsx +3 -6
  189. package/src/views/page-builder/NodeSettings.tsx +7 -16
  190. package/src/views/page-builder/PageTemplates.tsx +1 -1
  191. package/src/views/page-builder/SavedSections.tsx +2 -2
  192. package/src/views/page-builder/Widgets.tsx +1 -1
  193. package/src/views/page-editor/SectionInspector.tsx +9 -21
  194. package/src/views/post-editor/PostHeaderPanel.tsx +10 -20
  195. package/src/views/seo/TechnicalTab.tsx +15 -0
  196. package/src/views/settings/components.tsx +8 -16
@@ -0,0 +1,66 @@
1
+ import type { HTMLAttributes, ReactNode } from 'react'
2
+ import { cv, type VariantProps } from '../../lib/cv.js'
3
+
4
+ /**
5
+ * Canonical status chip from the design system (`.status` + `.status-dot`):
6
+ * a rectangular 5px-radius chip with a 5px dot, 11.5px medium text, and
7
+ * tint backgrounds per tone. This is the single source of truth for
8
+ * document / entity statuses across Posts, Pages, Forms, SEO, and tables —
9
+ * do not roll per-view status pills.
10
+ */
11
+ const statusBadge = cv(
12
+ 'inline-flex items-center gap-[5px] rounded-[5px] px-[9px] py-[3px] text-[length:var(--fs-hint)] font-medium whitespace-nowrap',
13
+ {
14
+ variants: {
15
+ tone: {
16
+ /** Published / Active — success tint (`.s-pub`). */
17
+ success: 'bg-[var(--suc-l)] text-[var(--suc)]',
18
+ /** Scheduled / Pending — warning tint (`.s-sch`). */
19
+ warning: 'bg-[var(--warn-l)] text-[var(--warn)]',
20
+ /** Errors / failing states. */
21
+ danger: 'bg-[var(--err-l)] text-[var(--err)]',
22
+ /** Informational states. */
23
+ info: 'bg-[var(--info-l)] text-[var(--info)]',
24
+ /** Draft — page background + border (`.s-dft`). */
25
+ neutral: 'border border-[var(--bdr)] bg-[var(--bg)] text-[var(--sub)]',
26
+ /** Archived — dimmest treatment (`.s-arc`). */
27
+ muted: 'border border-[var(--bdr)] bg-[var(--bg)] text-[var(--muted)]',
28
+ },
29
+ },
30
+ defaultVariants: { tone: 'neutral' },
31
+ },
32
+ )
33
+
34
+ const DOT_CLASS: Record<NonNullable<StatusBadgeVariants['tone']>, string> = {
35
+ success: 'bg-[var(--suc)]',
36
+ warning: 'bg-[var(--warn)]',
37
+ danger: 'bg-[var(--err)]',
38
+ info: 'bg-[var(--info)]',
39
+ neutral: 'bg-[var(--muted)]',
40
+ muted: 'bg-[var(--muted)]',
41
+ }
42
+
43
+ export type StatusBadgeVariants = VariantProps<typeof statusBadge>
44
+ export type StatusBadgeTone = NonNullable<StatusBadgeVariants['tone']>
45
+
46
+ export interface StatusBadgeProps extends HTMLAttributes<HTMLSpanElement>, StatusBadgeVariants {
47
+ /** Hide the leading dot (rare — e.g. numeric chips). */
48
+ dot?: boolean
49
+ children: ReactNode
50
+ }
51
+
52
+ export function StatusBadge({ tone, dot = true, className, children, ...rest }: StatusBadgeProps) {
53
+ return (
54
+ <span className={statusBadge({ tone, class: className })} {...rest}>
55
+ {dot && (
56
+ <span
57
+ aria-hidden
58
+ className={`h-[5px] w-[5px] shrink-0 rounded-full ${DOT_CLASS[tone ?? 'neutral']}`}
59
+ />
60
+ )}
61
+ {children}
62
+ </span>
63
+ )
64
+ }
65
+
66
+ export { statusBadge as statusBadgeVariants }
@@ -36,6 +36,14 @@ export { SearchInput } from './SearchInput.js'
36
36
  export type { SearchInputProps } from './SearchInput.js'
37
37
  export { Toggle, toggleTrackVariants, toggleThumbVariants } from './Toggle.js'
38
38
  export type { ToggleProps, ToggleVariants } from './Toggle.js'
39
+ export { StatusBadge, statusBadgeVariants } from './StatusBadge.js'
40
+ export type { StatusBadgeProps, StatusBadgeVariants, StatusBadgeTone } from './StatusBadge.js'
41
+ export { FilterPill } from './FilterPill.js'
42
+ export type { FilterPillProps } from './FilterPill.js'
43
+ export { StatPill } from './StatPill.js'
44
+ export type { StatPillProps } from './StatPill.js'
45
+ export { RowActions, RowActionButton } from './RowActions.js'
46
+ export type { RowActionButtonProps } from './RowActions.js'
39
47
  export { formControlBase, formControlState } from './form-control.js'
40
48
  export { CommandPalette } from './CommandPalette.js'
41
49
  export type { CommandPaletteProps } from './CommandPalette.js'
@@ -38,6 +38,7 @@ import {
38
38
  safeJsonStringify,
39
39
  switchUnionVariant,
40
40
  } from './component-block-helpers.js'
41
+ import { Toggle } from '../components/ui/Toggle.js'
41
42
 
42
43
  export { defaultForType }
43
44
 
@@ -249,21 +250,11 @@ function BooleanInput({
249
250
  return (
250
251
  <div className="flex items-center justify-between text-sm">
251
252
  <span>{label}</span>
252
- <button
253
- type="button"
254
- role="switch"
255
- aria-checked={boolVal}
256
- onClick={() => onChange(!boolVal)}
257
- className={`relative inline-flex h-6 w-11 shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors ${
258
- boolVal ? 'bg-[var(--primary)]' : 'bg-[var(--muted)]'
259
- }`}
260
- >
261
- <span
262
- className={`bg-card pointer-events-none inline-block h-5 w-5 rounded-full shadow-sm ring-0 transition-transform ${
263
- boolVal ? 'translate-x-5' : 'translate-x-0'
264
- }`}
265
- />
266
- </button>
253
+ <Toggle
254
+ checked={boolVal}
255
+ onCheckedChange={(v) => onChange(v)}
256
+ aria-label={typeof label === 'string' ? label : undefined}
257
+ />
267
258
  </div>
268
259
  )
269
260
  }
@@ -1,5 +1,7 @@
1
1
  'use client'
2
2
 
3
+ import { Toggle } from '../components/ui/Toggle.js'
4
+
3
5
  export interface ToggleFieldProps {
4
6
  label: string
5
7
  value?: boolean
@@ -14,21 +16,7 @@ export function ToggleField({ label, value = false, onChange, helpText }: Toggle
14
16
  <span className="text-sm font-medium">{label}</span>
15
17
  {helpText && <p className="text-xs text-[var(--muted-foreground)]">{helpText}</p>}
16
18
  </div>
17
- <button
18
- type="button"
19
- role="switch"
20
- aria-checked={value}
21
- onClick={() => onChange(!value)}
22
- className={`relative inline-flex h-6 w-11 shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors ${
23
- value ? 'bg-[var(--primary)]' : 'bg-[var(--muted)]'
24
- }`}
25
- >
26
- <span
27
- className={`bg-card pointer-events-none inline-block h-5 w-5 rounded-full shadow-sm ring-0 transition-transform ${
28
- value ? 'translate-x-5' : 'translate-x-0'
29
- }`}
30
- />
31
- </button>
19
+ <Toggle checked={value} onCheckedChange={onChange} aria-label={label} />
32
20
  </div>
33
21
  )
34
22
  }
@@ -127,8 +127,8 @@ export function Header({
127
127
  className="hover:bg-accent flex items-center gap-1.5 rounded-lg p-1 transition-colors"
128
128
  aria-label="Account menu"
129
129
  >
130
- <div className="flex h-8 w-8 items-center justify-center rounded-full bg-linear-to-br from-blue-500 to-purple-600">
131
- <span className="text-sm font-medium text-white">{initial}</span>
130
+ <div className="flex h-8 w-8 items-center justify-center rounded-full bg-[var(--acc)]">
131
+ <span className="text-xs font-semibold text-white">{initial}</span>
132
132
  </div>
133
133
  <ChevronDown
134
134
  className="text-muted-foreground hidden h-4 w-4 sm:block"
@@ -1,7 +1,7 @@
1
1
  'use client'
2
2
 
3
3
  import { useMemo, useState, useEffect, type ReactNode } from 'react'
4
- import { ExternalLink, Plus } from 'lucide-react'
4
+ import { Plus } from 'lucide-react'
5
5
  import { Sidebar } from './Sidebar.js'
6
6
  import { Header } from './Header.js'
7
7
  import { Breadcrumbs } from '../components/Breadcrumbs.js'
@@ -102,8 +102,9 @@ interface PageActionsContext {
102
102
  * primary/secondary pair that mirrors the reference dashboard.
103
103
  *
104
104
  * Resolution rules:
105
- * - Dashboard / home"+ New Post" + "View Site"
106
- * - List views just "+ New <Type>"
105
+ * - Dashboard / Posts / Pages nothing here; those views render their
106
+ * hero CTAs inside the page header per the design
107
+ * - Other list views → just "+ New <Type>"
107
108
  * - Editor views → no top-bar action (editor renders its own toolbar)
108
109
  */
109
110
  function computePageActions({
@@ -112,56 +113,12 @@ function computePageActions({
112
113
  onNavigate,
113
114
  }: PageActionsContext): ReactNode | null {
114
115
  const collections = collectionList(config)
115
- const postCollections = collections.filter((c) => c.type === 'post')
116
- const newPostPath = resolveNewPostPath(postCollections)
117
- const siteUrl: string | null = config?.site?.url ?? config?.seo?.siteUrl ?? null
118
116
 
117
+ // Dashboard, /posts, and /pages render their own hero CTAs in the page
118
+ // header (design `.page-hdr` → `.hdr-r`), so the top bar stays empty there.
119
119
  const isDashboard = currentPath === '/' || currentPath === '/dashboard'
120
-
121
- // Dashboard: keep the existing "+ New Post" / "View Site" pair. With
122
- // a single Post Type we route straight to its editor; with several we
123
- // route to the picker so the user can choose the type first.
124
- if (isDashboard) {
125
- return (
126
- <>
127
- <button
128
- type="button"
129
- onClick={() => onNavigate(newPostPath)}
130
- className="bg-primary text-primary-foreground hover:bg-primary/90 focus-visible:ring-ring inline-flex items-center gap-1.5 rounded-lg px-3.5 py-1.5 text-sm font-medium shadow-sm transition-colors focus:outline-none focus-visible:ring-2"
131
- >
132
- <Plus className="h-3.5 w-3.5" aria-hidden />
133
- New Post
134
- </button>
135
- {siteUrl ? (
136
- <a
137
- href={siteUrl}
138
- target="_blank"
139
- rel="noopener noreferrer"
140
- className="border-border bg-background text-foreground hover:bg-accent inline-flex items-center gap-1.5 rounded-lg border px-3 py-1.5 text-sm font-medium transition-colors"
141
- >
142
- <ExternalLink className="h-3.5 w-3.5" aria-hidden />
143
- View Site
144
- </a>
145
- ) : null}
146
- </>
147
- )
148
- }
149
-
150
- // Posts area — "+ New Post" + "Manage types" on the list page;
151
- // "+ New Post Type" on the types page. The buttons live in the top
152
- // bar so the page header stays clean. See the dashboard rule for
153
- // why these come from a single config-aware helper.
154
- if (currentPath === '/posts') {
155
- return (
156
- <button
157
- type="button"
158
- onClick={() => onNavigate(newPostPath)}
159
- className="bg-primary text-primary-foreground hover:bg-primary/90 focus-visible:ring-ring inline-flex items-center gap-1.5 rounded-lg px-3.5 py-1.5 text-sm font-medium shadow-sm transition-colors focus:outline-none focus-visible:ring-2"
160
- >
161
- <Plus className="h-3.5 w-3.5" aria-hidden />
162
- New Post
163
- </button>
164
- )
120
+ if (isDashboard || currentPath === '/posts' || currentPath === '/pages') {
121
+ return null
165
122
  }
166
123
 
167
124
  if (currentPath === '/posts/types') {
@@ -177,22 +134,6 @@ function computePageActions({
177
134
  )
178
135
  }
179
136
 
180
- // Pages list — "+ New page" routes to the page creation flow. Handled
181
- // explicitly (not via the collection loop) so the label/route stay
182
- // exact even though `pages` is a reserved route.
183
- if (currentPath === '/pages') {
184
- return (
185
- <button
186
- type="button"
187
- onClick={() => onNavigate('/pages/new')}
188
- className="bg-primary text-primary-foreground hover:bg-primary/90 focus-visible:ring-ring inline-flex items-center gap-1.5 rounded-lg px-3.5 py-1.5 text-sm font-medium shadow-sm transition-colors focus:outline-none focus-visible:ring-2"
189
- >
190
- <Plus className="h-3.5 w-3.5" aria-hidden />
191
- New page
192
- </button>
193
- )
194
- }
195
-
196
137
  // List views — surface "+ New X" so authors can keep creating without
197
138
  // hunting for a per-list button. Skip on editor views; those have their
198
139
  // own toolbar.
@@ -221,19 +162,6 @@ interface CollectionMeta {
221
162
  admin?: { hidden?: boolean }
222
163
  }
223
164
 
224
- /**
225
- * Where "+ New Post" should land, given the configured post-type
226
- * collections:
227
- * - exactly one → straight to that type's editor (`/<slug>/new`)
228
- * - more than one → the type picker (`/posts/new`)
229
- * - none → the Post Type setup instructions
230
- */
231
- function resolveNewPostPath(postCollections: CollectionMeta[]): string {
232
- if (postCollections.length === 1) return `/${postCollections[0]!.slug}/new`
233
- if (postCollections.length > 1) return '/posts/new'
234
- return '/posts/types/new'
235
- }
236
-
237
165
  function collectionList(config: any): CollectionMeta[] {
238
166
  if (!config?.collections) return []
239
167
  const raw = config.collections
@@ -78,11 +78,7 @@ export function PageContainer({
78
78
  <header className="flex flex-col gap-3 md:flex-row md:items-start md:justify-between">
79
79
  {(title || description) && (
80
80
  <div className="min-w-0">
81
- {title && (
82
- <h1 className="text-foreground truncate text-2xl font-medium tracking-tight">
83
- {title}
84
- </h1>
85
- )}
81
+ {title && <h1 className="text-foreground truncate">{title}</h1>}
86
82
  {description && <p className="text-muted-foreground mt-1 text-sm">{description}</p>}
87
83
  </div>
88
84
  )}
@@ -510,6 +510,24 @@ export async function bulkUpdatePages(
510
510
  })
511
511
  }
512
512
 
513
+ /** Duplicate a single page. Server resets status to DRAFT and appends "(Copy)". */
514
+ export async function duplicatePage(id: string): Promise<{ id?: string; error?: string }> {
515
+ const res = await cmsApi<DocumentApiRow>(
516
+ `/collections/${PAGES_COLLECTION}/${encodeURIComponent(id)}/duplicate`,
517
+ { method: 'POST' },
518
+ )
519
+ if (res.error) return { error: res.error }
520
+ return { id: res.data?.id }
521
+ }
522
+
523
+ /** Soft-delete a single page. */
524
+ export async function deletePage(id: string): Promise<{ error?: string }> {
525
+ const res = await cmsApi(`/collections/${PAGES_COLLECTION}/${encodeURIComponent(id)}`, {
526
+ method: 'DELETE',
527
+ })
528
+ return res.error ? { error: res.error } : {}
529
+ }
530
+
513
531
  export async function bulkDeletePages(ids: string[]): Promise<BulkResult> {
514
532
  return mapWithConcurrency(ids, 5, async (id) => {
515
533
  const res = await cmsApi(`/collections/${PAGES_COLLECTION}/${encodeURIComponent(id)}`, {
@@ -50,6 +50,8 @@ export interface Post {
50
50
  publishDate: string | null
51
51
  updatedDate: string
52
52
  seoScore: number | null
53
+ /** Featured-image URL for the list thumbnail, when the document has one. */
54
+ imageUrl: string | null
53
55
  createdAt: string
54
56
  updatedAt: string
55
57
  }
@@ -151,6 +153,7 @@ interface PostsApiPost {
151
153
  createdAt: string
152
154
  updatedAt: string
153
155
  seoScore: number | null
156
+ image?: string | null
154
157
  author: { id: string; name: string } | null
155
158
  }
156
159
 
@@ -240,6 +243,7 @@ export async function fetchPosts(query: PostsQuery = {}): Promise<PostsResult> {
240
243
  publishDate: d.publishedAt ?? d.scheduledAt ?? null,
241
244
  updatedDate: pickDisplayDate(d),
242
245
  seoScore: d.seoScore,
246
+ imageUrl: d.image ?? null,
243
247
  createdAt: d.createdAt,
244
248
  updatedAt: d.updatedAt,
245
249
  }
@@ -282,6 +286,28 @@ export async function bulkUpdatePosts(
282
286
  return { ok, failed }
283
287
  }
284
288
 
289
+ /** Duplicate a single post. Server resets status to DRAFT and appends "(Copy)". */
290
+ export async function duplicatePost(
291
+ postTypeId: string,
292
+ id: string,
293
+ ): Promise<{ id?: string; error?: string }> {
294
+ const res = await cmsApi<{ id?: string }>(
295
+ `/collections/${encodeURIComponent(postTypeId)}/${encodeURIComponent(id)}/duplicate`,
296
+ { method: 'POST' },
297
+ )
298
+ if (res.error) return { error: res.error }
299
+ return { id: res.data?.id }
300
+ }
301
+
302
+ /** Soft-delete a single post. */
303
+ export async function deletePost(postTypeId: string, id: string): Promise<{ error?: string }> {
304
+ const res = await cmsApi(
305
+ `/collections/${encodeURIComponent(postTypeId)}/${encodeURIComponent(id)}`,
306
+ { method: 'DELETE' },
307
+ )
308
+ return res.error ? { error: res.error } : {}
309
+ }
310
+
285
311
  export async function bulkDeletePosts(
286
312
  ids: Array<{ postTypeId: string; id: string }>,
287
313
  ): Promise<{ ok: number; failed: number }> {
@@ -635,6 +635,9 @@ export interface CrawlSettings {
635
635
  indexEntireSite: boolean
636
636
  includeImagesInSitemap: boolean
637
637
  includePostsInSitemap: boolean
638
+ sitemapEnabled: boolean
639
+ autoRegenerateSitemapOnPublish: boolean
640
+ pingSearchEnginesOnPublish: boolean
638
641
  }
639
642
 
640
643
  export async function fetchSitemapStatus(): Promise<SitemapStatus | null> {
@@ -287,6 +287,14 @@ export function Dashboard({ config, session, onNavigate }: DashboardProps) {
287
287
 
288
288
  const collections = useMemo(() => resolveCollections(config), [config])
289
289
 
290
+ // Hero CTA targets (mirrors the quick-action "New Post" resolution).
291
+ const heroNewPostPath = useMemo(() => {
292
+ const postTypes = collections.filter((c) => c.type === 'post')
293
+ if (postTypes.length === 1) return `/${postTypes[0]!.slug}/new`
294
+ return postTypes.length > 1 ? '/posts/new' : '/posts/types/new'
295
+ }, [collections])
296
+ const siteUrl: string | null = config?.site?.url ?? config?.seo?.siteUrl ?? null
297
+
290
298
  // ── Stat cards ──────────────────────────────────────────────────────────
291
299
  const statCards: StatCardData[] = useMemo(() => {
292
300
  const counts = stats?.collectionCounts ?? {}
@@ -561,24 +569,46 @@ export function Dashboard({ config, session, onNavigate }: DashboardProps) {
561
569
  )}
562
570
 
563
571
  {/* Hero greeting ─────────────────────────────────────────────────
564
- Primary "+ New Post" and "View Site" actions live in the top bar
565
- (Header.tsx page-action slot), not here, so the hero is purely
566
- informational and the dashboard's chrome-level affordances stay
567
- consistent across navigation. The on-page secondary action row
568
- below covers the same creation paths plus authoring shortcuts. */}
569
- <div className="flex flex-col gap-1 sm:gap-0.5">
570
- <h1 className="text-foreground text-xl font-medium tracking-tight sm:text-2xl">
571
- {greeting}, {userName} <span aria-hidden>👋</span>
572
- </h1>
573
- <p className="text-muted-foreground text-sm">
574
- {dateStr}
575
- {totalIssues > 0 && (
576
- <span className="hidden sm:inline">
577
- {' · '}
578
- {totalIssues} content item{totalIssues === 1 ? '' : 's'} need attention
579
- </span>
580
- )}
581
- </p>
572
+ Per the design, the primary "+ New Post" and "View Site" actions
573
+ sit on the right of the hero (`.dash-hdr` → `.dh-r`), not in the
574
+ top bar. The quick-action row below keeps the wider authoring
575
+ shortcuts. */}
576
+ <div className="flex flex-wrap items-start justify-between gap-3">
577
+ <div className="flex flex-col gap-1 sm:gap-0.5">
578
+ <h1 className="text-foreground">
579
+ {greeting}, {userName} <span aria-hidden>👋</span>
580
+ </h1>
581
+ <p className="text-muted-foreground text-sm">
582
+ {dateStr}
583
+ {totalIssues > 0 && (
584
+ <span className="hidden sm:inline">
585
+ {' · '}
586
+ {totalIssues} content item{totalIssues === 1 ? '' : 's'} need attention
587
+ </span>
588
+ )}
589
+ </p>
590
+ </div>
591
+ <div className="flex items-center gap-2">
592
+ <button
593
+ type="button"
594
+ onClick={() => nav(heroNewPostPath)}
595
+ className="bg-primary text-primary-foreground hover:bg-primary/90 focus-visible:ring-ring inline-flex items-center gap-1.5 rounded-lg px-3.5 py-1.5 text-sm font-medium shadow-sm transition-colors focus:outline-none focus-visible:ring-2"
596
+ >
597
+ <Plus className="h-3.5 w-3.5" aria-hidden />
598
+ New Post
599
+ </button>
600
+ {siteUrl ? (
601
+ <a
602
+ href={siteUrl}
603
+ target="_blank"
604
+ rel="noopener noreferrer"
605
+ className="border-border bg-card text-foreground hover:bg-accent inline-flex items-center gap-1.5 rounded-lg border px-3 py-1.5 text-sm font-medium transition-colors"
606
+ >
607
+ <ExternalLink className="h-3.5 w-3.5" aria-hidden />
608
+ View Site
609
+ </a>
610
+ ) : null}
611
+ </div>
582
612
  </div>
583
613
 
584
614
  {/* Quick actions ──────────────────────────────────────────────── */}
@@ -16,6 +16,7 @@ import {
16
16
  } from 'lucide-react'
17
17
  import { useApiData } from '../lib/useApiData.js'
18
18
  import { cmsApi } from '../lib/api.js'
19
+ import { Toggle } from '../components/ui/Toggle.js'
19
20
 
20
21
  interface FormField {
21
22
  id: string
@@ -463,15 +464,11 @@ export function FormEditor({ formId, onNavigate }: FormEditorProps) {
463
464
  {expandedSection === 'analytics' && (
464
465
  <div className="border-border border-t p-4">
465
466
  <div className="mb-4 flex items-center gap-3">
466
- <label className="relative inline-flex cursor-pointer items-center">
467
- <input
468
- type="checkbox"
469
- checked={analytics.enabled}
470
- onChange={(e) => setAnalytics((a) => ({ ...a, enabled: e.target.checked }))}
471
- className="peer sr-only"
472
- />
473
- <div className="bg-muted peer peer-checked:bg-primary peer-focus:ring-ring/50 after:border-border after:bg-card h-5 w-9 rounded-full peer-focus:ring-2 peer-focus:outline-none after:absolute after:top-[2px] after:left-[2px] after:h-4 after:w-4 after:rounded-full after:border after:transition-all after:content-[''] peer-checked:after:translate-x-full peer-checked:after:border-white" />
474
- </label>
467
+ <Toggle
468
+ checked={analytics.enabled}
469
+ onCheckedChange={(v) => setAnalytics((a) => ({ ...a, enabled: v }))}
470
+ aria-label="Push events to Google Analytics 4"
471
+ />
475
472
  <span className="text-foreground text-sm font-medium">
476
473
  Push events to Google Analytics 4
477
474
  </span>
@@ -234,7 +234,7 @@ export function FormEntries({ onNavigate }: FormEntriesProps) {
234
234
  <div className="space-y-5 p-6">
235
235
  <div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
236
236
  <div>
237
- <h1 className="text-foreground text-2xl font-medium">All Entries</h1>
237
+ <h1 className="text-foreground">All Entries</h1>
238
238
  <p className="text-muted-foreground mt-1 text-sm">
239
239
  {total} {total === 1 ? 'submission' : 'submissions'} across your forms
240
240
  </p>
@@ -254,7 +254,7 @@ export function FormSubmissions({ formId, onNavigate }: FormSubmissionsProps) {
254
254
  </button>
255
255
  <div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
256
256
  <div>
257
- <h1 className="text-foreground text-2xl font-medium">
257
+ <h1 className="text-foreground">
258
258
  {formName ? `${formName} — Submissions` : 'Submissions'}
259
259
  </h1>
260
260
  <p className="text-muted-foreground mt-1 text-sm">
@@ -158,7 +158,7 @@ export function Forms({ onNavigate }: FormsProps) {
158
158
  <div className="space-y-5 p-6">
159
159
  <header className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
160
160
  <div>
161
- <h1 className="text-foreground text-2xl font-medium">Forms</h1>
161
+ <h1 className="text-foreground">Forms</h1>
162
162
  <p className="text-muted-foreground mt-1 text-sm">
163
163
  Manage forms and view incoming submissions
164
164
  </p>
@@ -30,6 +30,8 @@ import { sortByRelevance, type SortConfig, toggleSort } from '../lib/search.js'
30
30
  import { useApiData } from '../lib/useApiData.js'
31
31
  import { cmsApi } from '../lib/api.js'
32
32
  import { FolderTree, type FolderSelection } from '../components/FolderTree.js'
33
+ import { StatusBadge } from '../components/ui/StatusBadge.js'
34
+ import { StatPill } from '../components/ui/StatPill.js'
33
35
 
34
36
  interface MediaItem {
35
37
  id: number | string
@@ -62,6 +64,14 @@ function matchesMediaType(item: MediaItem, filterType: string): boolean {
62
64
  return item.type === filterType
63
65
  }
64
66
 
67
+ function formatStorage(bytes: number): string {
68
+ if (bytes <= 0) return '0 B'
69
+ const units = ['B', 'KB', 'MB', 'GB', 'TB']
70
+ const i = Math.min(units.length - 1, Math.floor(Math.log2(bytes) / 10))
71
+ const value = bytes / 1024 ** i
72
+ return `${value >= 100 || i === 0 ? Math.round(value) : value.toFixed(1)} ${units[i]}`
73
+ }
74
+
65
75
  function MediaPreview({ item }: { item: MediaItem }) {
66
76
  if (isImageMedia(item)) {
67
77
  return (
@@ -149,6 +159,22 @@ export function MediaBrowser({ onNavigate }: MediaBrowserProps) {
149
159
  return results
150
160
  }, [mediaItems, searchQuery, filterType, sortConfig])
151
161
 
162
+ // Header stat pills (design `.mc-stats`): counts over the current folder
163
+ // scope, before search/type filters, so the numbers describe the library.
164
+ const mediaStats = useMemo(() => {
165
+ let images = 0
166
+ let videos = 0
167
+ let documents = 0
168
+ let bytes = 0
169
+ for (const item of mediaItems) {
170
+ if (item.type?.startsWith('image/') || isImageMedia(item)) images++
171
+ else if (item.type?.startsWith('video/')) videos++
172
+ else documents++
173
+ bytes += item.sizeBytes || 0
174
+ }
175
+ return { total: mediaItems.length, images, videos, documents, bytes }
176
+ }, [mediaItems])
177
+
152
178
  const openDetail = (item: MediaItem) => {
153
179
  setActiveItem(item)
154
180
  setEditAlt(item.altTag ?? '')
@@ -411,8 +437,14 @@ export function MediaBrowser({ onNavigate }: MediaBrowserProps) {
411
437
  <FolderInput className="text-muted-foreground h-5 w-5" />
412
438
  </button>
413
439
  <div>
414
- <h1 className="text-foreground mb-1 text-xl font-medium sm:text-2xl">Media Library</h1>
415
- <p className="text-muted-foreground text-sm">{filteredAndSorted.length} files</p>
440
+ <h1 className="text-foreground mb-1">Media Library</h1>
441
+ <div className="flex flex-wrap items-center gap-1.5">
442
+ <StatPill value={mediaStats.total} label="Total Files" />
443
+ <StatPill value={mediaStats.images} label="Images" />
444
+ <StatPill value={mediaStats.videos} label="Videos" />
445
+ <StatPill value={mediaStats.documents} label="Documents" />
446
+ <StatPill value={formatStorage(mediaStats.bytes)} label="Storage used" />
447
+ </div>
416
448
  </div>
417
449
  </div>
418
450
  <div>
@@ -722,13 +754,9 @@ export function MediaBrowser({ onNavigate }: MediaBrowserProps) {
722
754
  </td>
723
755
  <td className="px-3 py-2">
724
756
  {hasIssues ? (
725
- <span className="bg-warning/15 text-warning inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-xs font-medium">
726
- <AlertTriangle className="h-3 w-3" /> Needs attention
727
- </span>
757
+ <StatusBadge tone="warning">Needs attention</StatusBadge>
728
758
  ) : (
729
- <span className="bg-success/15 text-success inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium">
730
- Complete
731
- </span>
759
+ <StatusBadge tone="success">Complete</StatusBadge>
732
760
  )}
733
761
  </td>
734
762
  </tr>
@@ -85,9 +85,7 @@ export function PageTagManager({ onNavigate, startCreating = false }: PageTagMan
85
85
 
86
86
  <div className="mb-5 flex items-center justify-between">
87
87
  <div>
88
- <h1 className="text-foreground mb-1 text-xl font-medium tracking-tight sm:text-2xl">
89
- Page Tags
90
- </h1>
88
+ <h1 className="text-foreground mb-1">Page Tags</h1>
91
89
  <p className="text-muted-foreground text-sm">
92
90
  Organize pages with reusable, color-coded labels.
93
91
  </p>