@actuate-media/cms-admin 0.73.0 → 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.
- package/CHANGELOG.md +16 -0
- package/dist/AdminRoot.d.ts.map +1 -1
- package/dist/AdminRoot.js +3 -2
- package/dist/AdminRoot.js.map +1 -1
- package/dist/LoginRoot.d.ts.map +1 -1
- package/dist/LoginRoot.js +2 -1
- package/dist/LoginRoot.js.map +1 -1
- package/dist/__tests__/components/theme-provider-accent.test.d.ts +2 -0
- package/dist/__tests__/components/theme-provider-accent.test.d.ts.map +1 -0
- package/dist/__tests__/components/theme-provider-accent.test.js +64 -0
- package/dist/__tests__/components/theme-provider-accent.test.js.map +1 -0
- package/dist/__tests__/views/portal-container.test.js +13 -0
- package/dist/__tests__/views/portal-container.test.js.map +1 -1
- package/dist/__tests__/views/redirects-pane.render.test.js +6 -0
- package/dist/__tests__/views/redirects-pane.render.test.js.map +1 -1
- package/dist/__tests__/views/seo-content-pane.render.test.js +13 -4
- package/dist/__tests__/views/seo-content-pane.render.test.js.map +1 -1
- package/dist/actuate-admin.css +1 -1
- package/dist/components/CoauthorResultPopover.js +1 -1
- package/dist/components/EditorSeoAside.d.ts +20 -0
- package/dist/components/EditorSeoAside.d.ts.map +1 -0
- package/dist/components/EditorSeoAside.js +30 -0
- package/dist/components/EditorSeoAside.js.map +1 -0
- package/dist/components/MediaPickerModal.js +1 -1
- package/dist/components/SEOConfigPanel.js +1 -1
- package/dist/components/SchedulePublishDialog.js +1 -1
- package/dist/components/SharePreviewLinkDialog.js +1 -1
- package/dist/components/ThemeProvider.d.ts.map +1 -1
- package/dist/components/ThemeProvider.js +2 -1
- package/dist/components/ThemeProvider.js.map +1 -1
- package/dist/components/VersionHistory.js +1 -1
- package/dist/components/seo/SeoEditorPane.d.ts.map +1 -1
- package/dist/components/seo/SeoEditorPane.js +28 -8
- package/dist/components/seo/SeoEditorPane.js.map +1 -1
- package/dist/components/seo/SeoIssueFixPanel.d.ts.map +1 -1
- package/dist/components/seo/SeoIssueFixPanel.js +13 -3
- package/dist/components/seo/SeoIssueFixPanel.js.map +1 -1
- package/dist/components/seo/primitives.d.ts.map +1 -1
- package/dist/components/seo/primitives.js +3 -3
- package/dist/components/seo/primitives.js.map +1 -1
- package/dist/components/ui/Card.js +5 -5
- package/dist/components/ui/Card.js.map +1 -1
- package/dist/components/ui/InspectorPane.js +3 -3
- package/dist/components/ui/InspectorPane.js.map +1 -1
- package/dist/components/ui/PanelSection.d.ts +16 -0
- package/dist/components/ui/PanelSection.d.ts.map +1 -0
- package/dist/components/ui/PanelSection.js +15 -0
- package/dist/components/ui/PanelSection.js.map +1 -0
- package/dist/lib/admin-root-element.d.ts +9 -0
- package/dist/lib/admin-root-element.d.ts.map +1 -0
- package/dist/lib/admin-root-element.js +14 -0
- package/dist/lib/admin-root-element.js.map +1 -0
- package/dist/lib/portal-container.d.ts.map +1 -1
- package/dist/lib/portal-container.js +2 -3
- package/dist/lib/portal-container.js.map +1 -1
- package/dist/lib/seo-service.d.ts +89 -0
- package/dist/lib/seo-service.d.ts.map +1 -1
- package/dist/lib/seo-service.js +50 -2
- package/dist/lib/seo-service.js.map +1 -1
- package/dist/views/ApiKeys.js +2 -2
- package/dist/views/Dashboard.d.ts.map +1 -1
- package/dist/views/Dashboard.js +12 -14
- package/dist/views/Dashboard.js.map +1 -1
- package/dist/views/MediaBrowser.d.ts.map +1 -1
- package/dist/views/MediaBrowser.js +35 -6
- package/dist/views/MediaBrowser.js.map +1 -1
- package/dist/views/PageEditor.js +1 -1
- package/dist/views/Posts/PostTypeEditor.js +3 -3
- package/dist/views/SEO.d.ts.map +1 -1
- package/dist/views/SEO.js +4 -2
- package/dist/views/SEO.js.map +1 -1
- package/dist/views/ScriptTags.js +1 -1
- package/dist/views/ScriptTags.js.map +1 -1
- package/dist/views/Users.js +1 -1
- package/dist/views/page-editor/EditorTopBar.d.ts +4 -1
- package/dist/views/page-editor/EditorTopBar.d.ts.map +1 -1
- package/dist/views/page-editor/EditorTopBar.js +5 -3
- package/dist/views/page-editor/EditorTopBar.js.map +1 -1
- package/dist/views/page-editor/PageSectionEditor.d.ts.map +1 -1
- package/dist/views/page-editor/PageSectionEditor.js +24 -1
- package/dist/views/page-editor/PageSectionEditor.js.map +1 -1
- package/dist/views/page-editor/SectionInspector.js +2 -2
- package/dist/views/page-editor/SectionsPanel.js +1 -1
- package/dist/views/post-editor/PostHeaderPanel.js +1 -1
- package/dist/views/post-editor/PostSectionEditor.d.ts.map +1 -1
- package/dist/views/post-editor/PostSectionEditor.js +24 -1
- package/dist/views/post-editor/PostSectionEditor.js.map +1 -1
- package/dist/views/seo/LinksTab.d.ts +4 -0
- package/dist/views/seo/LinksTab.d.ts.map +1 -0
- package/dist/views/seo/LinksTab.js +111 -0
- package/dist/views/seo/LinksTab.js.map +1 -0
- package/dist/views/seo/OverviewTab.js +1 -1
- package/dist/views/seo/OverviewTab.js.map +1 -1
- package/dist/views/seo/RedirectsTab.d.ts.map +1 -1
- package/dist/views/seo/RedirectsTab.js +39 -6
- package/dist/views/seo/RedirectsTab.js.map +1 -1
- package/dist/views/seo/TechnicalTab.js +2 -2
- package/dist/views/settings/AISettingsTab.js +1 -1
- package/dist/views/settings/AiFeaturesCard.js +1 -1
- package/dist/views/settings/AiFeaturesCard.js.map +1 -1
- package/dist/views/settings/AppearanceTab.js +1 -1
- package/dist/views/settings/BrandVoiceCard.js +1 -1
- package/dist/views/settings/EmailSettingsTab.js +2 -2
- package/dist/views/settings/GeneralSettingsTab.js +1 -1
- package/dist/views/settings/SecurityStatusCard.js +1 -1
- package/dist/views/settings/SeoSettingsTab.js +4 -4
- package/dist/views/settings/UpdatesTab.js +2 -2
- package/dist/views/settings/components.js +3 -3
- package/dist/views/settings/components.js.map +1 -1
- package/package.json +2 -2
- package/src/AdminRoot.tsx +3 -2
- package/src/LoginRoot.tsx +2 -1
- package/src/__tests__/components/theme-provider-accent.test.tsx +84 -0
- package/src/__tests__/views/portal-container.test.tsx +14 -0
- package/src/__tests__/views/redirects-pane.render.test.tsx +6 -0
- package/src/__tests__/views/seo-content-pane.render.test.tsx +14 -4
- package/src/components/CoauthorResultPopover.tsx +1 -1
- package/src/components/EditorSeoAside.tsx +149 -0
- package/src/components/MediaPickerModal.tsx +1 -1
- package/src/components/SEOConfigPanel.tsx +2 -2
- package/src/components/SchedulePublishDialog.tsx +1 -1
- package/src/components/SharePreviewLinkDialog.tsx +1 -1
- package/src/components/ThemeProvider.tsx +3 -1
- package/src/components/VersionHistory.tsx +1 -1
- package/src/components/seo/SeoEditorPane.tsx +41 -12
- package/src/components/seo/SeoIssueFixPanel.tsx +16 -4
- package/src/components/seo/primitives.tsx +7 -3
- package/src/components/ui/Card.tsx +5 -5
- package/src/components/ui/InspectorPane.tsx +3 -3
- package/src/components/ui/PanelSection.tsx +53 -0
- package/src/lib/admin-root-element.ts +15 -0
- package/src/lib/portal-container.ts +3 -2
- package/src/lib/seo-service.ts +143 -2
- package/src/views/ApiKeys.tsx +2 -2
- package/src/views/Dashboard.tsx +40 -53
- package/src/views/MediaBrowser.tsx +62 -12
- package/src/views/PageEditor.tsx +1 -1
- package/src/views/Posts/PostTypeEditor.tsx +3 -3
- package/src/views/SEO.tsx +6 -0
- package/src/views/ScriptTags.tsx +2 -2
- package/src/views/Users.tsx +1 -1
- package/src/views/page-editor/EditorTopBar.tsx +24 -0
- package/src/views/page-editor/PageSectionEditor.tsx +43 -3
- package/src/views/page-editor/SectionInspector.tsx +2 -2
- package/src/views/page-editor/SectionsPanel.tsx +1 -1
- package/src/views/post-editor/PostHeaderPanel.tsx +1 -1
- package/src/views/post-editor/PostSectionEditor.tsx +43 -3
- package/src/views/seo/LinksTab.tsx +462 -0
- package/src/views/seo/OverviewTab.tsx +1 -1
- package/src/views/seo/RedirectsTab.tsx +97 -4
- package/src/views/seo/TechnicalTab.tsx +2 -2
- package/src/views/settings/AISettingsTab.tsx +3 -3
- package/src/views/settings/AiFeaturesCard.tsx +1 -1
- package/src/views/settings/AppearanceTab.tsx +1 -1
- package/src/views/settings/BrandVoiceCard.tsx +2 -2
- package/src/views/settings/EmailSettingsTab.tsx +2 -2
- package/src/views/settings/GeneralSettingsTab.tsx +3 -3
- package/src/views/settings/SecurityStatusCard.tsx +1 -1
- package/src/views/settings/SeoSettingsTab.tsx +6 -6
- package/src/views/settings/UpdatesTab.tsx +3 -3
- package/src/views/settings/components.tsx +3 -3
|
@@ -3,6 +3,7 @@ import { afterEach, describe, expect, it } from 'vitest'
|
|
|
3
3
|
import { cleanup, render, screen } from '@testing-library/react'
|
|
4
4
|
import { ConfirmDangerousSettingDialog } from '../../views/settings/components.js'
|
|
5
5
|
import { adminPortalContainer } from '../../lib/portal-container.js'
|
|
6
|
+
import { ADMIN_ROOT_ATTR } from '../../lib/admin-root-element.js'
|
|
6
7
|
|
|
7
8
|
/**
|
|
8
9
|
* Regression: theme tokens only exist under `.actuate-admin` (theme.css), so
|
|
@@ -24,11 +25,24 @@ describe('adminPortalContainer', () => {
|
|
|
24
25
|
it('resolves the admin root element', () => {
|
|
25
26
|
const root = document.createElement('div')
|
|
26
27
|
root.className = 'actuate-admin'
|
|
28
|
+
root.setAttribute(ADMIN_ROOT_ATTR, '')
|
|
27
29
|
document.body.appendChild(root)
|
|
28
30
|
expect(adminPortalContainer()).toBe(root)
|
|
29
31
|
root.remove()
|
|
30
32
|
})
|
|
31
33
|
|
|
34
|
+
it('prefers the marked shell when nested inside another .actuate-admin', () => {
|
|
35
|
+
const outer = document.createElement('div')
|
|
36
|
+
outer.className = 'actuate-admin'
|
|
37
|
+
const inner = document.createElement('div')
|
|
38
|
+
inner.className = 'actuate-admin'
|
|
39
|
+
inner.setAttribute(ADMIN_ROOT_ATTR, '')
|
|
40
|
+
outer.appendChild(inner)
|
|
41
|
+
document.body.appendChild(outer)
|
|
42
|
+
expect(adminPortalContainer()).toBe(inner)
|
|
43
|
+
outer.remove()
|
|
44
|
+
})
|
|
45
|
+
|
|
32
46
|
it('returns undefined when no admin root is mounted', () => {
|
|
33
47
|
document.querySelectorAll('.actuate-admin').forEach((el) => el.remove())
|
|
34
48
|
expect(adminPortalContainer()).toBeUndefined()
|
|
@@ -55,6 +55,12 @@ vi.mock('../../lib/seo-service.js', () => ({
|
|
|
55
55
|
dismissRedirectSuggestion: vi.fn(async () => ({})),
|
|
56
56
|
exportRedirectsCsv: vi.fn(async () => ({ csv: '' })),
|
|
57
57
|
importRedirectsCsv: vi.fn(async () => ({ imported: 0 })),
|
|
58
|
+
fetchRedirectRecoverySettings: vi.fn(async () => ({
|
|
59
|
+
aiRerank: false,
|
|
60
|
+
autoApply: false,
|
|
61
|
+
autoApplyMinConfidence: 0.85,
|
|
62
|
+
})),
|
|
63
|
+
saveRedirectRecoverySettings: vi.fn(async () => ({})),
|
|
58
64
|
}))
|
|
59
65
|
|
|
60
66
|
vi.mock('sonner', () => ({ toast: { success: vi.fn(), error: vi.fn(), loading: vi.fn() } }))
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
// @vitest-environment happy-dom
|
|
2
2
|
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
|
3
|
-
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
|
|
3
|
+
import { fireEvent, render, screen, waitFor, within } from '@testing-library/react'
|
|
4
4
|
|
|
5
5
|
/**
|
|
6
6
|
* Content SEO tab with the inline editor pane (design split-pane pattern —
|
|
@@ -18,6 +18,9 @@ function makeRecord(over: Record<string, unknown> = {}) {
|
|
|
18
18
|
url: 'https://example.com/',
|
|
19
19
|
canonicalUrl: null,
|
|
20
20
|
metaTitle: 'Home — Example',
|
|
21
|
+
metaTitlePage: 'Home',
|
|
22
|
+
siteName: 'Example',
|
|
23
|
+
titleTemplate: '%page% — %site%',
|
|
21
24
|
metaDescription: 'Welcome to Example.',
|
|
22
25
|
focusKeyword: null,
|
|
23
26
|
keyTakeaway: null,
|
|
@@ -54,6 +57,11 @@ const fetchSeoContentRecord = vi.fn(async (entityType: string, entityId: string)
|
|
|
54
57
|
})
|
|
55
58
|
const updateSeoFields = vi.fn(async (_t: string, _i: string, _p: unknown) => ({}))
|
|
56
59
|
|
|
60
|
+
vi.mock('@actuate-media/cms-core/client', async (importOriginal) => {
|
|
61
|
+
const actual = await importOriginal<typeof import('@actuate-media/cms-core/client')>()
|
|
62
|
+
return { ...actual }
|
|
63
|
+
})
|
|
64
|
+
|
|
57
65
|
vi.mock('../../lib/seo-service.js', () => ({
|
|
58
66
|
fetchSeoContentRecords: () => fetchSeoContentRecords(),
|
|
59
67
|
fetchSeoContentRecord: (t: string, i: string) => fetchSeoContentRecord(t, i),
|
|
@@ -99,8 +107,10 @@ describe('Content SEO inline editor pane', () => {
|
|
|
99
107
|
expect(screen.getByRole('region', { name: 'SEO editor' })).toBeTruthy()
|
|
100
108
|
expect(rowButton.getAttribute('aria-expanded')).toBe('true')
|
|
101
109
|
await waitFor(() => expect(fetchSeoContentRecord).toHaveBeenCalledWith('page', 'p1'))
|
|
102
|
-
//
|
|
103
|
-
expect(await screen.findByDisplayValue('Home
|
|
110
|
+
// Form loads the stored page portion; preview applies the site title template.
|
|
111
|
+
expect(await screen.findByDisplayValue('Home')).toBeTruthy()
|
|
112
|
+
const editor = screen.getByRole('region', { name: 'SEO editor' })
|
|
113
|
+
expect(within(editor).getAllByText('Home — Example').length).toBeGreaterThan(0)
|
|
104
114
|
})
|
|
105
115
|
|
|
106
116
|
it('toggles the pane closed when the same row is clicked again', async () => {
|
|
@@ -118,7 +128,7 @@ describe('Content SEO inline editor pane', () => {
|
|
|
118
128
|
render(<ContentTab />)
|
|
119
129
|
fireEvent.click(await screen.findByRole('button', { name: /Home/ }))
|
|
120
130
|
|
|
121
|
-
const titleInput = await screen.findByDisplayValue('Home
|
|
131
|
+
const titleInput = await screen.findByDisplayValue('Home')
|
|
122
132
|
fireEvent.change(titleInput, { target: { value: 'New Meta Title' } })
|
|
123
133
|
fireEvent.click(screen.getByRole('button', { name: 'Save Changes' }))
|
|
124
134
|
|
|
@@ -120,7 +120,7 @@ export function CoauthorResultPopover({
|
|
|
120
120
|
onClick={(e) => e.stopPropagation()}
|
|
121
121
|
className="bg-card w-full max-w-2xl rounded-lg shadow-2xl outline-none"
|
|
122
122
|
>
|
|
123
|
-
<div className="border-border flex items-center justify-between border-b px-
|
|
123
|
+
<div className="border-border flex items-center justify-between border-b px-6 py-4">
|
|
124
124
|
<div className="text-foreground flex items-center gap-2 text-sm font-medium">
|
|
125
125
|
<Sparkles className="text-primary h-4 w-4" aria-hidden />
|
|
126
126
|
<span>AI {coauthorActionLabel(action)}</span>
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import { useEffect } from 'react'
|
|
4
|
+
import { Search, X } from 'lucide-react'
|
|
5
|
+
import { SeoCharCount } from './seo/SeoCharCount.js'
|
|
6
|
+
import { SeoEditorPane } from './seo/SeoEditorPane.js'
|
|
7
|
+
import { InspectorExpandButton, ResizableAside } from './ui/ResizableAside.js'
|
|
8
|
+
|
|
9
|
+
const SEO_TITLE_MIN = 30
|
|
10
|
+
const SEO_TITLE_MAX = 60
|
|
11
|
+
const SEO_DESC_MIN = 120
|
|
12
|
+
const SEO_DESC_MAX = 160
|
|
13
|
+
|
|
14
|
+
const INSPECTOR_SHELL = 'bg-card border-border flex h-full flex-col border-l'
|
|
15
|
+
const LABEL = 'text-foreground mb-1 block text-xs font-medium'
|
|
16
|
+
const INPUT =
|
|
17
|
+
'border-input bg-input-background text-foreground focus-visible:ring-ring w-full rounded-md border px-2.5 py-1.5 text-sm focus-visible:ring-2 focus-visible:outline-none'
|
|
18
|
+
|
|
19
|
+
export interface EditorSeoAsideProps {
|
|
20
|
+
entityType: 'page' | 'post'
|
|
21
|
+
/** Saved document id — when absent, shows a draft-only meta form. */
|
|
22
|
+
entityId: string | null
|
|
23
|
+
title: string
|
|
24
|
+
seoTitle: string
|
|
25
|
+
seoDescription: string
|
|
26
|
+
canEdit: boolean
|
|
27
|
+
onClose: () => void
|
|
28
|
+
/** Sync basic meta fields on the in-memory editor model (draft flow). */
|
|
29
|
+
onDraftChange?: (patch: { seoTitle?: string; seoDescription?: string }) => void
|
|
30
|
+
/** Refetch the editor document after SEO API save (saved flow). */
|
|
31
|
+
onSaved?: () => void
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function DraftSeoForm({
|
|
35
|
+
title,
|
|
36
|
+
seoTitle,
|
|
37
|
+
seoDescription,
|
|
38
|
+
canEdit,
|
|
39
|
+
onDraftChange,
|
|
40
|
+
}: Pick<
|
|
41
|
+
EditorSeoAsideProps,
|
|
42
|
+
'title' | 'seoTitle' | 'seoDescription' | 'canEdit' | 'onDraftChange'
|
|
43
|
+
>) {
|
|
44
|
+
return (
|
|
45
|
+
<div className="flex-1 space-y-4 overflow-y-auto p-4">
|
|
46
|
+
<p className="border-border bg-muted text-muted-foreground rounded-md border p-4 text-sm">
|
|
47
|
+
Save a draft first to unlock the full SEO editor — focus keyword, social previews, schema,
|
|
48
|
+
robots, and AI assists. You can still set a basic title and description below; they are
|
|
49
|
+
saved with the document.
|
|
50
|
+
</p>
|
|
51
|
+
<div>
|
|
52
|
+
<div className="mb-1 flex items-center justify-between">
|
|
53
|
+
<label className={`${LABEL} mb-0`} htmlFor="editor-seo-title">
|
|
54
|
+
SEO title
|
|
55
|
+
</label>
|
|
56
|
+
<SeoCharCount value={seoTitle} min={SEO_TITLE_MIN} max={SEO_TITLE_MAX} />
|
|
57
|
+
</div>
|
|
58
|
+
<input
|
|
59
|
+
id="editor-seo-title"
|
|
60
|
+
className={INPUT}
|
|
61
|
+
value={seoTitle}
|
|
62
|
+
disabled={!canEdit}
|
|
63
|
+
placeholder={title || 'Defaults to the page title'}
|
|
64
|
+
onChange={(e) => onDraftChange?.({ seoTitle: e.target.value })}
|
|
65
|
+
/>
|
|
66
|
+
</div>
|
|
67
|
+
<div>
|
|
68
|
+
<div className="mb-1 flex items-center justify-between">
|
|
69
|
+
<label className={`${LABEL} mb-0`} htmlFor="editor-seo-desc">
|
|
70
|
+
SEO description
|
|
71
|
+
</label>
|
|
72
|
+
<SeoCharCount value={seoDescription} min={SEO_DESC_MIN} max={SEO_DESC_MAX} />
|
|
73
|
+
</div>
|
|
74
|
+
<textarea
|
|
75
|
+
id="editor-seo-desc"
|
|
76
|
+
className={`${INPUT} min-h-[96px] resize-y`}
|
|
77
|
+
value={seoDescription}
|
|
78
|
+
disabled={!canEdit}
|
|
79
|
+
placeholder="Summarize this content for search results (120–160 characters)"
|
|
80
|
+
onChange={(e) => onDraftChange?.({ seoDescription: e.target.value })}
|
|
81
|
+
/>
|
|
82
|
+
</div>
|
|
83
|
+
</div>
|
|
84
|
+
)
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** Right-side SEO panel for the Page / Post section editors. */
|
|
88
|
+
export function EditorSeoAside({
|
|
89
|
+
entityType,
|
|
90
|
+
entityId,
|
|
91
|
+
title,
|
|
92
|
+
seoTitle,
|
|
93
|
+
seoDescription,
|
|
94
|
+
canEdit,
|
|
95
|
+
onClose,
|
|
96
|
+
onDraftChange,
|
|
97
|
+
onSaved,
|
|
98
|
+
}: EditorSeoAsideProps) {
|
|
99
|
+
useEffect(() => {
|
|
100
|
+
function onKey(e: KeyboardEvent) {
|
|
101
|
+
if (e.key === 'Escape') onClose()
|
|
102
|
+
}
|
|
103
|
+
document.addEventListener('keydown', onKey)
|
|
104
|
+
return () => document.removeEventListener('keydown', onKey)
|
|
105
|
+
}, [onClose])
|
|
106
|
+
|
|
107
|
+
return (
|
|
108
|
+
<ResizableAside role="dialog" aria-label="SEO settings" className={INSPECTOR_SHELL}>
|
|
109
|
+
{entityId ? (
|
|
110
|
+
<SeoEditorPane
|
|
111
|
+
entityType={entityType}
|
|
112
|
+
entityId={entityId}
|
|
113
|
+
onClose={onClose}
|
|
114
|
+
onSaved={onSaved}
|
|
115
|
+
/>
|
|
116
|
+
) : (
|
|
117
|
+
<>
|
|
118
|
+
<div className="border-border flex items-center justify-between border-b px-6 py-4">
|
|
119
|
+
<div className="flex min-w-0 items-center gap-2">
|
|
120
|
+
<Search className="text-muted-foreground h-4 w-4 shrink-0" aria-hidden />
|
|
121
|
+
<div className="min-w-0">
|
|
122
|
+
<h2 className="text-foreground truncate text-sm font-medium">SEO</h2>
|
|
123
|
+
<p className="text-muted-foreground truncate text-xs">{title || 'Untitled'}</p>
|
|
124
|
+
</div>
|
|
125
|
+
</div>
|
|
126
|
+
<div className="flex shrink-0 items-center gap-0.5">
|
|
127
|
+
<InspectorExpandButton />
|
|
128
|
+
<button
|
|
129
|
+
type="button"
|
|
130
|
+
onClick={onClose}
|
|
131
|
+
aria-label="Close SEO panel"
|
|
132
|
+
className="text-muted-foreground hover:text-foreground hover:bg-accent flex h-9 w-9 items-center justify-center rounded-md md:h-7 md:w-7"
|
|
133
|
+
>
|
|
134
|
+
<X className="h-4 w-4" aria-hidden />
|
|
135
|
+
</button>
|
|
136
|
+
</div>
|
|
137
|
+
</div>
|
|
138
|
+
<DraftSeoForm
|
|
139
|
+
title={title}
|
|
140
|
+
seoTitle={seoTitle}
|
|
141
|
+
seoDescription={seoDescription}
|
|
142
|
+
canEdit={canEdit}
|
|
143
|
+
onDraftChange={onDraftChange}
|
|
144
|
+
/>
|
|
145
|
+
</>
|
|
146
|
+
)}
|
|
147
|
+
</ResizableAside>
|
|
148
|
+
)
|
|
149
|
+
}
|
|
@@ -138,7 +138,7 @@ export function MediaPickerModal({
|
|
|
138
138
|
aria-describedby={undefined}
|
|
139
139
|
className="bg-card text-card-foreground border-border fixed top-1/2 left-1/2 z-70 mx-4 flex max-h-[80vh] w-[calc(100%-2rem)] max-w-2xl -translate-x-1/2 -translate-y-1/2 flex-col rounded-xl border shadow-2xl focus:outline-none"
|
|
140
140
|
>
|
|
141
|
-
<div className="border-border flex items-center justify-between border-b px-
|
|
141
|
+
<div className="border-border flex items-center justify-between border-b px-6 py-4">
|
|
142
142
|
<Dialog.Title className="text-foreground text-lg font-medium">
|
|
143
143
|
Insert Image
|
|
144
144
|
</Dialog.Title>
|
|
@@ -242,7 +242,7 @@ export function SEOConfigPanel() {
|
|
|
242
242
|
|
|
243
243
|
{/* Site-wide section */}
|
|
244
244
|
<section className="border-border bg-card rounded-lg border">
|
|
245
|
-
<header className="border-border flex items-center gap-2 border-b px-
|
|
245
|
+
<header className="border-border flex items-center gap-2 border-b px-6 py-4">
|
|
246
246
|
<Globe className="text-primary h-4 w-4" />
|
|
247
247
|
<h3 className="text-foreground text-sm font-medium">Site-wide defaults</h3>
|
|
248
248
|
</header>
|
|
@@ -333,7 +333,7 @@ export function SEOConfigPanel() {
|
|
|
333
333
|
|
|
334
334
|
{/* Per-collection section */}
|
|
335
335
|
<section className="border-border bg-card rounded-lg border">
|
|
336
|
-
<header className="border-border flex items-center gap-2 border-b px-
|
|
336
|
+
<header className="border-border flex items-center gap-2 border-b px-6 py-4">
|
|
337
337
|
<FileText className="text-primary h-4 w-4" />
|
|
338
338
|
<h3 className="text-foreground text-sm font-medium">Per-collection defaults</h3>
|
|
339
339
|
<span className="text-muted-foreground text-xs">
|
|
@@ -142,7 +142,7 @@ export function SchedulePublishDialog({
|
|
|
142
142
|
<Dialog.Portal container={adminPortalContainer()}>
|
|
143
143
|
<Dialog.Overlay className="fixed inset-0 z-50 bg-black/40" />
|
|
144
144
|
<Dialog.Content className="bg-card text-card-foreground border-border fixed top-1/2 left-1/2 z-50 w-full max-w-md -translate-x-1/2 -translate-y-1/2 rounded-lg border shadow-xl focus:outline-none">
|
|
145
|
-
<div className="border-border flex items-center justify-between border-b px-
|
|
145
|
+
<div className="border-border flex items-center justify-between border-b px-6 py-4">
|
|
146
146
|
<div className="flex items-center gap-2">
|
|
147
147
|
<Calendar className="text-muted-foreground h-5 w-5" aria-hidden />
|
|
148
148
|
<Dialog.Title className="text-foreground text-lg font-medium">
|
|
@@ -129,7 +129,7 @@ export function SharePreviewLinkDialog({
|
|
|
129
129
|
<Dialog.Portal container={adminPortalContainer()}>
|
|
130
130
|
<Dialog.Overlay className="fixed inset-0 z-50 bg-black/40" />
|
|
131
131
|
<Dialog.Content className="bg-card text-card-foreground border-border fixed top-1/2 left-1/2 z-50 w-full max-w-lg -translate-x-1/2 -translate-y-1/2 rounded-lg border shadow-xl focus:outline-none">
|
|
132
|
-
<div className="border-border flex items-center justify-between border-b px-
|
|
132
|
+
<div className="border-border flex items-center justify-between border-b px-6 py-4">
|
|
133
133
|
<div className="flex items-center gap-2">
|
|
134
134
|
<Share2 className="text-muted-foreground h-5 w-5" aria-hidden />
|
|
135
135
|
<Dialog.Title className="text-foreground text-lg font-medium">
|
|
@@ -8,6 +8,8 @@ import {
|
|
|
8
8
|
} from '@actuate-media/cms-core/appearance'
|
|
9
9
|
import { createContext, useContext, useState, useEffect, useCallback, type ReactNode } from 'react'
|
|
10
10
|
|
|
11
|
+
import { getAdminRootElement } from '../lib/admin-root-element.js'
|
|
12
|
+
|
|
11
13
|
/**
|
|
12
14
|
* Admin appearance engine.
|
|
13
15
|
*
|
|
@@ -119,7 +121,7 @@ const KEY_MOTION = 'actuate-reduce-motion'
|
|
|
119
121
|
const KEY_BRAND = 'actuate-admin-brand'
|
|
120
122
|
|
|
121
123
|
function adminRootEl(): HTMLElement | null {
|
|
122
|
-
return
|
|
124
|
+
return getAdminRootElement()
|
|
123
125
|
}
|
|
124
126
|
|
|
125
127
|
function applyColorMode(resolved: 'light' | 'dark') {
|
|
@@ -98,7 +98,7 @@ export function VersionHistory({
|
|
|
98
98
|
aria-describedby={undefined}
|
|
99
99
|
className="bg-card text-card-foreground border-border animate-in slide-in-from-right fixed inset-y-0 right-0 z-50 flex w-full max-w-md flex-col border-l shadow-xl focus:outline-none"
|
|
100
100
|
>
|
|
101
|
-
<div className="border-border flex items-center justify-between border-b px-
|
|
101
|
+
<div className="border-border flex items-center justify-between border-b px-6 py-4">
|
|
102
102
|
<div className="flex items-center gap-2">
|
|
103
103
|
<Clock className="text-muted-foreground h-5 w-5" aria-hidden />
|
|
104
104
|
<Dialog.Title className="text-foreground text-lg font-medium">
|
|
@@ -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 {
|
|
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.
|
|
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
|
-
|
|
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, [
|
|
201
|
+
setAiAlignments((prev) => ({ ...prev, [targetField]: result.brandAlignment }))
|
|
188
202
|
}
|
|
189
|
-
toast.success(
|
|
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
|
|
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-
|
|
317
|
+
<div className="px-5 py-4">
|
|
289
318
|
<SeoLoading />
|
|
290
319
|
</div>
|
|
291
320
|
) : error ? (
|
|
292
|
-
<div className="px-
|
|
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)]">{
|
|
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
|
|
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 ||
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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 ? '
|
|
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
|
|
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-
|
|
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-
|
|
14
|
-
lg: 'px-
|
|
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-
|
|
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-
|
|
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-
|
|
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-
|
|
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-
|
|
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-
|
|
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'
|