@actuate-media/cms-admin 0.66.0 → 0.68.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 +37 -0
- package/dist/AdminRoot.d.ts.map +1 -1
- package/dist/AdminRoot.js +36 -9
- package/dist/AdminRoot.js.map +1 -1
- package/dist/__tests__/components/inspector-pane.test.d.ts +2 -0
- package/dist/__tests__/components/inspector-pane.test.d.ts.map +1 -0
- package/dist/__tests__/components/inspector-pane.test.js +31 -0
- package/dist/__tests__/components/inspector-pane.test.js.map +1 -0
- package/dist/__tests__/hooks/useIdleLogout.test.d.ts +2 -0
- package/dist/__tests__/hooks/useIdleLogout.test.d.ts.map +1 -0
- package/dist/__tests__/hooks/useIdleLogout.test.js +64 -0
- package/dist/__tests__/hooks/useIdleLogout.test.js.map +1 -0
- package/dist/__tests__/lib/pages-tree.test.d.ts +2 -0
- package/dist/__tests__/lib/pages-tree.test.d.ts.map +1 -0
- package/dist/__tests__/lib/pages-tree.test.js +173 -0
- package/dist/__tests__/lib/pages-tree.test.js.map +1 -0
- package/dist/__tests__/lib/post-types-service.test.d.ts +2 -0
- package/dist/__tests__/lib/post-types-service.test.d.ts.map +1 -0
- package/dist/__tests__/lib/post-types-service.test.js +88 -0
- package/dist/__tests__/lib/post-types-service.test.js.map +1 -0
- package/dist/__tests__/views/collection-list-nav.render.test.js +43 -5
- package/dist/__tests__/views/collection-list-nav.render.test.js.map +1 -1
- package/dist/__tests__/views/dashboard.test.js +7 -3
- package/dist/__tests__/views/dashboard.test.js.map +1 -1
- package/dist/__tests__/views/forms-list.render.test.js +25 -6
- package/dist/__tests__/views/forms-list.render.test.js.map +1 -1
- package/dist/__tests__/views/media-seo-audit.render.test.d.ts +2 -0
- package/dist/__tests__/views/media-seo-audit.render.test.d.ts.map +1 -0
- package/dist/__tests__/views/media-seo-audit.render.test.js +102 -0
- package/dist/__tests__/views/media-seo-audit.render.test.js.map +1 -0
- package/dist/__tests__/views/onboarding-block.render.test.d.ts +2 -0
- package/dist/__tests__/views/onboarding-block.render.test.d.ts.map +1 -0
- package/dist/__tests__/views/onboarding-block.render.test.js +146 -0
- package/dist/__tests__/views/onboarding-block.render.test.js.map +1 -0
- package/dist/__tests__/views/page-builder-sections-panel.render.test.d.ts +2 -0
- package/dist/__tests__/views/page-builder-sections-panel.render.test.d.ts.map +1 -0
- package/dist/__tests__/views/page-builder-sections-panel.render.test.js +121 -0
- package/dist/__tests__/views/page-builder-sections-panel.render.test.js.map +1 -0
- package/dist/__tests__/views/page-templates.render.test.d.ts +2 -0
- package/dist/__tests__/views/page-templates.render.test.d.ts.map +1 -0
- package/dist/__tests__/views/page-templates.render.test.js +105 -0
- package/dist/__tests__/views/page-templates.render.test.js.map +1 -0
- package/dist/__tests__/views/pages-list-view.test.js +77 -1
- package/dist/__tests__/views/pages-list-view.test.js.map +1 -1
- package/dist/__tests__/views/post-type-editor.render.test.d.ts +2 -0
- package/dist/__tests__/views/post-type-editor.render.test.d.ts.map +1 -0
- package/dist/__tests__/views/post-type-editor.render.test.js +102 -0
- package/dist/__tests__/views/post-type-editor.render.test.js.map +1 -0
- package/dist/__tests__/views/security-settings.render.test.js +85 -4
- package/dist/__tests__/views/security-settings.render.test.js.map +1 -1
- package/dist/__tests__/views/seo-content-pane.render.test.d.ts +2 -0
- package/dist/__tests__/views/seo-content-pane.render.test.d.ts.map +1 -0
- package/dist/__tests__/views/seo-content-pane.render.test.js +117 -0
- package/dist/__tests__/views/seo-content-pane.render.test.js.map +1 -0
- package/dist/actuate-admin.css +1 -1
- package/dist/components/collections/EntryDetailPane.d.ts +10 -0
- package/dist/components/collections/EntryDetailPane.d.ts.map +1 -0
- package/dist/components/collections/EntryDetailPane.js +92 -0
- package/dist/components/collections/EntryDetailPane.js.map +1 -0
- package/dist/components/forms/FieldsPanel.js +1 -1
- package/dist/components/forms/FieldsPanel.js.map +1 -1
- package/dist/components/forms/FormSchemaPane.d.ts +8 -0
- package/dist/components/forms/FormSchemaPane.d.ts.map +1 -0
- package/dist/components/forms/FormSchemaPane.js +95 -0
- package/dist/components/forms/FormSchemaPane.js.map +1 -0
- package/dist/components/script-tags/ScriptTagEditorPane.d.ts +26 -0
- package/dist/components/script-tags/ScriptTagEditorPane.d.ts.map +1 -0
- package/dist/components/script-tags/ScriptTagEditorPane.js +160 -0
- package/dist/components/script-tags/ScriptTagEditorPane.js.map +1 -0
- package/dist/components/seo/SeoEditorPane.d.ts +9 -0
- package/dist/components/seo/SeoEditorPane.d.ts.map +1 -0
- package/dist/components/seo/SeoEditorPane.js +175 -0
- package/dist/components/seo/SeoEditorPane.js.map +1 -0
- package/dist/components/ui/Badge.d.ts +1 -1
- package/dist/components/ui/Button.d.ts +1 -1
- package/dist/components/ui/InspectorPane.d.ts +51 -0
- package/dist/components/ui/InspectorPane.d.ts.map +1 -0
- package/dist/components/ui/InspectorPane.js +42 -0
- package/dist/components/ui/InspectorPane.js.map +1 -0
- package/dist/components/ui/StatusBadge.d.ts +1 -1
- package/dist/components/ui/index.d.ts +2 -0
- package/dist/components/ui/index.d.ts.map +1 -1
- package/dist/components/ui/index.js +1 -0
- package/dist/components/ui/index.js.map +1 -1
- package/dist/hooks/useIdleLogout.d.ts +18 -0
- package/dist/hooks/useIdleLogout.d.ts.map +1 -0
- package/dist/hooks/useIdleLogout.js +66 -0
- package/dist/hooks/useIdleLogout.js.map +1 -0
- package/dist/lib/api.d.ts +1 -0
- package/dist/lib/api.d.ts.map +1 -1
- package/dist/lib/api.js +7 -1
- package/dist/lib/api.js.map +1 -1
- package/dist/lib/page-editor-service.d.ts +11 -2
- package/dist/lib/page-editor-service.d.ts.map +1 -1
- package/dist/lib/page-editor-service.js +8 -2
- package/dist/lib/page-editor-service.js.map +1 -1
- package/dist/lib/pages-tree.d.ts +44 -0
- package/dist/lib/pages-tree.d.ts.map +1 -0
- package/dist/lib/pages-tree.js +106 -0
- package/dist/lib/pages-tree.js.map +1 -0
- package/dist/lib/post-types-service.d.ts +44 -0
- package/dist/lib/post-types-service.d.ts.map +1 -0
- package/dist/lib/post-types-service.js +83 -0
- package/dist/lib/post-types-service.js.map +1 -0
- package/dist/views/CollectionList.d.ts.map +1 -1
- package/dist/views/CollectionList.js +23 -4
- package/dist/views/CollectionList.js.map +1 -1
- package/dist/views/Dashboard.d.ts.map +1 -1
- package/dist/views/Dashboard.js +31 -2
- package/dist/views/Dashboard.js.map +1 -1
- package/dist/views/Forms.d.ts.map +1 -1
- package/dist/views/Forms.js +25 -21
- package/dist/views/Forms.js.map +1 -1
- package/dist/views/MediaBrowser.d.ts.map +1 -1
- package/dist/views/MediaBrowser.js +28 -2
- package/dist/views/MediaBrowser.js.map +1 -1
- package/dist/views/MediaSeoAudit.d.ts +25 -0
- package/dist/views/MediaSeoAudit.d.ts.map +1 -0
- package/dist/views/MediaSeoAudit.js +97 -0
- package/dist/views/MediaSeoAudit.js.map +1 -0
- package/dist/views/Pages/PagesListView.d.ts.map +1 -1
- package/dist/views/Pages/PagesListView.js +90 -10
- package/dist/views/Pages/PagesListView.js.map +1 -1
- package/dist/views/Pages/PagesTreeTable.d.ts +35 -0
- package/dist/views/Pages/PagesTreeTable.d.ts.map +1 -0
- package/dist/views/Pages/PagesTreeTable.js +28 -0
- package/dist/views/Pages/PagesTreeTable.js.map +1 -0
- package/dist/views/Posts/PostTypeEditor.d.ts +16 -0
- package/dist/views/Posts/PostTypeEditor.d.ts.map +1 -0
- package/dist/views/Posts/PostTypeEditor.js +159 -0
- package/dist/views/Posts/PostTypeEditor.js.map +1 -0
- package/dist/views/Posts/PostTypesView.d.ts +3 -2
- package/dist/views/Posts/PostTypesView.d.ts.map +1 -1
- package/dist/views/Posts/PostTypesView.js +1 -1
- package/dist/views/Posts/PostTypesView.js.map +1 -1
- package/dist/views/ScriptTags.d.ts +5 -1
- package/dist/views/ScriptTags.d.ts.map +1 -1
- package/dist/views/ScriptTags.js +32 -7
- package/dist/views/ScriptTags.js.map +1 -1
- package/dist/views/dashboard/OnboardingBlock.d.ts +29 -0
- package/dist/views/dashboard/OnboardingBlock.d.ts.map +1 -0
- package/dist/views/dashboard/OnboardingBlock.js +71 -0
- package/dist/views/dashboard/OnboardingBlock.js.map +1 -0
- package/dist/views/page-builder/BuilderSectionsPanel.d.ts +20 -0
- package/dist/views/page-builder/BuilderSectionsPanel.d.ts.map +1 -0
- package/dist/views/page-builder/BuilderSectionsPanel.js +105 -0
- package/dist/views/page-builder/BuilderSectionsPanel.js.map +1 -0
- package/dist/views/page-builder/PageBuilder.d.ts.map +1 -1
- package/dist/views/page-builder/PageBuilder.js +51 -2
- package/dist/views/page-builder/PageBuilder.js.map +1 -1
- package/dist/views/page-builder/PageTemplates.d.ts.map +1 -1
- package/dist/views/page-builder/PageTemplates.js +113 -6
- package/dist/views/page-builder/PageTemplates.js.map +1 -1
- package/dist/views/page-editor/PageSectionEditor.d.ts +7 -1
- package/dist/views/page-editor/PageSectionEditor.d.ts.map +1 -1
- package/dist/views/page-editor/PageSectionEditor.js +3 -3
- package/dist/views/page-editor/PageSectionEditor.js.map +1 -1
- package/dist/views/page-editor/PageSettingsModal.d.ts +1 -1
- package/dist/views/page-editor/PageSettingsModal.d.ts.map +1 -1
- package/dist/views/page-editor/PageSettingsModal.js +42 -3
- package/dist/views/page-editor/PageSettingsModal.js.map +1 -1
- package/dist/views/seo/ContentTab.d.ts.map +1 -1
- package/dist/views/seo/ContentTab.js +22 -6
- package/dist/views/seo/ContentTab.js.map +1 -1
- package/dist/views/settings/AccessProtectionCard.d.ts +5 -1
- package/dist/views/settings/AccessProtectionCard.d.ts.map +1 -1
- package/dist/views/settings/AccessProtectionCard.js +13 -10
- package/dist/views/settings/AccessProtectionCard.js.map +1 -1
- package/dist/views/settings/SecurityTab.d.ts.map +1 -1
- package/dist/views/settings/SecurityTab.js +3 -3
- package/dist/views/settings/SecurityTab.js.map +1 -1
- package/dist/views/settings/SessionPolicyCard.d.ts +1 -2
- package/dist/views/settings/SessionPolicyCard.d.ts.map +1 -1
- package/dist/views/settings/SessionPolicyCard.js +12 -5
- package/dist/views/settings/SessionPolicyCard.js.map +1 -1
- package/dist/views/settings/useSecuritySettings.d.ts +13 -1
- package/dist/views/settings/useSecuritySettings.d.ts.map +1 -1
- package/dist/views/settings/useSecuritySettings.js +47 -4
- package/dist/views/settings/useSecuritySettings.js.map +1 -1
- package/package.json +2 -2
- package/src/AdminRoot.tsx +43 -10
- package/src/__tests__/components/inspector-pane.test.tsx +64 -0
- package/src/__tests__/hooks/useIdleLogout.test.tsx +86 -0
- package/src/__tests__/lib/pages-tree.test.ts +197 -0
- package/src/__tests__/lib/post-types-service.test.ts +112 -0
- package/src/__tests__/views/collection-list-nav.render.test.tsx +51 -5
- package/src/__tests__/views/dashboard.test.tsx +7 -3
- package/src/__tests__/views/forms-list.render.test.tsx +34 -6
- package/src/__tests__/views/media-seo-audit.render.test.tsx +117 -0
- package/src/__tests__/views/onboarding-block.render.test.tsx +193 -0
- package/src/__tests__/views/page-builder-sections-panel.render.test.tsx +138 -0
- package/src/__tests__/views/page-templates.render.test.tsx +142 -0
- package/src/__tests__/views/pages-list-view.test.tsx +93 -1
- package/src/__tests__/views/post-type-editor.render.test.tsx +116 -0
- package/src/__tests__/views/security-settings.render.test.tsx +111 -4
- package/src/__tests__/views/seo-content-pane.render.test.tsx +142 -0
- package/src/components/collections/EntryDetailPane.tsx +182 -0
- package/src/components/forms/FieldsPanel.tsx +3 -1
- package/src/components/forms/FormSchemaPane.tsx +192 -0
- package/src/components/script-tags/ScriptTagEditorPane.tsx +474 -0
- package/src/components/seo/SeoEditorPane.tsx +533 -0
- package/src/components/ui/InspectorPane.tsx +129 -0
- package/src/components/ui/index.ts +10 -0
- package/src/hooks/useIdleLogout.ts +77 -0
- package/src/lib/api.ts +8 -2
- package/src/lib/page-editor-service.ts +16 -2
- package/src/lib/pages-tree.ts +145 -0
- package/src/lib/post-types-service.ts +129 -0
- package/src/views/CollectionList.tsx +158 -110
- package/src/views/Dashboard.tsx +238 -196
- package/src/views/Forms.tsx +154 -126
- package/src/views/MediaBrowser.tsx +641 -511
- package/src/views/MediaSeoAudit.tsx +320 -0
- package/src/views/Pages/PagesListView.tsx +352 -183
- package/src/views/Pages/PagesTreeTable.tsx +339 -0
- package/src/views/Posts/PostTypeEditor.tsx +568 -0
- package/src/views/Posts/PostTypesView.tsx +5 -4
- package/src/views/ScriptTags.tsx +204 -129
- package/src/views/dashboard/OnboardingBlock.tsx +233 -0
- package/src/views/page-builder/BuilderSectionsPanel.tsx +304 -0
- package/src/views/page-builder/PageBuilder.tsx +69 -2
- package/src/views/page-builder/PageTemplates.tsx +340 -57
- package/src/views/page-editor/PageSectionEditor.tsx +9 -2
- package/src/views/page-editor/PageSettingsModal.tsx +75 -2
- package/src/views/seo/ContentTab.tsx +113 -80
- package/src/views/settings/AccessProtectionCard.tsx +83 -53
- package/src/views/settings/SecurityTab.tsx +23 -2
- package/src/views/settings/SessionPolicyCard.tsx +45 -9
- package/src/views/settings/useSecuritySettings.ts +78 -22
- package/dist/components/forms/FormSchemaDrawer.d.ts +0 -9
- package/dist/components/forms/FormSchemaDrawer.d.ts.map +0 -1
- package/dist/components/forms/FormSchemaDrawer.js +0 -99
- package/dist/components/forms/FormSchemaDrawer.js.map +0 -1
- package/dist/components/seo/SeoEditorDrawer.d.ts +0 -8
- package/dist/components/seo/SeoEditorDrawer.d.ts.map +0 -1
- package/dist/components/seo/SeoEditorDrawer.js +0 -172
- package/dist/components/seo/SeoEditorDrawer.js.map +0 -1
- package/dist/views/ScriptTagEditor.d.ts +0 -6
- package/dist/views/ScriptTagEditor.d.ts.map +0 -1
- package/dist/views/ScriptTagEditor.js +0 -111
- package/dist/views/ScriptTagEditor.js.map +0 -1
- package/src/components/forms/FormSchemaDrawer.tsx +0 -192
- package/src/components/seo/SeoEditorDrawer.tsx +0 -529
- package/src/views/ScriptTagEditor.tsx +0 -353
|
@@ -0,0 +1,533 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Inline SEO editor pane — design-parity replacement for the old
|
|
5
|
+
* `SeoEditorDrawer` overlay. Rendered inside an `<InspectorPane>` beside the
|
|
6
|
+
* Content SEO list (380px in the handoff). Same fields, validation, AI
|
|
7
|
+
* assists, and `updateSeoFields` save path as the drawer; only the
|
|
8
|
+
* presentation pattern changed.
|
|
9
|
+
*/
|
|
10
|
+
import { cloneElement, isValidElement, useEffect, useId, useState, type ReactElement } from 'react'
|
|
11
|
+
import { Sparkles, Check, Loader2, Wand2 } from 'lucide-react'
|
|
12
|
+
import { toast } from 'sonner'
|
|
13
|
+
import type { BrandAlignmentScoreResult } from '@actuate-media/cms-core/brand-voice'
|
|
14
|
+
import {
|
|
15
|
+
fetchSeoContentRecord,
|
|
16
|
+
updateSeoFields,
|
|
17
|
+
generateSeoField,
|
|
18
|
+
SEO_SCHEMA_TYPES,
|
|
19
|
+
type SeoContentRecord,
|
|
20
|
+
type SeoFieldPayload,
|
|
21
|
+
type LlmsInclude,
|
|
22
|
+
} from '../../lib/seo-service.js'
|
|
23
|
+
import { fixBrandVoiceContent } from '../../lib/brand-voice-client.js'
|
|
24
|
+
import { BrandVoiceAlignmentBadge } from '../BrandVoiceAlignmentBadge.js'
|
|
25
|
+
import {
|
|
26
|
+
InspectorPaneBody,
|
|
27
|
+
InspectorPaneFooter,
|
|
28
|
+
InspectorPaneHeader,
|
|
29
|
+
InspectorPaneSection,
|
|
30
|
+
} from '../ui/InspectorPane.js'
|
|
31
|
+
import { Toggle } from '../ui/Toggle.js'
|
|
32
|
+
import { SeoLoading, SeoErrorState, gradeMeta } from './primitives.js'
|
|
33
|
+
|
|
34
|
+
const META_TITLE_MAX = 60
|
|
35
|
+
const META_DESC_MAX = 160
|
|
36
|
+
const KEY_TAKEAWAY_MAX = 240
|
|
37
|
+
const LLMS_DESC_MAX = 160
|
|
38
|
+
|
|
39
|
+
type AiField =
|
|
40
|
+
| 'metaTitle'
|
|
41
|
+
| 'metaDescription'
|
|
42
|
+
| 'ogTitle'
|
|
43
|
+
| 'ogDescription'
|
|
44
|
+
| 'focusKeyword'
|
|
45
|
+
| 'keyTakeaway'
|
|
46
|
+
|
|
47
|
+
function seoBrandChannel(field: AiField): string | undefined {
|
|
48
|
+
if (field === 'metaDescription' || field === 'ogDescription') return 'meta_description'
|
|
49
|
+
return undefined
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const inputCls =
|
|
53
|
+
'w-full rounded-[7px] border border-[var(--bdr)] bg-[var(--card)] px-2.5 py-1.5 text-[12.5px] text-[var(--txt)] placeholder:text-[var(--muted)] focus:border-[var(--acc)] focus:outline-none'
|
|
54
|
+
|
|
55
|
+
function CharCount({ value, max }: { value: string; max: number }) {
|
|
56
|
+
const over = value.length > max
|
|
57
|
+
return (
|
|
58
|
+
<span className={`text-[11px] ${over ? 'text-[var(--err)]' : 'text-[var(--muted)]'}`}>
|
|
59
|
+
{value.length}/{max}
|
|
60
|
+
</span>
|
|
61
|
+
)
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function PaneField({
|
|
65
|
+
label,
|
|
66
|
+
children,
|
|
67
|
+
hint,
|
|
68
|
+
}: {
|
|
69
|
+
label: string
|
|
70
|
+
children: React.ReactNode
|
|
71
|
+
hint?: React.ReactNode
|
|
72
|
+
}) {
|
|
73
|
+
// Associate the visible label with its control for screen readers. We inject
|
|
74
|
+
// a generated id onto the single child input/textarea (unless it already has
|
|
75
|
+
// one) and point the <label htmlFor> at it.
|
|
76
|
+
const generatedId = useId()
|
|
77
|
+
let control = children
|
|
78
|
+
if (isValidElement(children) && (children.props as { id?: string }).id == null) {
|
|
79
|
+
control = cloneElement(children as ReactElement<{ id?: string }>, { id: generatedId })
|
|
80
|
+
}
|
|
81
|
+
const controlId = isValidElement(control) ? (control.props as { id?: string }).id : undefined
|
|
82
|
+
|
|
83
|
+
return (
|
|
84
|
+
<div className="mb-3 last:mb-0">
|
|
85
|
+
<div className="mb-1 flex items-center justify-between gap-2">
|
|
86
|
+
<label htmlFor={controlId} className="text-[11.5px] font-medium text-[var(--sub)]">
|
|
87
|
+
{label}
|
|
88
|
+
</label>
|
|
89
|
+
{hint}
|
|
90
|
+
</div>
|
|
91
|
+
{control}
|
|
92
|
+
</div>
|
|
93
|
+
)
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function PaneToggleRow({
|
|
97
|
+
label,
|
|
98
|
+
hint,
|
|
99
|
+
checked,
|
|
100
|
+
onCheckedChange,
|
|
101
|
+
}: {
|
|
102
|
+
label: string
|
|
103
|
+
hint: string
|
|
104
|
+
checked: boolean
|
|
105
|
+
onCheckedChange: (checked: boolean) => void
|
|
106
|
+
}) {
|
|
107
|
+
return (
|
|
108
|
+
<div className="flex items-center justify-between gap-3 py-1.5">
|
|
109
|
+
<div className="min-w-0">
|
|
110
|
+
<div className="text-[12.5px] font-medium text-[var(--txt)]">{label}</div>
|
|
111
|
+
<div className="text-[11px] text-[var(--muted)]">{hint}</div>
|
|
112
|
+
</div>
|
|
113
|
+
<Toggle checked={checked} onCheckedChange={onCheckedChange} aria-label={label} />
|
|
114
|
+
</div>
|
|
115
|
+
)
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
export interface SeoEditorPaneProps {
|
|
119
|
+
entityType: string
|
|
120
|
+
entityId: string
|
|
121
|
+
onClose: () => void
|
|
122
|
+
/** Called after a successful save so the list can refetch. */
|
|
123
|
+
onSaved?: () => void
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
export function SeoEditorPane({ entityType, entityId, onClose, onSaved }: SeoEditorPaneProps) {
|
|
127
|
+
const [record, setRecord] = useState<SeoContentRecord | null>(null)
|
|
128
|
+
const [loading, setLoading] = useState(true)
|
|
129
|
+
const [error, setError] = useState<string | null>(null)
|
|
130
|
+
const [saving, setSaving] = useState(false)
|
|
131
|
+
const [aiBusy, setAiBusy] = useState<AiField | null>(null)
|
|
132
|
+
const [aiFixBusy, setAiFixBusy] = useState<AiField | null>(null)
|
|
133
|
+
const [aiAlignments, setAiAlignments] = useState<
|
|
134
|
+
Partial<Record<AiField, BrandAlignmentScoreResult>>
|
|
135
|
+
>({})
|
|
136
|
+
const [form, setForm] = useState<SeoFieldPayload>({})
|
|
137
|
+
|
|
138
|
+
useEffect(() => {
|
|
139
|
+
let alive = true
|
|
140
|
+
setLoading(true)
|
|
141
|
+
setError(null)
|
|
142
|
+
fetchSeoContentRecord(entityType, entityId)
|
|
143
|
+
.then((rec) => {
|
|
144
|
+
if (!alive) return
|
|
145
|
+
if (!rec) {
|
|
146
|
+
setError('Could not load this record.')
|
|
147
|
+
return
|
|
148
|
+
}
|
|
149
|
+
setRecord(rec)
|
|
150
|
+
setForm({
|
|
151
|
+
metaTitle: rec.metaTitle ?? '',
|
|
152
|
+
metaDescription: rec.metaDescription ?? '',
|
|
153
|
+
canonicalUrl: rec.canonicalUrl ?? '',
|
|
154
|
+
focusKeyword: rec.focusKeyword ?? '',
|
|
155
|
+
keyTakeaway: rec.keyTakeaway ?? '',
|
|
156
|
+
ogTitle: rec.ogTitle ?? '',
|
|
157
|
+
ogDescription: rec.ogDescription ?? '',
|
|
158
|
+
structuredDataType: rec.structuredDataType ?? 'WebPage',
|
|
159
|
+
noindex: rec.noindex,
|
|
160
|
+
nofollow: rec.nofollow,
|
|
161
|
+
llmsInclude: rec.llmsInclude ?? 'AUTO',
|
|
162
|
+
llmsDescription: rec.llmsDescription ?? '',
|
|
163
|
+
llmsSection: rec.llmsSection ?? '',
|
|
164
|
+
llmsOptional: rec.llmsOptional ?? false,
|
|
165
|
+
})
|
|
166
|
+
})
|
|
167
|
+
.catch((e: unknown) => alive && setError(e instanceof Error ? e.message : 'Failed to load'))
|
|
168
|
+
.finally(() => alive && setLoading(false))
|
|
169
|
+
return () => {
|
|
170
|
+
alive = false
|
|
171
|
+
}
|
|
172
|
+
}, [entityType, entityId])
|
|
173
|
+
|
|
174
|
+
const update = (patch: Partial<SeoFieldPayload>) => setForm((f) => ({ ...f, ...patch }))
|
|
175
|
+
|
|
176
|
+
async function handleAiGenerate(field: AiField) {
|
|
177
|
+
setAiBusy(field)
|
|
178
|
+
try {
|
|
179
|
+
const result = await generateSeoField(field, { entityType, entityId })
|
|
180
|
+
if (result.unavailable) {
|
|
181
|
+
toast.error('AI is not configured. Add an AI provider to enable suggestions.')
|
|
182
|
+
return
|
|
183
|
+
}
|
|
184
|
+
if (result.text) {
|
|
185
|
+
update({ [field]: result.text } as Partial<SeoFieldPayload>)
|
|
186
|
+
if (result.brandAlignment) {
|
|
187
|
+
setAiAlignments((prev) => ({ ...prev, [field]: result.brandAlignment }))
|
|
188
|
+
}
|
|
189
|
+
toast.success('AI suggestion applied — review before saving.')
|
|
190
|
+
} else {
|
|
191
|
+
toast.error('No suggestion was returned.')
|
|
192
|
+
}
|
|
193
|
+
} catch {
|
|
194
|
+
toast.error('AI request failed.')
|
|
195
|
+
} finally {
|
|
196
|
+
setAiBusy(null)
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
async function handleAiFix(field: AiField) {
|
|
201
|
+
const text = (form[field as keyof SeoFieldPayload] as string | undefined) ?? ''
|
|
202
|
+
if (!text.trim()) return
|
|
203
|
+
setAiFixBusy(field)
|
|
204
|
+
try {
|
|
205
|
+
const res = await fixBrandVoiceContent({
|
|
206
|
+
content: text,
|
|
207
|
+
channel: seoBrandChannel(field),
|
|
208
|
+
})
|
|
209
|
+
if (!res.ok || !res.result) {
|
|
210
|
+
toast.error(res.error ?? 'Could not fix copy for brand voice.')
|
|
211
|
+
return
|
|
212
|
+
}
|
|
213
|
+
update({ [field]: res.result.text } as Partial<SeoFieldPayload>)
|
|
214
|
+
setAiAlignments((prev) => ({ ...prev, [field]: res.result!.afterScore }))
|
|
215
|
+
toast.success('Copy adjusted to match brand voice.')
|
|
216
|
+
} finally {
|
|
217
|
+
setAiFixBusy(null)
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
function alignmentRow(field: AiField) {
|
|
222
|
+
const alignment = aiAlignments[field]
|
|
223
|
+
if (!alignment) return null
|
|
224
|
+
const fixing = aiFixBusy === field
|
|
225
|
+
return (
|
|
226
|
+
<div className="mt-2 flex flex-wrap items-center gap-2">
|
|
227
|
+
<BrandVoiceAlignmentBadge alignment={alignment} compact />
|
|
228
|
+
{alignment.score < 85 && (
|
|
229
|
+
<button
|
|
230
|
+
type="button"
|
|
231
|
+
onClick={() => void handleAiFix(field)}
|
|
232
|
+
disabled={fixing || aiBusy !== null}
|
|
233
|
+
className="inline-flex items-center gap-1 rounded-md px-2 py-0.5 text-[11.5px] font-medium text-[var(--acc)] transition-colors duration-[var(--motion-fast)] hover:bg-[var(--acc-l)] focus-visible:ring-2 focus-visible:ring-[var(--ring)] focus-visible:outline-none disabled:opacity-60"
|
|
234
|
+
>
|
|
235
|
+
{fixing ? (
|
|
236
|
+
<Loader2 className="h-3.5 w-3.5 animate-spin" aria-hidden />
|
|
237
|
+
) : (
|
|
238
|
+
<Wand2 className="h-3.5 w-3.5" aria-hidden />
|
|
239
|
+
)}
|
|
240
|
+
Fix for brand voice
|
|
241
|
+
</button>
|
|
242
|
+
)}
|
|
243
|
+
</div>
|
|
244
|
+
)
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
async function handleSave() {
|
|
248
|
+
setSaving(true)
|
|
249
|
+
const res = await updateSeoFields(entityType, entityId, form)
|
|
250
|
+
setSaving(false)
|
|
251
|
+
if (res.error) {
|
|
252
|
+
toast.error(res.error)
|
|
253
|
+
return
|
|
254
|
+
}
|
|
255
|
+
toast.success('SEO fields saved.')
|
|
256
|
+
onSaved?.()
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
const title = form.metaTitle || record?.title || 'Untitled'
|
|
260
|
+
const desc = form.metaDescription || 'No meta description set. Search engines may generate one.'
|
|
261
|
+
const url = record?.url ?? ''
|
|
262
|
+
|
|
263
|
+
function aiButton(field: AiField) {
|
|
264
|
+
return (
|
|
265
|
+
<button
|
|
266
|
+
type="button"
|
|
267
|
+
onClick={() => void handleAiGenerate(field)}
|
|
268
|
+
disabled={aiBusy !== null}
|
|
269
|
+
className="inline-flex items-center gap-1 rounded-md px-1.5 py-0.5 text-[11px] font-medium text-[var(--acc)] transition-colors duration-[var(--motion-fast)] hover:bg-[var(--acc-l)] focus-visible:ring-2 focus-visible:ring-[var(--ring)] focus-visible:outline-none disabled:opacity-60"
|
|
270
|
+
>
|
|
271
|
+
<Sparkles className={`h-3 w-3 ${aiBusy === field ? 'animate-pulse' : ''}`} aria-hidden />
|
|
272
|
+
{aiBusy === field ? 'Generating…' : 'AI Generate'}
|
|
273
|
+
</button>
|
|
274
|
+
)
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
return (
|
|
278
|
+
<>
|
|
279
|
+
<InspectorPaneHeader onClose={onClose} closeLabel="Close SEO editor">
|
|
280
|
+
<div className="truncate text-[13.5px] font-semibold text-[var(--txt)]">
|
|
281
|
+
{record?.title ?? 'Edit SEO'}
|
|
282
|
+
</div>
|
|
283
|
+
{url && <div className="mt-0.5 truncate text-[11.5px] text-[var(--muted)]">{url}</div>}
|
|
284
|
+
</InspectorPaneHeader>
|
|
285
|
+
|
|
286
|
+
<InspectorPaneBody>
|
|
287
|
+
{loading ? (
|
|
288
|
+
<div className="px-4 py-3.5">
|
|
289
|
+
<SeoLoading />
|
|
290
|
+
</div>
|
|
291
|
+
) : error ? (
|
|
292
|
+
<div className="px-4 py-3.5">
|
|
293
|
+
<SeoErrorState message={error} />
|
|
294
|
+
</div>
|
|
295
|
+
) : record ? (
|
|
296
|
+
<>
|
|
297
|
+
<InspectorPaneSection title="Google Preview">
|
|
298
|
+
<div className="rounded-[7px] border border-[var(--bdr)] bg-[var(--card)] p-3">
|
|
299
|
+
<p className="truncate text-[11.5px] text-[var(--suc)]">{url}</p>
|
|
300
|
+
<p className="text-[15px] leading-snug text-[var(--acc)]">{title}</p>
|
|
301
|
+
<p className="line-clamp-2 text-[12px] text-[var(--sub)]">{desc}</p>
|
|
302
|
+
</div>
|
|
303
|
+
</InspectorPaneSection>
|
|
304
|
+
|
|
305
|
+
<InspectorPaneSection title="Meta Tags">
|
|
306
|
+
<PaneField
|
|
307
|
+
label="Meta title"
|
|
308
|
+
hint={
|
|
309
|
+
<span className="flex items-center gap-2">
|
|
310
|
+
<CharCount value={form.metaTitle ?? ''} max={META_TITLE_MAX} />
|
|
311
|
+
{aiButton('metaTitle')}
|
|
312
|
+
</span>
|
|
313
|
+
}
|
|
314
|
+
>
|
|
315
|
+
<input
|
|
316
|
+
className={inputCls}
|
|
317
|
+
value={form.metaTitle ?? ''}
|
|
318
|
+
onChange={(e) => update({ metaTitle: e.target.value })}
|
|
319
|
+
placeholder="Page title for search results"
|
|
320
|
+
/>
|
|
321
|
+
</PaneField>
|
|
322
|
+
<PaneField
|
|
323
|
+
label="Meta description"
|
|
324
|
+
hint={
|
|
325
|
+
<span className="flex items-center gap-2">
|
|
326
|
+
<CharCount value={form.metaDescription ?? ''} max={META_DESC_MAX} />
|
|
327
|
+
{aiButton('metaDescription')}
|
|
328
|
+
</span>
|
|
329
|
+
}
|
|
330
|
+
>
|
|
331
|
+
<textarea
|
|
332
|
+
className={`${inputCls} min-h-20 resize-y`}
|
|
333
|
+
value={form.metaDescription ?? ''}
|
|
334
|
+
onChange={(e) => update({ metaDescription: e.target.value })}
|
|
335
|
+
placeholder="Short summary shown under the title in search results"
|
|
336
|
+
/>
|
|
337
|
+
</PaneField>
|
|
338
|
+
{alignmentRow('metaDescription')}
|
|
339
|
+
</InspectorPaneSection>
|
|
340
|
+
|
|
341
|
+
<InspectorPaneSection title="Focus Keyword">
|
|
342
|
+
<PaneField label="Target keyword" hint={aiButton('focusKeyword')}>
|
|
343
|
+
<input
|
|
344
|
+
className={inputCls}
|
|
345
|
+
value={form.focusKeyword ?? ''}
|
|
346
|
+
onChange={(e) => update({ focusKeyword: e.target.value })}
|
|
347
|
+
placeholder="Primary keyword this page targets"
|
|
348
|
+
/>
|
|
349
|
+
</PaneField>
|
|
350
|
+
<PaneField
|
|
351
|
+
label="Key takeaway"
|
|
352
|
+
hint={
|
|
353
|
+
<span className="flex items-center gap-2">
|
|
354
|
+
<CharCount value={form.keyTakeaway ?? ''} max={KEY_TAKEAWAY_MAX} />
|
|
355
|
+
{aiButton('keyTakeaway')}
|
|
356
|
+
</span>
|
|
357
|
+
}
|
|
358
|
+
>
|
|
359
|
+
<textarea
|
|
360
|
+
className={`${inputCls} min-h-16 resize-y`}
|
|
361
|
+
value={form.keyTakeaway ?? ''}
|
|
362
|
+
onChange={(e) => update({ keyTakeaway: e.target.value })}
|
|
363
|
+
placeholder="One or two sentences AI answer engines can quote — what is this page about?"
|
|
364
|
+
/>
|
|
365
|
+
</PaneField>
|
|
366
|
+
</InspectorPaneSection>
|
|
367
|
+
|
|
368
|
+
<InspectorPaneSection title="Open Graph" subtitle="Social sharing">
|
|
369
|
+
<PaneField label="OG title" hint={aiButton('ogTitle')}>
|
|
370
|
+
<input
|
|
371
|
+
className={inputCls}
|
|
372
|
+
value={form.ogTitle ?? ''}
|
|
373
|
+
onChange={(e) => update({ ogTitle: e.target.value })}
|
|
374
|
+
placeholder={form.metaTitle || 'Title when shared on social media'}
|
|
375
|
+
/>
|
|
376
|
+
</PaneField>
|
|
377
|
+
<PaneField label="OG description" hint={aiButton('ogDescription')}>
|
|
378
|
+
<textarea
|
|
379
|
+
className={`${inputCls} min-h-16 resize-y`}
|
|
380
|
+
value={form.ogDescription ?? ''}
|
|
381
|
+
onChange={(e) => update({ ogDescription: e.target.value })}
|
|
382
|
+
placeholder={form.metaDescription || 'Description when shared on social media'}
|
|
383
|
+
/>
|
|
384
|
+
</PaneField>
|
|
385
|
+
{alignmentRow('ogDescription')}
|
|
386
|
+
<div className="mt-3 overflow-hidden rounded-[7px] border border-[var(--bdr)]">
|
|
387
|
+
{record.ogImage ? (
|
|
388
|
+
// eslint-disable-next-line @next/next/no-img-element
|
|
389
|
+
<img
|
|
390
|
+
src={record.ogImage}
|
|
391
|
+
alt={`Social preview for ${record.title}`}
|
|
392
|
+
className="aspect-[1200/630] w-full bg-[var(--bg)] object-cover"
|
|
393
|
+
/>
|
|
394
|
+
) : (
|
|
395
|
+
<div className="flex aspect-[1200/630] w-full items-center justify-center bg-[var(--bg)] text-[12px] text-[var(--muted)]">
|
|
396
|
+
No OG image set
|
|
397
|
+
</div>
|
|
398
|
+
)}
|
|
399
|
+
<div className="p-2.5">
|
|
400
|
+
<p className="truncate text-[10.5px] text-[var(--muted)] uppercase">
|
|
401
|
+
{url.replace(/^https?:\/\//, '').split('/')[0]}
|
|
402
|
+
</p>
|
|
403
|
+
<p className="truncate text-[12.5px] font-medium text-[var(--txt)]">
|
|
404
|
+
{form.ogTitle || title}
|
|
405
|
+
</p>
|
|
406
|
+
<p className="line-clamp-1 text-[12px] text-[var(--sub)]">
|
|
407
|
+
{form.ogDescription || desc}
|
|
408
|
+
</p>
|
|
409
|
+
</div>
|
|
410
|
+
</div>
|
|
411
|
+
</InspectorPaneSection>
|
|
412
|
+
|
|
413
|
+
<InspectorPaneSection title="Technical SEO">
|
|
414
|
+
<PaneField label="Canonical URL">
|
|
415
|
+
<input
|
|
416
|
+
className={inputCls}
|
|
417
|
+
value={form.canonicalUrl ?? ''}
|
|
418
|
+
onChange={(e) => update({ canonicalUrl: e.target.value })}
|
|
419
|
+
placeholder={url || 'https://example.com/page'}
|
|
420
|
+
/>
|
|
421
|
+
</PaneField>
|
|
422
|
+
{!form.canonicalUrl?.trim() && url ? (
|
|
423
|
+
<p className="mb-3 text-[11px] text-[var(--muted)]">
|
|
424
|
+
Auto-canonical: {record.canonicalUrl ?? url}
|
|
425
|
+
</p>
|
|
426
|
+
) : null}
|
|
427
|
+
<PaneField label="Schema type">
|
|
428
|
+
<select
|
|
429
|
+
className={inputCls}
|
|
430
|
+
value={form.structuredDataType ?? 'WebPage'}
|
|
431
|
+
onChange={(e) => update({ structuredDataType: e.target.value })}
|
|
432
|
+
>
|
|
433
|
+
{SEO_SCHEMA_TYPES.map((t) => (
|
|
434
|
+
<option key={t} value={t}>
|
|
435
|
+
{t}
|
|
436
|
+
</option>
|
|
437
|
+
))}
|
|
438
|
+
</select>
|
|
439
|
+
</PaneField>
|
|
440
|
+
<PaneToggleRow
|
|
441
|
+
label="No-index"
|
|
442
|
+
hint="Hide this page from search engines"
|
|
443
|
+
checked={!!form.noindex}
|
|
444
|
+
onCheckedChange={(v) => update({ noindex: v })}
|
|
445
|
+
/>
|
|
446
|
+
<PaneToggleRow
|
|
447
|
+
label="No-follow"
|
|
448
|
+
hint="Tell crawlers not to follow links on this page"
|
|
449
|
+
checked={!!form.nofollow}
|
|
450
|
+
onCheckedChange={(v) => update({ nofollow: v })}
|
|
451
|
+
/>
|
|
452
|
+
</InspectorPaneSection>
|
|
453
|
+
|
|
454
|
+
<InspectorPaneSection title="AI Discovery" subtitle="llms.txt">
|
|
455
|
+
<p className="mb-3 text-[11px] text-[var(--muted)]">
|
|
456
|
+
Controls how this page appears in the curated <code>llms.txt</code> guide that AI
|
|
457
|
+
agents read. It does not affect search ranking.
|
|
458
|
+
</p>
|
|
459
|
+
<PaneField label="Include in llms.txt">
|
|
460
|
+
<select
|
|
461
|
+
className={inputCls}
|
|
462
|
+
value={form.llmsInclude ?? 'AUTO'}
|
|
463
|
+
onChange={(e) => update({ llmsInclude: e.target.value as LlmsInclude })}
|
|
464
|
+
>
|
|
465
|
+
<option value="AUTO">Auto — include when eligible</option>
|
|
466
|
+
<option value="ALWAYS">Always — pin it in</option>
|
|
467
|
+
<option value="NEVER">Never — exclude</option>
|
|
468
|
+
</select>
|
|
469
|
+
</PaneField>
|
|
470
|
+
<PaneField
|
|
471
|
+
label="Agent description"
|
|
472
|
+
hint={<CharCount value={form.llmsDescription ?? ''} max={LLMS_DESC_MAX} />}
|
|
473
|
+
>
|
|
474
|
+
<textarea
|
|
475
|
+
className={`${inputCls} min-h-16 resize-y`}
|
|
476
|
+
value={form.llmsDescription ?? ''}
|
|
477
|
+
onChange={(e) => update({ llmsDescription: e.target.value })}
|
|
478
|
+
placeholder={
|
|
479
|
+
form.keyTakeaway ||
|
|
480
|
+
form.metaDescription ||
|
|
481
|
+
'One line for an AI agent — what this page covers and why it matters.'
|
|
482
|
+
}
|
|
483
|
+
/>
|
|
484
|
+
</PaneField>
|
|
485
|
+
<PaneField label="Section">
|
|
486
|
+
<input
|
|
487
|
+
className={inputCls}
|
|
488
|
+
value={form.llmsSection ?? ''}
|
|
489
|
+
onChange={(e) => update({ llmsSection: e.target.value })}
|
|
490
|
+
placeholder="Defaults to the content type"
|
|
491
|
+
/>
|
|
492
|
+
</PaneField>
|
|
493
|
+
<PaneToggleRow
|
|
494
|
+
label="Optional"
|
|
495
|
+
hint="Group under “Optional” — agents skip these first when context is tight"
|
|
496
|
+
checked={!!form.llmsOptional}
|
|
497
|
+
onCheckedChange={(v) => update({ llmsOptional: v })}
|
|
498
|
+
/>
|
|
499
|
+
</InspectorPaneSection>
|
|
500
|
+
|
|
501
|
+
<InspectorPaneSection>
|
|
502
|
+
{(() => {
|
|
503
|
+
const meta = gradeMeta(record.seoGrade)
|
|
504
|
+
return (
|
|
505
|
+
<div
|
|
506
|
+
className={`flex items-center gap-2 rounded-[7px] px-3 py-2 text-[12.5px] ${meta.bg} ${meta.text}`}
|
|
507
|
+
>
|
|
508
|
+
<Check className="h-4 w-4 shrink-0" aria-hidden />
|
|
509
|
+
<span>
|
|
510
|
+
Current SEO score: <span className="font-medium">{record.seoScore}</span> (
|
|
511
|
+
{meta.label})
|
|
512
|
+
</span>
|
|
513
|
+
</div>
|
|
514
|
+
)
|
|
515
|
+
})()}
|
|
516
|
+
</InspectorPaneSection>
|
|
517
|
+
</>
|
|
518
|
+
) : null}
|
|
519
|
+
</InspectorPaneBody>
|
|
520
|
+
|
|
521
|
+
<InspectorPaneFooter>
|
|
522
|
+
<button
|
|
523
|
+
type="button"
|
|
524
|
+
onClick={() => void handleSave()}
|
|
525
|
+
disabled={saving || loading || !!error}
|
|
526
|
+
className="w-full rounded-[7px] bg-[var(--acc)] px-3 py-2 text-[13px] font-semibold text-white transition-colors duration-[var(--motion-fast)] hover:opacity-90 focus-visible:ring-2 focus-visible:ring-[var(--ring)] focus-visible:outline-none disabled:cursor-not-allowed disabled:opacity-50"
|
|
527
|
+
>
|
|
528
|
+
{saving ? 'Saving…' : 'Save Changes'}
|
|
529
|
+
</button>
|
|
530
|
+
</InspectorPaneFooter>
|
|
531
|
+
</>
|
|
532
|
+
)
|
|
533
|
+
}
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import type { ReactNode } from 'react'
|
|
4
|
+
import { X } from 'lucide-react'
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Design-system inline split-pane pattern: a list that shrinks while a
|
|
8
|
+
* fixed-width editor/detail pane slides in beside it (Script Tags 420px,
|
|
9
|
+
* Entries 400px, Forms 390px, SEO Content 380px in the handoff).
|
|
10
|
+
*
|
|
11
|
+
* Compose as:
|
|
12
|
+
*
|
|
13
|
+
* <SplitPaneLayout>
|
|
14
|
+
* <SplitPaneList>…table…</SplitPaneList>
|
|
15
|
+
* <InspectorPane open={!!selected} width={420} aria-label="Edit script">
|
|
16
|
+
* <InspectorPaneHeader onClose={close}>…</InspectorPaneHeader>
|
|
17
|
+
* <InspectorPaneBody>…sections…</InspectorPaneBody>
|
|
18
|
+
* <InspectorPaneFooter>…save…</InspectorPaneFooter>
|
|
19
|
+
* </InspectorPane>
|
|
20
|
+
* </SplitPaneLayout>
|
|
21
|
+
*/
|
|
22
|
+
export function SplitPaneLayout({
|
|
23
|
+
children,
|
|
24
|
+
className,
|
|
25
|
+
}: {
|
|
26
|
+
children: ReactNode
|
|
27
|
+
className?: string
|
|
28
|
+
}) {
|
|
29
|
+
return <div className={`flex min-h-0 flex-1 overflow-hidden ${className ?? ''}`}>{children}</div>
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function SplitPaneList({
|
|
33
|
+
children,
|
|
34
|
+
className,
|
|
35
|
+
}: {
|
|
36
|
+
children: ReactNode
|
|
37
|
+
className?: string
|
|
38
|
+
}) {
|
|
39
|
+
return <div className={`min-w-0 flex-1 overflow-y-auto ${className ?? ''}`}>{children}</div>
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export interface InspectorPaneProps {
|
|
43
|
+
open: boolean
|
|
44
|
+
/** Outer shell width in px when open (inner card fills it minus 12px gutters). */
|
|
45
|
+
width: number
|
|
46
|
+
children: ReactNode
|
|
47
|
+
'aria-label': string
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function InspectorPane({
|
|
51
|
+
open,
|
|
52
|
+
width,
|
|
53
|
+
children,
|
|
54
|
+
'aria-label': ariaLabel,
|
|
55
|
+
}: InspectorPaneProps) {
|
|
56
|
+
return (
|
|
57
|
+
<div
|
|
58
|
+
className="shrink-0 overflow-hidden bg-[var(--bg)] motion-safe:transition-[width] motion-safe:duration-200"
|
|
59
|
+
style={{ width: open ? width : 0 }}
|
|
60
|
+
aria-hidden={!open}
|
|
61
|
+
>
|
|
62
|
+
<div
|
|
63
|
+
role="region"
|
|
64
|
+
aria-label={ariaLabel}
|
|
65
|
+
className="my-3 mr-3 ml-4 flex h-[calc(100%-24px)] flex-col overflow-hidden rounded-[var(--r)] border border-[var(--bdr)] bg-[var(--sb)] shadow-[var(--shd2)]"
|
|
66
|
+
style={{ width: width - 28 }}
|
|
67
|
+
>
|
|
68
|
+
{open ? children : null}
|
|
69
|
+
</div>
|
|
70
|
+
</div>
|
|
71
|
+
)
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export function InspectorPaneHeader({
|
|
75
|
+
children,
|
|
76
|
+
onClose,
|
|
77
|
+
closeLabel = 'Close panel',
|
|
78
|
+
}: {
|
|
79
|
+
children: ReactNode
|
|
80
|
+
onClose: () => void
|
|
81
|
+
closeLabel?: string
|
|
82
|
+
}) {
|
|
83
|
+
return (
|
|
84
|
+
<div className="flex items-start gap-2 border-b border-[var(--bdr)] px-4 py-3.5">
|
|
85
|
+
<div className="min-w-0 flex-1">{children}</div>
|
|
86
|
+
<button
|
|
87
|
+
type="button"
|
|
88
|
+
onClick={onClose}
|
|
89
|
+
aria-label={closeLabel}
|
|
90
|
+
className="flex h-7 w-7 shrink-0 items-center justify-center rounded-[6px] text-[var(--muted)] transition-colors duration-[var(--motion-fast)] hover:bg-[var(--bg)] hover:text-[var(--txt)] focus-visible:ring-2 focus-visible:ring-[var(--ring)] focus-visible:outline-none"
|
|
91
|
+
>
|
|
92
|
+
<X size={16} />
|
|
93
|
+
</button>
|
|
94
|
+
</div>
|
|
95
|
+
)
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export function InspectorPaneBody({ children }: { children: ReactNode }) {
|
|
99
|
+
return <div className="min-h-0 flex-1 overflow-y-auto">{children}</div>
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** Bordered section with the design's uppercase 10.5px section title. */
|
|
103
|
+
export function InspectorPaneSection({
|
|
104
|
+
title,
|
|
105
|
+
subtitle,
|
|
106
|
+
children,
|
|
107
|
+
}: {
|
|
108
|
+
title?: string
|
|
109
|
+
subtitle?: string
|
|
110
|
+
children: ReactNode
|
|
111
|
+
}) {
|
|
112
|
+
return (
|
|
113
|
+
<div className="border-b border-[var(--bdr)] px-4 py-3.5 last:border-b-0">
|
|
114
|
+
{title && (
|
|
115
|
+
<div className="mb-2.5 flex items-baseline gap-2">
|
|
116
|
+
<span className="text-[10.5px] font-semibold tracking-[0.6px] text-[var(--muted)] uppercase">
|
|
117
|
+
{title}
|
|
118
|
+
</span>
|
|
119
|
+
{subtitle && <span className="text-[11px] text-[var(--muted)]">{subtitle}</span>}
|
|
120
|
+
</div>
|
|
121
|
+
)}
|
|
122
|
+
{children}
|
|
123
|
+
</div>
|
|
124
|
+
)
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
export function InspectorPaneFooter({ children }: { children: ReactNode }) {
|
|
128
|
+
return <div className="border-t border-[var(--bdr)] px-4 py-3.5">{children}</div>
|
|
129
|
+
}
|
|
@@ -44,6 +44,16 @@ export { StatPill } from './StatPill.js'
|
|
|
44
44
|
export type { StatPillProps } from './StatPill.js'
|
|
45
45
|
export { RowActions, RowActionButton } from './RowActions.js'
|
|
46
46
|
export type { RowActionButtonProps } from './RowActions.js'
|
|
47
|
+
export {
|
|
48
|
+
SplitPaneLayout,
|
|
49
|
+
SplitPaneList,
|
|
50
|
+
InspectorPane,
|
|
51
|
+
InspectorPaneHeader,
|
|
52
|
+
InspectorPaneBody,
|
|
53
|
+
InspectorPaneSection,
|
|
54
|
+
InspectorPaneFooter,
|
|
55
|
+
} from './InspectorPane.js'
|
|
56
|
+
export type { InspectorPaneProps } from './InspectorPane.js'
|
|
47
57
|
export { formControlBase, formControlState } from './form-control.js'
|
|
48
58
|
export { CommandPalette } from './CommandPalette.js'
|
|
49
59
|
export type { CommandPaletteProps } from './CommandPalette.js'
|