@actuate-media/cms-admin 0.72.3 → 0.74.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 (178) hide show
  1. package/CHANGELOG.md +22 -0
  2. package/dist/AdminRoot.d.ts.map +1 -1
  3. package/dist/AdminRoot.js +3 -2
  4. package/dist/AdminRoot.js.map +1 -1
  5. package/dist/LoginRoot.d.ts.map +1 -1
  6. package/dist/LoginRoot.js +2 -1
  7. package/dist/LoginRoot.js.map +1 -1
  8. package/dist/__tests__/components/resizable-aside.test.d.ts +2 -0
  9. package/dist/__tests__/components/resizable-aside.test.d.ts.map +1 -0
  10. package/dist/__tests__/components/resizable-aside.test.js +77 -0
  11. package/dist/__tests__/components/resizable-aside.test.js.map +1 -0
  12. package/dist/__tests__/components/theme-provider-accent.test.d.ts +2 -0
  13. package/dist/__tests__/components/theme-provider-accent.test.d.ts.map +1 -0
  14. package/dist/__tests__/components/theme-provider-accent.test.js +64 -0
  15. package/dist/__tests__/components/theme-provider-accent.test.js.map +1 -0
  16. package/dist/__tests__/views/portal-container.test.js +13 -0
  17. package/dist/__tests__/views/portal-container.test.js.map +1 -1
  18. package/dist/__tests__/views/redirects-pane.render.test.js +6 -0
  19. package/dist/__tests__/views/redirects-pane.render.test.js.map +1 -1
  20. package/dist/__tests__/views/seo-content-pane.render.test.js +13 -4
  21. package/dist/__tests__/views/seo-content-pane.render.test.js.map +1 -1
  22. package/dist/actuate-admin.css +1 -1
  23. package/dist/components/CoauthorResultPopover.js +1 -1
  24. package/dist/components/EditorSeoAside.d.ts +20 -0
  25. package/dist/components/EditorSeoAside.d.ts.map +1 -0
  26. package/dist/components/EditorSeoAside.js +30 -0
  27. package/dist/components/EditorSeoAside.js.map +1 -0
  28. package/dist/components/MediaPickerModal.js +1 -1
  29. package/dist/components/SEOConfigPanel.js +1 -1
  30. package/dist/components/SchedulePublishDialog.js +1 -1
  31. package/dist/components/SharePreviewLinkDialog.js +1 -1
  32. package/dist/components/ThemeProvider.d.ts.map +1 -1
  33. package/dist/components/ThemeProvider.js +2 -1
  34. package/dist/components/ThemeProvider.js.map +1 -1
  35. package/dist/components/VersionHistory.js +1 -1
  36. package/dist/components/seo/SeoEditorPane.d.ts.map +1 -1
  37. package/dist/components/seo/SeoEditorPane.js +28 -8
  38. package/dist/components/seo/SeoEditorPane.js.map +1 -1
  39. package/dist/components/seo/SeoIssueFixPanel.d.ts.map +1 -1
  40. package/dist/components/seo/SeoIssueFixPanel.js +13 -3
  41. package/dist/components/seo/SeoIssueFixPanel.js.map +1 -1
  42. package/dist/components/seo/primitives.d.ts.map +1 -1
  43. package/dist/components/seo/primitives.js +3 -3
  44. package/dist/components/seo/primitives.js.map +1 -1
  45. package/dist/components/ui/Card.js +5 -5
  46. package/dist/components/ui/Card.js.map +1 -1
  47. package/dist/components/ui/InspectorPane.js +3 -3
  48. package/dist/components/ui/InspectorPane.js.map +1 -1
  49. package/dist/components/ui/PanelSection.d.ts +16 -0
  50. package/dist/components/ui/PanelSection.d.ts.map +1 -0
  51. package/dist/components/ui/PanelSection.js +15 -0
  52. package/dist/components/ui/PanelSection.js.map +1 -0
  53. package/dist/components/ui/ResizableAside.d.ts +38 -0
  54. package/dist/components/ui/ResizableAside.d.ts.map +1 -0
  55. package/dist/components/ui/ResizableAside.js +209 -0
  56. package/dist/components/ui/ResizableAside.js.map +1 -0
  57. package/dist/lib/admin-root-element.d.ts +9 -0
  58. package/dist/lib/admin-root-element.d.ts.map +1 -0
  59. package/dist/lib/admin-root-element.js +14 -0
  60. package/dist/lib/admin-root-element.js.map +1 -0
  61. package/dist/lib/portal-container.d.ts.map +1 -1
  62. package/dist/lib/portal-container.js +2 -3
  63. package/dist/lib/portal-container.js.map +1 -1
  64. package/dist/lib/seo-service.d.ts +89 -0
  65. package/dist/lib/seo-service.d.ts.map +1 -1
  66. package/dist/lib/seo-service.js +50 -2
  67. package/dist/lib/seo-service.js.map +1 -1
  68. package/dist/views/ApiKeys.js +2 -2
  69. package/dist/views/Dashboard.d.ts.map +1 -1
  70. package/dist/views/Dashboard.js +12 -14
  71. package/dist/views/Dashboard.js.map +1 -1
  72. package/dist/views/MediaBrowser.d.ts.map +1 -1
  73. package/dist/views/MediaBrowser.js +35 -6
  74. package/dist/views/MediaBrowser.js.map +1 -1
  75. package/dist/views/PageEditor.js +1 -1
  76. package/dist/views/Posts/PostTypeEditor.js +3 -3
  77. package/dist/views/SEO.d.ts.map +1 -1
  78. package/dist/views/SEO.js +4 -2
  79. package/dist/views/SEO.js.map +1 -1
  80. package/dist/views/ScriptTags.js +1 -1
  81. package/dist/views/ScriptTags.js.map +1 -1
  82. package/dist/views/Users.js +1 -1
  83. package/dist/views/page-editor/EditorTopBar.d.ts +4 -1
  84. package/dist/views/page-editor/EditorTopBar.d.ts.map +1 -1
  85. package/dist/views/page-editor/EditorTopBar.js +5 -3
  86. package/dist/views/page-editor/EditorTopBar.js.map +1 -1
  87. package/dist/views/page-editor/PageSectionEditor.d.ts.map +1 -1
  88. package/dist/views/page-editor/PageSectionEditor.js +24 -1
  89. package/dist/views/page-editor/PageSectionEditor.js.map +1 -1
  90. package/dist/views/page-editor/SectionInspector.d.ts.map +1 -1
  91. package/dist/views/page-editor/SectionInspector.js +7 -2
  92. package/dist/views/page-editor/SectionInspector.js.map +1 -1
  93. package/dist/views/page-editor/SectionsPanel.js +1 -1
  94. package/dist/views/post-editor/PostHeaderPanel.d.ts.map +1 -1
  95. package/dist/views/post-editor/PostHeaderPanel.js +2 -1
  96. package/dist/views/post-editor/PostHeaderPanel.js.map +1 -1
  97. package/dist/views/post-editor/PostSectionEditor.d.ts.map +1 -1
  98. package/dist/views/post-editor/PostSectionEditor.js +24 -1
  99. package/dist/views/post-editor/PostSectionEditor.js.map +1 -1
  100. package/dist/views/post-editor/PostTemplateEditor.js +1 -1
  101. package/dist/views/post-editor/PostTemplateEditor.js.map +1 -1
  102. package/dist/views/seo/LinksTab.d.ts +4 -0
  103. package/dist/views/seo/LinksTab.d.ts.map +1 -0
  104. package/dist/views/seo/LinksTab.js +111 -0
  105. package/dist/views/seo/LinksTab.js.map +1 -0
  106. package/dist/views/seo/OverviewTab.js +1 -1
  107. package/dist/views/seo/OverviewTab.js.map +1 -1
  108. package/dist/views/seo/RedirectsTab.d.ts.map +1 -1
  109. package/dist/views/seo/RedirectsTab.js +39 -6
  110. package/dist/views/seo/RedirectsTab.js.map +1 -1
  111. package/dist/views/seo/TechnicalTab.js +2 -2
  112. package/dist/views/settings/AISettingsTab.js +1 -1
  113. package/dist/views/settings/AiFeaturesCard.js +1 -1
  114. package/dist/views/settings/AiFeaturesCard.js.map +1 -1
  115. package/dist/views/settings/AppearanceTab.js +1 -1
  116. package/dist/views/settings/BrandVoiceCard.js +1 -1
  117. package/dist/views/settings/EmailSettingsTab.js +2 -2
  118. package/dist/views/settings/GeneralSettingsTab.js +1 -1
  119. package/dist/views/settings/SecurityStatusCard.js +1 -1
  120. package/dist/views/settings/SeoSettingsTab.js +4 -4
  121. package/dist/views/settings/UpdatesTab.js +2 -2
  122. package/dist/views/settings/components.js +3 -3
  123. package/dist/views/settings/components.js.map +1 -1
  124. package/package.json +2 -2
  125. package/src/AdminRoot.tsx +3 -2
  126. package/src/LoginRoot.tsx +2 -1
  127. package/src/__tests__/components/resizable-aside.test.tsx +130 -0
  128. package/src/__tests__/components/theme-provider-accent.test.tsx +84 -0
  129. package/src/__tests__/views/portal-container.test.tsx +14 -0
  130. package/src/__tests__/views/redirects-pane.render.test.tsx +6 -0
  131. package/src/__tests__/views/seo-content-pane.render.test.tsx +14 -4
  132. package/src/components/CoauthorResultPopover.tsx +1 -1
  133. package/src/components/EditorSeoAside.tsx +149 -0
  134. package/src/components/MediaPickerModal.tsx +1 -1
  135. package/src/components/SEOConfigPanel.tsx +2 -2
  136. package/src/components/SchedulePublishDialog.tsx +1 -1
  137. package/src/components/SharePreviewLinkDialog.tsx +1 -1
  138. package/src/components/ThemeProvider.tsx +3 -1
  139. package/src/components/VersionHistory.tsx +1 -1
  140. package/src/components/seo/SeoEditorPane.tsx +41 -12
  141. package/src/components/seo/SeoIssueFixPanel.tsx +16 -4
  142. package/src/components/seo/primitives.tsx +7 -3
  143. package/src/components/ui/Card.tsx +5 -5
  144. package/src/components/ui/InspectorPane.tsx +3 -3
  145. package/src/components/ui/PanelSection.tsx +53 -0
  146. package/src/components/ui/ResizableAside.tsx +317 -0
  147. package/src/lib/admin-root-element.ts +15 -0
  148. package/src/lib/portal-container.ts +3 -2
  149. package/src/lib/seo-service.ts +143 -2
  150. package/src/views/ApiKeys.tsx +2 -2
  151. package/src/views/Dashboard.tsx +40 -53
  152. package/src/views/MediaBrowser.tsx +62 -12
  153. package/src/views/PageEditor.tsx +1 -1
  154. package/src/views/Posts/PostTypeEditor.tsx +3 -3
  155. package/src/views/SEO.tsx +6 -0
  156. package/src/views/ScriptTags.tsx +2 -2
  157. package/src/views/Users.tsx +1 -1
  158. package/src/views/page-editor/EditorTopBar.tsx +24 -0
  159. package/src/views/page-editor/PageSectionEditor.tsx +44 -4
  160. package/src/views/page-editor/SectionInspector.tsx +33 -27
  161. package/src/views/page-editor/SectionsPanel.tsx +1 -1
  162. package/src/views/post-editor/PostHeaderPanel.tsx +16 -12
  163. package/src/views/post-editor/PostSectionEditor.tsx +44 -4
  164. package/src/views/post-editor/PostTemplateEditor.tsx +1 -1
  165. package/src/views/seo/LinksTab.tsx +462 -0
  166. package/src/views/seo/OverviewTab.tsx +1 -1
  167. package/src/views/seo/RedirectsTab.tsx +97 -4
  168. package/src/views/seo/TechnicalTab.tsx +2 -2
  169. package/src/views/settings/AISettingsTab.tsx +3 -3
  170. package/src/views/settings/AiFeaturesCard.tsx +1 -1
  171. package/src/views/settings/AppearanceTab.tsx +1 -1
  172. package/src/views/settings/BrandVoiceCard.tsx +2 -2
  173. package/src/views/settings/EmailSettingsTab.tsx +2 -2
  174. package/src/views/settings/GeneralSettingsTab.tsx +3 -3
  175. package/src/views/settings/SecurityStatusCard.tsx +1 -1
  176. package/src/views/settings/SeoSettingsTab.tsx +6 -6
  177. package/src/views/settings/UpdatesTab.tsx +3 -3
  178. package/src/views/settings/components.tsx +3 -3
@@ -7,10 +7,19 @@
7
7
  * assists, and `updateSeoFields` save path as the drawer; only the
8
8
  * presentation pattern changed.
9
9
  */
10
- import { cloneElement, isValidElement, useEffect, useId, useState, type ReactElement } from 'react'
10
+ import {
11
+ cloneElement,
12
+ isValidElement,
13
+ useEffect,
14
+ useId,
15
+ useMemo,
16
+ useState,
17
+ type ReactElement,
18
+ } from 'react'
11
19
  import { Sparkles, Check, Loader2, Wand2 } from 'lucide-react'
12
20
  import { toast } from 'sonner'
13
21
  import type { BrandAlignmentScoreResult } from '@actuate-media/cms-core/brand-voice'
22
+ import { resolveRenderedMetaTitle } from '@actuate-media/cms-core/client'
14
23
  import {
15
24
  fetchSeoContentRecord,
16
25
  updateSeoFields,
@@ -148,7 +157,7 @@ export function SeoEditorPane({ entityType, entityId, onClose, onSaved }: SeoEdi
148
157
  }
149
158
  setRecord(rec)
150
159
  setForm({
151
- metaTitle: rec.metaTitle ?? '',
160
+ metaTitle: rec.metaTitlePage ?? '',
152
161
  metaDescription: rec.metaDescription ?? '',
153
162
  canonicalUrl: rec.canonicalUrl ?? '',
154
163
  focusKeyword: rec.focusKeyword ?? '',
@@ -182,11 +191,20 @@ export function SeoEditorPane({ entityType, entityId, onClose, onSaved }: SeoEdi
182
191
  return
183
192
  }
184
193
  if (result.text) {
185
- update({ [field]: result.text } as Partial<SeoFieldPayload>)
194
+ const targetField =
195
+ field === 'ogTitle' ? 'metaTitle' : field === 'ogDescription' ? 'metaDescription' : field
196
+ const patch: Partial<SeoFieldPayload> = { [targetField]: result.text }
197
+ if (field === 'ogTitle') patch.ogTitle = ''
198
+ if (field === 'ogDescription') patch.ogDescription = ''
199
+ update(patch)
186
200
  if (result.brandAlignment) {
187
- setAiAlignments((prev) => ({ ...prev, [field]: result.brandAlignment }))
201
+ setAiAlignments((prev) => ({ ...prev, [targetField]: result.brandAlignment }))
188
202
  }
189
- toast.success('AI suggestion applied — review before saving.')
203
+ toast.success(
204
+ field === 'ogTitle' || field === 'ogDescription'
205
+ ? 'AI suggestion applied to meta tags — OG inherits from meta unless you override.'
206
+ : 'AI suggestion applied — review before saving.',
207
+ )
190
208
  } else {
191
209
  toast.error('No suggestion was returned.')
192
210
  }
@@ -256,7 +274,18 @@ export function SeoEditorPane({ entityType, entityId, onClose, onSaved }: SeoEdi
256
274
  onSaved?.()
257
275
  }
258
276
 
259
- const title = form.metaTitle || record?.title || 'Untitled'
277
+ const renderedTitle = useMemo(() => {
278
+ if (!record) return form.metaTitle?.trim() || 'Untitled'
279
+ return resolveRenderedMetaTitle({
280
+ metaTitlePage: form.metaTitle,
281
+ documentTitle: record.title,
282
+ site: {
283
+ siteName: record.siteName ?? undefined,
284
+ titleTemplate: record.titleTemplate ?? undefined,
285
+ },
286
+ })
287
+ }, [form.metaTitle, record])
288
+
260
289
  const desc = form.metaDescription || 'No meta description set. Search engines may generate one.'
261
290
  const url = record?.url ?? ''
262
291
 
@@ -285,11 +314,11 @@ export function SeoEditorPane({ entityType, entityId, onClose, onSaved }: SeoEdi
285
314
 
286
315
  <InspectorPaneBody>
287
316
  {loading ? (
288
- <div className="px-4 py-3.5">
317
+ <div className="px-5 py-4">
289
318
  <SeoLoading />
290
319
  </div>
291
320
  ) : error ? (
292
- <div className="px-4 py-3.5">
321
+ <div className="px-5 py-4">
293
322
  <SeoErrorState message={error} />
294
323
  </div>
295
324
  ) : record ? (
@@ -297,7 +326,7 @@ export function SeoEditorPane({ entityType, entityId, onClose, onSaved }: SeoEdi
297
326
  <InspectorPaneSection title="Google Preview">
298
327
  <div className="rounded-[7px] border border-[var(--bdr)] bg-[var(--card)] p-3">
299
328
  <p className="truncate text-[11.5px] text-[var(--suc)]">{url}</p>
300
- <p className="text-[15px] leading-snug text-[var(--acc)]">{title}</p>
329
+ <p className="text-[15px] leading-snug text-[var(--acc)]">{renderedTitle}</p>
301
330
  <p className="line-clamp-2 text-[12px] text-[var(--sub)]">{desc}</p>
302
331
  </div>
303
332
  </InspectorPaneSection>
@@ -316,7 +345,7 @@ export function SeoEditorPane({ entityType, entityId, onClose, onSaved }: SeoEdi
316
345
  className={inputCls}
317
346
  value={form.metaTitle ?? ''}
318
347
  onChange={(e) => update({ metaTitle: e.target.value })}
319
- placeholder="Page title for search results"
348
+ placeholder="Page-specific title (%page% in your site template)"
320
349
  />
321
350
  </PaneField>
322
351
  <PaneField
@@ -401,10 +430,10 @@ export function SeoEditorPane({ entityType, entityId, onClose, onSaved }: SeoEdi
401
430
  {url.replace(/^https?:\/\//, '').split('/')[0]}
402
431
  </p>
403
432
  <p className="truncate text-[12.5px] font-medium text-[var(--txt)]">
404
- {form.ogTitle || title}
433
+ {form.ogTitle || renderedTitle}
405
434
  </p>
406
435
  <p className="line-clamp-1 text-[12px] text-[var(--sub)]">
407
- {form.ogDescription || desc}
436
+ {form.ogDescription || form.metaDescription || desc}
408
437
  </p>
409
438
  </div>
410
439
  </div>
@@ -321,7 +321,11 @@ export function SeoIssueFixPanel({
321
321
  toast.error(res.error)
322
322
  return
323
323
  }
324
- toast.success('Fix applied and issue resolved.')
324
+ toast.success(
325
+ suggestion?.fixStrategy === 'fix-link'
326
+ ? 'Link updated in content and issue resolved.'
327
+ : 'Redirect created and issue resolved.',
328
+ )
325
329
  onChanged?.()
326
330
  }
327
331
 
@@ -408,11 +412,15 @@ export function SeoIssueFixPanel({
408
412
  {suggestion.source === 'ai' && !suggestion.aiUnavailable ? (
409
413
  <span className="bg-primary/10 text-primary inline-flex items-center gap-1 rounded-full px-2.5 py-0.5 text-xs font-medium">
410
414
  <Sparkles className="h-3 w-3" aria-hidden />
411
- AI suggestion
415
+ {suggestion.fixStrategy === 'fix-link'
416
+ ? 'Fix link in content'
417
+ : 'AI suggestion'}
412
418
  </span>
413
419
  ) : suggestion.source === 'deterministic' ? (
414
420
  <span className="bg-muted text-muted-foreground rounded-full px-2.5 py-0.5 text-xs font-medium">
415
- Rule-based fix
421
+ {suggestion.fixStrategy === 'fix-link'
422
+ ? 'Content link fix'
423
+ : 'Rule-based fix'}
416
424
  </span>
417
425
  ) : null}
418
426
  </div>
@@ -483,7 +491,11 @@ export function SeoIssueFixPanel({
483
491
  disabled={busy}
484
492
  >
485
493
  <Check className="h-4 w-4" aria-hidden />
486
- {busy ? 'Applying…' : 'Approve fix'}
494
+ {busy
495
+ ? 'Applying…'
496
+ : suggestion.fixStrategy === 'fix-link'
497
+ ? 'Fix link in content'
498
+ : 'Approve redirect'}
487
499
  </button>
488
500
  ) : inlineFix && suggestion?.autoFixable && !inlineApplyEnabled ? (
489
501
  <PlanUpgradeCallout feature="seo.inlineApply" upgradeUrl={planInfo?.upgradeUrl} />
@@ -153,14 +153,18 @@ export function SectionCard({
153
153
  {action}
154
154
  </CardHeader>
155
155
  )}
156
- <CardContent className={title || action ? 'pt-0' : ''}>{children}</CardContent>
156
+ <CardContent className={title || action ? '' : 'pt-6!'}>{children}</CardContent>
157
157
  </Card>
158
158
  )
159
159
  }
160
160
 
161
161
  export function SeoLoading({ label = 'Loading…' }: { label?: string }) {
162
162
  return (
163
- <div className="flex h-64 items-center justify-center" role="status" aria-live="polite">
163
+ <div
164
+ className="flex min-h-32 items-center justify-center py-12"
165
+ role="status"
166
+ aria-live="polite"
167
+ >
164
168
  <Loader2 className="text-muted-foreground h-6 w-6 animate-spin" aria-hidden />
165
169
  <span className="sr-only">{label}</span>
166
170
  </div>
@@ -195,7 +199,7 @@ export function SeoEmptyState({
195
199
  export function SeoErrorState({ message, onRetry }: { message: string; onRetry?: () => void }) {
196
200
  return (
197
201
  <div
198
- className="border-destructive/30 bg-destructive/5 flex items-center gap-3 rounded-lg border p-3"
202
+ className="border-destructive/30 bg-destructive/5 flex items-center gap-3 rounded-lg border p-4"
199
203
  role="alert"
200
204
  >
201
205
  <AlertTriangle className="text-destructive h-5 w-5 shrink-0" aria-hidden />
@@ -10,8 +10,8 @@ const card = cv(
10
10
  /** Stat / compact surfaces (`.dstat`). */
11
11
  sm: 'px-4 py-3.5',
12
12
  /** Simple padded panel (`.dcard-b`). */
13
- md: 'px-[18px] py-4',
14
- lg: 'px-[18px] py-6',
13
+ md: 'px-6 py-4',
14
+ lg: 'px-6 py-6',
15
15
  },
16
16
  elevation: {
17
17
  flat: 'shadow-none',
@@ -55,7 +55,7 @@ export function CardHeader({
55
55
  }: HTMLAttributes<HTMLDivElement> & { bordered?: boolean }) {
56
56
  return (
57
57
  <div
58
- className={[bordered ? 'border-b border-[var(--bdr)] px-[18px] py-[14px]' : '', className]
58
+ className={[bordered ? 'border-b border-[var(--bdr)] px-6 pt-6 pb-4' : '', className]
59
59
  .filter(Boolean)
60
60
  .join(' ')}
61
61
  {...rest}
@@ -96,7 +96,7 @@ export function CardDescription({
96
96
  export function CardContent({ className = '', children, ...rest }: HTMLAttributes<HTMLDivElement>) {
97
97
  return (
98
98
  <div
99
- className={`px-[18px] py-4 text-[length:var(--fs-body)] leading-[1.55] text-[var(--sub)] ${className}`}
99
+ className={`px-6 pt-4 pb-6 text-[length:var(--fs-body)] leading-[1.55] text-[var(--sub)] ${className}`}
100
100
  {...rest}
101
101
  >
102
102
  {children}
@@ -107,7 +107,7 @@ export function CardContent({ className = '', children, ...rest }: HTMLAttribute
107
107
  export function CardFooter({ className = '', children, ...rest }: HTMLAttributes<HTMLDivElement>) {
108
108
  return (
109
109
  <div
110
- className={`flex items-center justify-end gap-2 border-t border-[var(--bdr)] px-[18px] py-3 ${className}`}
110
+ className={`flex items-center justify-end gap-2 border-t border-[var(--bdr)] px-6 py-4 ${className}`}
111
111
  {...rest}
112
112
  >
113
113
  {children}
@@ -86,7 +86,7 @@ export function InspectorPaneHeader({
86
86
  closeLabel?: string
87
87
  }) {
88
88
  return (
89
- <div className="flex items-start gap-2 border-b border-[var(--bdr)] px-4 py-3.5">
89
+ <div className="flex items-start gap-2 border-b border-[var(--bdr)] px-5 py-4">
90
90
  <div className="min-w-0 flex-1">{children}</div>
91
91
  <button
92
92
  type="button"
@@ -115,7 +115,7 @@ export function InspectorPaneSection({
115
115
  children: ReactNode
116
116
  }) {
117
117
  return (
118
- <div className="border-b border-[var(--bdr)] px-4 py-3.5 last:border-b-0">
118
+ <div className="border-b border-[var(--bdr)] px-5 py-4 last:border-b-0">
119
119
  {title && (
120
120
  <div className="mb-2.5 flex items-baseline gap-2">
121
121
  <span className="text-[10.5px] font-semibold tracking-[0.6px] text-[var(--muted)] uppercase">
@@ -130,5 +130,5 @@ export function InspectorPaneSection({
130
130
  }
131
131
 
132
132
  export function InspectorPaneFooter({ children }: { children: ReactNode }) {
133
- return <div className="border-t border-[var(--bdr)] px-4 py-3.5">{children}</div>
133
+ return <div className="border-t border-[var(--bdr)] px-5 py-4">{children}</div>
134
134
  }
@@ -0,0 +1,53 @@
1
+ 'use client'
2
+
3
+ import type { ReactNode } from 'react'
4
+
5
+ /**
6
+ * Bordered panel with a header divider — Dashboard, modals, and list-heavy views.
7
+ * Matches Settings/Section card rhythm: `px-6` gutters, `py-4` header, body flush
8
+ * below the divider with its own padding.
9
+ */
10
+ export function PanelSection({
11
+ title,
12
+ description,
13
+ action,
14
+ children,
15
+ className = '',
16
+ }: {
17
+ title?: ReactNode
18
+ description?: ReactNode
19
+ action?: ReactNode
20
+ children: ReactNode
21
+ className?: string
22
+ }) {
23
+ return (
24
+ <section
25
+ className={`bg-card border-border overflow-hidden rounded-xl border shadow-sm ${className}`}
26
+ >
27
+ {(title || action) && (
28
+ <header className="border-border flex items-center justify-between gap-3 border-b px-6 py-4">
29
+ <div className="min-w-0">
30
+ {title &&
31
+ (typeof title === 'string' ? (
32
+ <h2 className="text-foreground text-sm font-medium">{title}</h2>
33
+ ) : (
34
+ title
35
+ ))}
36
+ {description &&
37
+ (typeof description === 'string' ? (
38
+ <p className="text-muted-foreground mt-0.5 text-xs">{description}</p>
39
+ ) : (
40
+ description
41
+ ))}
42
+ </div>
43
+ {action}
44
+ </header>
45
+ )}
46
+ {children}
47
+ </section>
48
+ )
49
+ }
50
+
51
+ /** Standard inset for list rows / table bodies inside a {@link PanelSection}. */
52
+ export const PANEL_LIST_ROW_CLASS =
53
+ 'hover:bg-accent/50 focus-visible:bg-accent/50 w-full px-6 py-4 text-left transition-colors'
@@ -0,0 +1,317 @@
1
+ 'use client'
2
+
3
+ import {
4
+ createContext,
5
+ useCallback,
6
+ useContext,
7
+ useEffect,
8
+ useRef,
9
+ useState,
10
+ type ComponentPropsWithoutRef,
11
+ type KeyboardEvent,
12
+ type PointerEvent,
13
+ type ReactNode,
14
+ } from 'react'
15
+ import { Maximize2, Minimize2 } from 'lucide-react'
16
+
17
+ export const SECTION_INSPECTOR_WIDTH_KEY = 'actuate:section-inspector-width'
18
+ export const SECTION_INSPECTOR_DEFAULT_WIDTH = 320
19
+ export const SECTION_INSPECTOR_MIN_WIDTH = 280
20
+ export const SECTION_INSPECTOR_MAX_WIDTH = 640
21
+ export const SECTION_INSPECTOR_MIN_CANVAS_WIDTH = 96
22
+ /** Below this editor-row width the expand action uses an overlay panel. */
23
+ export const SECTION_INSPECTOR_OVERLAY_BREAKPOINT = 768
24
+ export const SECTION_INSPECTOR_OVERLAY_MAX_WIDTH = 480
25
+
26
+ function clampWidth(width: number, max = SECTION_INSPECTOR_MAX_WIDTH): number {
27
+ return Math.min(max, Math.max(SECTION_INSPECTOR_MIN_WIDTH, width))
28
+ }
29
+
30
+ /** Max inline width given the surrounding editor row. */
31
+ export function computeInspectorMaxWidth(containerWidth: number): number {
32
+ if (!Number.isFinite(containerWidth) || containerWidth <= 0) {
33
+ return SECTION_INSPECTOR_MAX_WIDTH
34
+ }
35
+ const containerCap = Math.max(
36
+ SECTION_INSPECTOR_MIN_WIDTH,
37
+ containerWidth - SECTION_INSPECTOR_MIN_CANVAS_WIDTH,
38
+ )
39
+ return Math.min(SECTION_INSPECTOR_MAX_WIDTH, containerCap)
40
+ }
41
+
42
+ /** Width when expanded as an overlay on tablet/mobile. */
43
+ export function computeInspectorOverlayWidth(containerWidth: number): number {
44
+ if (!Number.isFinite(containerWidth) || containerWidth <= 0) {
45
+ return SECTION_INSPECTOR_OVERLAY_MAX_WIDTH
46
+ }
47
+ return Math.min(
48
+ SECTION_INSPECTOR_OVERLAY_MAX_WIDTH,
49
+ Math.max(SECTION_INSPECTOR_MIN_WIDTH, containerWidth - 32),
50
+ )
51
+ }
52
+
53
+ export function readSectionInspectorWidth(): number {
54
+ if (typeof window === 'undefined') return SECTION_INSPECTOR_DEFAULT_WIDTH
55
+ try {
56
+ const raw = localStorage.getItem(SECTION_INSPECTOR_WIDTH_KEY)
57
+ if (!raw) return SECTION_INSPECTOR_DEFAULT_WIDTH
58
+ const parsed = Number.parseInt(raw, 10)
59
+ if (!Number.isFinite(parsed)) return SECTION_INSPECTOR_DEFAULT_WIDTH
60
+ return clampWidth(parsed)
61
+ } catch {
62
+ return SECTION_INSPECTOR_DEFAULT_WIDTH
63
+ }
64
+ }
65
+
66
+ function persistSectionInspectorWidth(width: number, max = SECTION_INSPECTOR_MAX_WIDTH): void {
67
+ try {
68
+ localStorage.setItem(SECTION_INSPECTOR_WIDTH_KEY, String(clampWidth(width, max)))
69
+ } catch {
70
+ // Storage may be unavailable in private mode.
71
+ }
72
+ }
73
+
74
+ interface InspectorResizeContextValue {
75
+ width: number
76
+ maxWidth: number
77
+ isExpanded: boolean
78
+ canExpand: boolean
79
+ toggleExpanded: () => void
80
+ }
81
+
82
+ const InspectorResizeContext = createContext<InspectorResizeContextValue | null>(null)
83
+
84
+ export function useInspectorResize(): InspectorResizeContextValue | null {
85
+ return useContext(InspectorResizeContext)
86
+ }
87
+
88
+ interface ResizableAsideProps extends ComponentPropsWithoutRef<'aside'> {
89
+ children: ReactNode
90
+ }
91
+
92
+ /**
93
+ * Right-hand editor shell with a draggable left edge. Used by the page/post
94
+ * section inspector and post header panel (default 320px, persisted in
95
+ * localStorage). On tablet/mobile the handle uses a 44px touch target and
96
+ * the header expand button can grow the panel to an overlay width.
97
+ */
98
+ export function ResizableAside({ children, className, style, ...props }: ResizableAsideProps) {
99
+ const asideRef = useRef<HTMLElement>(null)
100
+ const [width, setWidth] = useState(SECTION_INSPECTOR_DEFAULT_WIDTH)
101
+ const [containerWidth, setContainerWidth] = useState(0)
102
+ const [isExpanded, setIsExpanded] = useState(false)
103
+ const [dragging, setDragging] = useState(false)
104
+ const widthRef = useRef(width)
105
+ const compactWidthRef = useRef(SECTION_INSPECTOR_DEFAULT_WIDTH)
106
+ const dragRef = useRef<{ startX: number; startWidth: number } | null>(null)
107
+
108
+ const inlineMax = computeInspectorMaxWidth(containerWidth)
109
+ const overlayMax = computeInspectorOverlayWidth(containerWidth)
110
+ const useOverlay =
111
+ containerWidth > 0 && containerWidth < SECTION_INSPECTOR_OVERLAY_BREAKPOINT && isExpanded
112
+ const effectiveMax = useOverlay ? overlayMax : inlineMax
113
+
114
+ useEffect(() => {
115
+ setWidth(readSectionInspectorWidth())
116
+ }, [])
117
+
118
+ useEffect(() => {
119
+ widthRef.current = width
120
+ }, [width])
121
+
122
+ useEffect(() => {
123
+ const parent = asideRef.current?.parentElement
124
+ if (!parent) return
125
+
126
+ const update = (nextWidth: number) => {
127
+ setContainerWidth(nextWidth)
128
+ }
129
+
130
+ update(parent.getBoundingClientRect().width)
131
+ const observer = new ResizeObserver((entries) => {
132
+ const entry = entries[0]
133
+ if (!entry) return
134
+ update(entry.contentRect.width)
135
+ })
136
+ observer.observe(parent)
137
+ return () => observer.disconnect()
138
+ }, [])
139
+
140
+ useEffect(() => {
141
+ setWidth((current) => clampWidth(current, effectiveMax))
142
+ }, [effectiveMax])
143
+
144
+ const endDrag = useCallback(() => {
145
+ dragRef.current = null
146
+ setDragging(false)
147
+ document.body.style.cursor = ''
148
+ document.body.style.userSelect = ''
149
+ persistSectionInspectorWidth(widthRef.current, inlineMax)
150
+ if (widthRef.current < effectiveMax - 24) {
151
+ setIsExpanded(false)
152
+ }
153
+ }, [effectiveMax, inlineMax])
154
+
155
+ const onPointerDown = useCallback((event: PointerEvent<HTMLDivElement>) => {
156
+ if (event.button !== 0) return
157
+ dragRef.current = { startX: event.clientX, startWidth: widthRef.current }
158
+ setDragging(true)
159
+ event.currentTarget.setPointerCapture(event.pointerId)
160
+ document.body.style.cursor = 'col-resize'
161
+ document.body.style.userSelect = 'none'
162
+ }, [])
163
+
164
+ const onPointerMove = useCallback(
165
+ (event: PointerEvent<HTMLDivElement>) => {
166
+ if (!dragRef.current) return
167
+ const delta = dragRef.current.startX - event.clientX
168
+ setWidth(clampWidth(dragRef.current.startWidth + delta, effectiveMax))
169
+ },
170
+ [effectiveMax],
171
+ )
172
+
173
+ const onPointerUp = useCallback(
174
+ (event: PointerEvent<HTMLDivElement>) => {
175
+ if (!dragRef.current) return
176
+ if (event.currentTarget.hasPointerCapture(event.pointerId)) {
177
+ event.currentTarget.releasePointerCapture(event.pointerId)
178
+ }
179
+ endDrag()
180
+ },
181
+ [endDrag],
182
+ )
183
+
184
+ const nudgeWidth = useCallback(
185
+ (delta: number) => {
186
+ setWidth((current) => {
187
+ const next = clampWidth(current + delta, effectiveMax)
188
+ persistSectionInspectorWidth(next, inlineMax)
189
+ if (next < effectiveMax - 24) setIsExpanded(false)
190
+ return next
191
+ })
192
+ },
193
+ [effectiveMax, inlineMax],
194
+ )
195
+
196
+ const onKeyDown = useCallback(
197
+ (event: KeyboardEvent<HTMLDivElement>) => {
198
+ if (event.key === 'ArrowLeft') {
199
+ event.preventDefault()
200
+ nudgeWidth(16)
201
+ } else if (event.key === 'ArrowRight') {
202
+ event.preventDefault()
203
+ nudgeWidth(-16)
204
+ } else if (event.key === 'Home') {
205
+ event.preventDefault()
206
+ setIsExpanded(false)
207
+ setWidth(SECTION_INSPECTOR_MIN_WIDTH)
208
+ persistSectionInspectorWidth(SECTION_INSPECTOR_MIN_WIDTH, inlineMax)
209
+ } else if (event.key === 'End') {
210
+ event.preventDefault()
211
+ setWidth(effectiveMax)
212
+ persistSectionInspectorWidth(effectiveMax, inlineMax)
213
+ setIsExpanded(effectiveMax >= inlineMax - 8)
214
+ }
215
+ },
216
+ [effectiveMax, inlineMax, nudgeWidth],
217
+ )
218
+
219
+ const toggleExpanded = useCallback(() => {
220
+ if (isExpanded) {
221
+ const restored = clampWidth(compactWidthRef.current, inlineMax)
222
+ setIsExpanded(false)
223
+ setWidth(restored)
224
+ persistSectionInspectorWidth(restored, inlineMax)
225
+ return
226
+ }
227
+
228
+ compactWidthRef.current = widthRef.current
229
+ const target =
230
+ containerWidth > 0 && containerWidth < SECTION_INSPECTOR_OVERLAY_BREAKPOINT
231
+ ? overlayMax
232
+ : inlineMax
233
+ setIsExpanded(true)
234
+ setWidth(target)
235
+ persistSectionInspectorWidth(target, inlineMax)
236
+ }, [containerWidth, inlineMax, isExpanded, overlayMax])
237
+
238
+ useEffect(() => {
239
+ return () => {
240
+ if (dragRef.current) endDrag()
241
+ }
242
+ }, [endDrag])
243
+
244
+ const canExpand =
245
+ !isExpanded &&
246
+ (containerWidth > 0 && containerWidth < SECTION_INSPECTOR_OVERLAY_BREAKPOINT
247
+ ? overlayMax > width + 8
248
+ : inlineMax > width + 8)
249
+
250
+ return (
251
+ <InspectorResizeContext.Provider
252
+ value={{ width, maxWidth: effectiveMax, isExpanded, canExpand, toggleExpanded }}
253
+ >
254
+ <aside
255
+ {...props}
256
+ ref={asideRef}
257
+ data-overlay={useOverlay ? '' : undefined}
258
+ className={`relative shrink-0 ${useOverlay ? 'absolute top-0 right-0 bottom-0 z-20 shadow-lg' : ''} ${className ?? ''}`}
259
+ style={{ width, ...style }}
260
+ >
261
+ <div
262
+ role="separator"
263
+ aria-orientation="vertical"
264
+ aria-label="Resize inspector panel"
265
+ aria-valuemin={SECTION_INSPECTOR_MIN_WIDTH}
266
+ aria-valuemax={effectiveMax}
267
+ aria-valuenow={width}
268
+ tabIndex={0}
269
+ data-dragging={dragging ? '' : undefined}
270
+ onPointerDown={onPointerDown}
271
+ onPointerMove={onPointerMove}
272
+ onPointerUp={onPointerUp}
273
+ onPointerCancel={onPointerUp}
274
+ onKeyDown={onKeyDown}
275
+ className="group absolute inset-y-0 -left-5 z-10 flex w-11 -translate-x-1/2 cursor-col-resize touch-none focus-visible:outline-none md:-left-1 md:w-3 md:translate-x-0"
276
+ >
277
+ <div
278
+ className={`mx-auto h-full w-px motion-safe:transition-colors md:mx-0 ${
279
+ dragging
280
+ ? 'bg-primary'
281
+ : 'bg-border group-hover:bg-primary/50 group-focus-visible:bg-primary/50'
282
+ }`}
283
+ />
284
+ </div>
285
+ {children}
286
+ </aside>
287
+ </InspectorResizeContext.Provider>
288
+ )
289
+ }
290
+
291
+ const EXPAND_BUTTON_CLASS =
292
+ 'text-muted-foreground hover:text-foreground hover:bg-accent flex h-9 w-9 items-center justify-center rounded-md transition-colors focus-visible:ring-ring focus-visible:ring-2 focus-visible:outline-none md:h-7 md:w-7'
293
+
294
+ /** One-tap expand/collapse for tablet and mobile inspector panels. */
295
+ export function InspectorExpandButton({ className }: { className?: string }) {
296
+ const ctx = useInspectorResize()
297
+ if (!ctx) return null
298
+
299
+ const { isExpanded, canExpand, toggleExpanded } = ctx
300
+ if (!canExpand && !isExpanded) return null
301
+
302
+ return (
303
+ <button
304
+ type="button"
305
+ onClick={toggleExpanded}
306
+ aria-label={isExpanded ? 'Collapse inspector panel' : 'Expand inspector panel'}
307
+ aria-pressed={isExpanded}
308
+ className={className ?? EXPAND_BUTTON_CLASS}
309
+ >
310
+ {isExpanded ? (
311
+ <Minimize2 className="h-4 w-4" aria-hidden />
312
+ ) : (
313
+ <Maximize2 className="h-4 w-4" aria-hidden />
314
+ )}
315
+ </button>
316
+ )
317
+ }
@@ -0,0 +1,15 @@
1
+ /** Marks the canonical admin shell root (AdminRoot / LoginRoot). */
2
+ export const ADMIN_ROOT_ATTR = 'data-actuate-admin-root'
3
+
4
+ /**
5
+ * Resolve the element that owns admin theme tokens (accent, color mode, density).
6
+ * Prefer the marked shell root so a consumer wrapper `.actuate-admin` does not
7
+ * steal runtime overrides from the inner AdminRoot.
8
+ */
9
+ export function getAdminRootElement(): HTMLElement | null {
10
+ if (typeof document === 'undefined') return null
11
+ return (
12
+ document.querySelector<HTMLElement>(`[${ADMIN_ROOT_ATTR}]`) ??
13
+ document.querySelector<HTMLElement>('.actuate-admin')
14
+ )
15
+ }
@@ -1,3 +1,5 @@
1
+ import { getAdminRootElement } from './admin-root-element.js'
2
+
1
3
  /**
2
4
  * Admin theme tokens are scoped under `.actuate-admin` (styles/theme.css),
3
5
  * and dark mode / accent / density are applied as classes-attributes on that
@@ -9,6 +11,5 @@
9
11
  * Returns `undefined` during SSR (portals only materialize client-side).
10
12
  */
11
13
  export function adminPortalContainer(): HTMLElement | undefined {
12
- if (typeof document === 'undefined') return undefined
13
- return document.querySelector<HTMLElement>('.actuate-admin') ?? undefined
14
+ return getAdminRootElement() ?? undefined
14
15
  }