@actuate-media/cms-admin 0.73.0 → 0.75.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 +28 -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/post-fields-modal.render.test.js +3 -6
- package/dist/__tests__/views/post-fields-modal.render.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 +15 -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 +23 -0
- package/dist/components/EditorSeoAside.d.ts.map +1 -0
- package/dist/components/EditorSeoAside.js +21 -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/EditorSeoScoreBadge.d.ts +7 -0
- package/dist/components/seo/EditorSeoScoreBadge.d.ts.map +1 -0
- package/dist/components/seo/EditorSeoScoreBadge.js +9 -0
- package/dist/components/seo/EditorSeoScoreBadge.js.map +1 -0
- package/dist/components/seo/SeoAccordion.d.ts +16 -0
- package/dist/components/seo/SeoAccordion.d.ts.map +1 -0
- package/dist/components/seo/SeoAccordion.js +21 -0
- package/dist/components/seo/SeoAccordion.js.map +1 -0
- package/dist/components/seo/SeoAnalysisList.d.ts +7 -0
- package/dist/components/seo/SeoAnalysisList.d.ts.map +1 -0
- package/dist/components/seo/SeoAnalysisList.js +32 -0
- package/dist/components/seo/SeoAnalysisList.js.map +1 -0
- package/dist/components/seo/SeoEditorPane.d.ts +15 -4
- package/dist/components/seo/SeoEditorPane.d.ts.map +1 -1
- package/dist/components/seo/SeoEditorPane.js +155 -35
- package/dist/components/seo/SeoEditorPane.js.map +1 -1
- package/dist/components/seo/SeoInsightsMetrics.d.ts +6 -0
- package/dist/components/seo/SeoInsightsMetrics.d.ts.map +1 -0
- package/dist/components/seo/SeoInsightsMetrics.js +16 -0
- package/dist/components/seo/SeoInsightsMetrics.js.map +1 -0
- 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/SeoLinkingSection.d.ts +9 -0
- package/dist/components/seo/SeoLinkingSection.d.ts.map +1 -0
- package/dist/components/seo/SeoLinkingSection.js +44 -0
- package/dist/components/seo/SeoLinkingSection.js.map +1 -0
- 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/editor-seo-content.d.ts +16 -0
- package/dist/lib/editor-seo-content.d.ts.map +1 -0
- package/dist/lib/editor-seo-content.js +139 -0
- package/dist/lib/editor-seo-content.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 +91 -0
- package/dist/lib/seo-service.d.ts.map +1 -1
- package/dist/lib/seo-service.js +55 -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 +6 -1
- package/dist/views/page-editor/EditorTopBar.d.ts.map +1 -1
- package/dist/views/page-editor/EditorTopBar.js +5 -2
- 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 +36 -1
- 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 +2 -13
- package/dist/views/page-editor/PageSettingsModal.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/PostFieldsModal.d.ts +1 -1
- package/dist/views/post-editor/PostFieldsModal.d.ts.map +1 -1
- package/dist/views/post-editor/PostFieldsModal.js +17 -79
- package/dist/views/post-editor/PostFieldsModal.js.map +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 +37 -1
- package/dist/views/post-editor/PostSectionEditor.js.map +1 -1
- package/dist/views/seo/ContentTab.d.ts.map +1 -1
- package/dist/views/seo/ContentTab.js +7 -1
- package/dist/views/seo/ContentTab.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/post-fields-modal.render.test.tsx +3 -9
- package/src/__tests__/views/redirects-pane.render.test.tsx +6 -0
- package/src/__tests__/views/seo-content-pane.render.test.tsx +16 -4
- package/src/components/CoauthorResultPopover.tsx +1 -1
- package/src/components/EditorSeoAside.tsx +68 -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/EditorSeoScoreBadge.tsx +26 -0
- package/src/components/seo/SeoAccordion.tsx +76 -0
- package/src/components/seo/SeoAnalysisList.tsx +113 -0
- package/src/components/seo/SeoEditorPane.tsx +532 -250
- package/src/components/seo/SeoInsightsMetrics.tsx +109 -0
- package/src/components/seo/SeoIssueFixPanel.tsx +16 -4
- package/src/components/seo/SeoLinkingSection.tsx +151 -0
- 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/editor-seo-content.ts +144 -0
- package/src/lib/portal-container.ts +3 -2
- package/src/lib/seo-service.ts +150 -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 +27 -0
- package/src/views/page-editor/PageSectionEditor.tsx +58 -3
- package/src/views/page-editor/PageSettingsModal.tsx +1 -45
- package/src/views/page-editor/SectionInspector.tsx +2 -2
- package/src/views/page-editor/SectionsPanel.tsx +1 -1
- package/src/views/post-editor/PostFieldsModal.tsx +19 -138
- package/src/views/post-editor/PostHeaderPanel.tsx +1 -1
- package/src/views/post-editor/PostSectionEditor.tsx +60 -3
- package/src/views/seo/ContentTab.tsx +11 -5
- 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
package/src/views/ScriptTags.tsx
CHANGED
|
@@ -236,7 +236,7 @@ export function ScriptTags({ initialSelectedId, initialNew }: ScriptTagsProps) {
|
|
|
236
236
|
<div className="flex h-full min-h-0 flex-col">
|
|
237
237
|
<div className="p-3 pr-6 pb-0 sm:p-4 sm:pr-8 sm:pb-0">
|
|
238
238
|
{error && (
|
|
239
|
-
<div className="border-destructive/30 bg-destructive/10 mb-4 flex items-center gap-3 rounded-lg border p-
|
|
239
|
+
<div className="border-destructive/30 bg-destructive/10 mb-4 flex items-center gap-3 rounded-lg border p-4">
|
|
240
240
|
<AlertTriangle className="text-destructive h-5 w-5 shrink-0" />
|
|
241
241
|
<span className="text-destructive flex-1 text-sm">{error}</span>
|
|
242
242
|
<button
|
|
@@ -334,7 +334,7 @@ export function ScriptTags({ initialSelectedId, initialNew }: ScriptTagsProps) {
|
|
|
334
334
|
</div>
|
|
335
335
|
|
|
336
336
|
<SplitPaneLayout>
|
|
337
|
-
<SplitPaneList className="p-
|
|
337
|
+
<SplitPaneList className="p-4 pr-8 sm:pr-8">
|
|
338
338
|
{tags.length === 0 && !error ? (
|
|
339
339
|
<div className="rounded-lg border border-[var(--bdr)] bg-[var(--card)] p-12 text-center">
|
|
340
340
|
<Code2 className="mx-auto mb-3 h-10 w-10 text-[var(--sub)]" />
|
package/src/views/Users.tsx
CHANGED
|
@@ -982,7 +982,7 @@ export function Users({ embedded = false, currentPath }: UsersProps = {}) {
|
|
|
982
982
|
|
|
983
983
|
{/* Role filter pills */}
|
|
984
984
|
<div
|
|
985
|
-
className="border-border flex flex-wrap gap-2 border-b px-
|
|
985
|
+
className="border-border flex flex-wrap gap-2 border-b px-6 py-4"
|
|
986
986
|
role="group"
|
|
987
987
|
aria-label="Filter by role"
|
|
988
988
|
>
|
|
@@ -15,6 +15,7 @@ import {
|
|
|
15
15
|
Tablet,
|
|
16
16
|
TriangleAlert,
|
|
17
17
|
} from 'lucide-react'
|
|
18
|
+
import { EditorSeoScoreBadge } from '../../components/seo/EditorSeoScoreBadge.js'
|
|
18
19
|
import type { PageStatus } from '../../lib/page-editor-service.js'
|
|
19
20
|
import { VIEWPORTS, type ViewportId } from './viewports.js'
|
|
20
21
|
|
|
@@ -57,6 +58,11 @@ interface EditorTopBarProps {
|
|
|
57
58
|
*/
|
|
58
59
|
onToggleSitePreview?: () => void
|
|
59
60
|
onOpenSettings: () => void
|
|
61
|
+
/** Open the document SEO panel (meta, social, schema, analysis). */
|
|
62
|
+
onOpenSeo?: () => void
|
|
63
|
+
seoOpen?: boolean
|
|
64
|
+
/** Live 0–100 SEO score shown left of the SEO label. */
|
|
65
|
+
seoScore?: number
|
|
60
66
|
onSaveDraft: () => void
|
|
61
67
|
onPublish: () => void
|
|
62
68
|
/**
|
|
@@ -102,6 +108,9 @@ export function EditorTopBar({
|
|
|
102
108
|
sitePreview = false,
|
|
103
109
|
onToggleSitePreview,
|
|
104
110
|
onOpenSettings,
|
|
111
|
+
onOpenSeo,
|
|
112
|
+
seoOpen = false,
|
|
113
|
+
seoScore = 0,
|
|
105
114
|
onSaveDraft,
|
|
106
115
|
onPublish,
|
|
107
116
|
structuralTypes = [],
|
|
@@ -192,6 +201,24 @@ export function EditorTopBar({
|
|
|
192
201
|
<Settings2 className="h-4 w-4" aria-hidden />
|
|
193
202
|
</button>
|
|
194
203
|
|
|
204
|
+
{onOpenSeo && (
|
|
205
|
+
<button
|
|
206
|
+
type="button"
|
|
207
|
+
onClick={onOpenSeo}
|
|
208
|
+
aria-pressed={seoOpen}
|
|
209
|
+
aria-label={`SEO, score ${seoScore} out of 100`}
|
|
210
|
+
title={`SEO — score ${seoScore}/100. Meta tags, social preview, schema, and analysis.`}
|
|
211
|
+
className={`inline-flex items-center gap-1.5 rounded-lg px-2 py-1.5 text-sm font-medium transition-colors ${
|
|
212
|
+
seoOpen
|
|
213
|
+
? 'bg-primary text-primary-foreground'
|
|
214
|
+
: 'text-muted-foreground hover:text-foreground hover:bg-accent'
|
|
215
|
+
}`}
|
|
216
|
+
>
|
|
217
|
+
<EditorSeoScoreBadge score={seoScore} inverted={seoOpen} />
|
|
218
|
+
<span className="hidden sm:inline">SEO</span>
|
|
219
|
+
</button>
|
|
220
|
+
)}
|
|
221
|
+
|
|
195
222
|
{onHistory && (
|
|
196
223
|
<button
|
|
197
224
|
type="button"
|
|
@@ -4,6 +4,7 @@ import { useCallback, useEffect, useMemo, useState } from 'react'
|
|
|
4
4
|
import { AlertTriangle, Blocks, Loader2, Lock } from 'lucide-react'
|
|
5
5
|
import { toast } from 'sonner'
|
|
6
6
|
import { ConfirmDialog } from '../../components/ui/ConfirmDialog.js'
|
|
7
|
+
import { EditorSeoAside } from '../../components/EditorSeoAside.js'
|
|
7
8
|
import { ErrorBoundary } from '../../components/ErrorBoundary.js'
|
|
8
9
|
import { VersionHistory } from '../../components/VersionHistory.js'
|
|
9
10
|
import {
|
|
@@ -44,6 +45,7 @@ import { AddSectionModal } from './AddSectionModal.js'
|
|
|
44
45
|
import { PageSettingsModal } from './PageSettingsModal.js'
|
|
45
46
|
import { ValidationSummary } from './ValidationSummary.js'
|
|
46
47
|
import { resolveDefaultSitePreview } from '../../lib/editor-preview.js'
|
|
48
|
+
import { computeLiveSeoScore } from '../../lib/editor-seo-content.js'
|
|
47
49
|
|
|
48
50
|
interface PageSectionEditorProps {
|
|
49
51
|
/** undefined when creating a new page (`/pages/new`) */
|
|
@@ -97,6 +99,7 @@ export function PageSectionEditor({
|
|
|
97
99
|
|
|
98
100
|
const [addOpen, setAddOpen] = useState(false)
|
|
99
101
|
const [settingsOpen, setSettingsOpen] = useState(false)
|
|
102
|
+
const [seoOpen, setSeoOpen] = useState(false)
|
|
100
103
|
const [historyOpen, setHistoryOpen] = useState(false)
|
|
101
104
|
const [deleteTargetId, setDeleteTargetId] = useState<string | null>(null)
|
|
102
105
|
const [confirmPublish, setConfirmPublish] = useState(false)
|
|
@@ -126,6 +129,17 @@ export function PageSectionEditor({
|
|
|
126
129
|
[page],
|
|
127
130
|
)
|
|
128
131
|
const publishReady = Boolean(page) && publishCheck.errors.length === 0
|
|
132
|
+
|
|
133
|
+
const liveSeoScore = useMemo(() => {
|
|
134
|
+
if (!page) return 0
|
|
135
|
+
return computeLiveSeoScore({
|
|
136
|
+
title: page.title,
|
|
137
|
+
slug: page.slug,
|
|
138
|
+
seoTitle: page.seoTitle,
|
|
139
|
+
seoDescription: page.seoDescription,
|
|
140
|
+
sections: page.sections,
|
|
141
|
+
})
|
|
142
|
+
}, [page])
|
|
129
143
|
const publishDisabledReason = publishCheck.errors[0]
|
|
130
144
|
|
|
131
145
|
// ─── Load ──────────────────────────────────────────────────────────
|
|
@@ -336,6 +350,7 @@ export function PageSectionEditor({
|
|
|
336
350
|
.then((p) => {
|
|
337
351
|
setPage(p)
|
|
338
352
|
setSelectedId(null)
|
|
353
|
+
setSeoOpen(false)
|
|
339
354
|
setDirty(false)
|
|
340
355
|
setSaveState('idle')
|
|
341
356
|
setValidation(null)
|
|
@@ -345,6 +360,27 @@ export function PageSectionEditor({
|
|
|
345
360
|
})
|
|
346
361
|
}, [page?.id])
|
|
347
362
|
|
|
363
|
+
const handleSeoSaved = useCallback(() => {
|
|
364
|
+
if (!page?.id) return
|
|
365
|
+
fetchPageForEditor(page.id)
|
|
366
|
+
.then((p) => setPage(p))
|
|
367
|
+
.catch(() => {
|
|
368
|
+
/* SEO API saved; editor meta will sync on next full load */
|
|
369
|
+
})
|
|
370
|
+
}, [page?.id])
|
|
371
|
+
|
|
372
|
+
const handleToggleSeo = useCallback(() => {
|
|
373
|
+
setSeoOpen((open) => {
|
|
374
|
+
if (!open) setSelectedId(null)
|
|
375
|
+
return !open
|
|
376
|
+
})
|
|
377
|
+
}, [])
|
|
378
|
+
|
|
379
|
+
const handleSelectSection = useCallback((id: string | null) => {
|
|
380
|
+
setSeoOpen(false)
|
|
381
|
+
setSelectedId(id)
|
|
382
|
+
}, [])
|
|
383
|
+
|
|
348
384
|
// ─── Navigation with unsaved guard ───────────────────────────────────
|
|
349
385
|
const guardedNavigate = useCallback(
|
|
350
386
|
(path: string) => {
|
|
@@ -492,6 +528,9 @@ export function PageSectionEditor({
|
|
|
492
528
|
onToggleSitePreview={page.id && page.slug ? sitePreview.toggle : undefined}
|
|
493
529
|
onHistory={page.id ? () => setHistoryOpen(true) : undefined}
|
|
494
530
|
onOpenSettings={() => setSettingsOpen(true)}
|
|
531
|
+
onOpenSeo={handleToggleSeo}
|
|
532
|
+
seoOpen={seoOpen}
|
|
533
|
+
seoScore={liveSeoScore}
|
|
495
534
|
onSaveDraft={handleSaveDraft}
|
|
496
535
|
onPublish={requestPublish}
|
|
497
536
|
structuralTypes={structuralTypes}
|
|
@@ -550,7 +589,7 @@ export function PageSectionEditor({
|
|
|
550
589
|
sections={page.sections}
|
|
551
590
|
selectedId={selectedId}
|
|
552
591
|
canEdit={effectiveCanEdit}
|
|
553
|
-
onSelect={
|
|
592
|
+
onSelect={handleSelectSection}
|
|
554
593
|
onReorder={handleReorder}
|
|
555
594
|
onToggleVisibility={handleToggleVisibility}
|
|
556
595
|
onDuplicate={handleDuplicate}
|
|
@@ -571,7 +610,7 @@ export function PageSectionEditor({
|
|
|
571
610
|
sections={page.sections}
|
|
572
611
|
selectedId={selectedId}
|
|
573
612
|
viewport={viewport}
|
|
574
|
-
onSelect={
|
|
613
|
+
onSelect={handleSelectSection}
|
|
575
614
|
onAddSection={() => setAddOpen(true)}
|
|
576
615
|
structuralTypes={structuralTypes}
|
|
577
616
|
sitePreviewAvailable={sitePreviewAvailable && !sitePreview.active}
|
|
@@ -579,7 +618,7 @@ export function PageSectionEditor({
|
|
|
579
618
|
)}
|
|
580
619
|
</div>
|
|
581
620
|
|
|
582
|
-
{selected && (
|
|
621
|
+
{selected && !seoOpen && (
|
|
583
622
|
<SectionInspector
|
|
584
623
|
section={selected}
|
|
585
624
|
canEdit={effectiveCanEdit}
|
|
@@ -590,6 +629,22 @@ export function PageSectionEditor({
|
|
|
590
629
|
onToggleVisibility={() => handleToggleVisibility(selected.id)}
|
|
591
630
|
/>
|
|
592
631
|
)}
|
|
632
|
+
|
|
633
|
+
{seoOpen && (
|
|
634
|
+
<EditorSeoAside
|
|
635
|
+
entityType="page"
|
|
636
|
+
entityId={page.id}
|
|
637
|
+
title={page.title}
|
|
638
|
+
slug={page.slug}
|
|
639
|
+
seoTitle={page.seoTitle}
|
|
640
|
+
seoDescription={page.seoDescription}
|
|
641
|
+
sections={page.sections}
|
|
642
|
+
canEdit={effectiveCanEdit}
|
|
643
|
+
onClose={() => setSeoOpen(false)}
|
|
644
|
+
onDraftChange={handlePageSettings}
|
|
645
|
+
onSaved={handleSeoSaved}
|
|
646
|
+
/>
|
|
647
|
+
)}
|
|
593
648
|
</div>
|
|
594
649
|
</div>
|
|
595
650
|
|
|
@@ -4,15 +4,9 @@ import { useEffect, useRef, useState } from 'react'
|
|
|
4
4
|
import { normalizeSlug, validateSlug } from '@actuate-media/cms-core/client'
|
|
5
5
|
import { Modal } from '../../components/ui/Modal.js'
|
|
6
6
|
import { Button } from '../../components/ui/Button.js'
|
|
7
|
-
import { SeoCharCount } from '../../components/seo/SeoCharCount.js'
|
|
8
7
|
import { cmsApi } from '../../lib/api.js'
|
|
9
8
|
import type { EditorPage } from '../../lib/page-editor-service.js'
|
|
10
9
|
|
|
11
|
-
const SEO_TITLE_MIN = 30
|
|
12
|
-
const SEO_TITLE_MAX = 60
|
|
13
|
-
const SEO_DESC_MIN = 120
|
|
14
|
-
const SEO_DESC_MAX = 160
|
|
15
|
-
|
|
16
10
|
interface PageSettingsModalProps {
|
|
17
11
|
open: boolean
|
|
18
12
|
page: EditorPage
|
|
@@ -32,7 +26,7 @@ interface ParentOption {
|
|
|
32
26
|
title: string
|
|
33
27
|
}
|
|
34
28
|
|
|
35
|
-
/** Edits page-level fields: title, slug, path, parent page
|
|
29
|
+
/** Edits page-level fields: title, slug, path, and parent page. SEO lives in the SEO tab. */
|
|
36
30
|
export function PageSettingsModal({
|
|
37
31
|
open,
|
|
38
32
|
page,
|
|
@@ -44,8 +38,6 @@ export function PageSettingsModal({
|
|
|
44
38
|
const [slug, setSlug] = useState(page.slug)
|
|
45
39
|
const [path, setPath] = useState(page.path)
|
|
46
40
|
const [parentPageId, setParentPageId] = useState(page.parentPageId ?? '')
|
|
47
|
-
const [seoTitle, setSeoTitle] = useState(page.seoTitle)
|
|
48
|
-
const [seoDescription, setSeoDescription] = useState(page.seoDescription)
|
|
49
41
|
// Auto-derive slug/path from title until the user edits slug manually.
|
|
50
42
|
const [slugTouched, setSlugTouched] = useState(Boolean(page.slug))
|
|
51
43
|
const [parentOptions, setParentOptions] = useState<ParentOption[] | null>(null)
|
|
@@ -59,8 +51,6 @@ export function PageSettingsModal({
|
|
|
59
51
|
setSlug(page.slug)
|
|
60
52
|
setPath(page.path)
|
|
61
53
|
setParentPageId(page.parentPageId ?? '')
|
|
62
|
-
setSeoTitle(page.seoTitle)
|
|
63
|
-
setSeoDescription(page.seoDescription)
|
|
64
54
|
setSlugTouched(Boolean(page.slug))
|
|
65
55
|
}, [open, page])
|
|
66
56
|
|
|
@@ -110,8 +100,6 @@ export function PageSettingsModal({
|
|
|
110
100
|
slug,
|
|
111
101
|
path: path || (slug ? `/${slug}` : '/'),
|
|
112
102
|
parentPageId: parentPageId || null,
|
|
113
|
-
seoTitle,
|
|
114
|
-
seoDescription,
|
|
115
103
|
})
|
|
116
104
|
onClose()
|
|
117
105
|
}
|
|
@@ -231,38 +219,6 @@ export function PageSettingsModal({
|
|
|
231
219
|
Nests this page under a parent in the Pages tree view.
|
|
232
220
|
</p>
|
|
233
221
|
</div>
|
|
234
|
-
<div>
|
|
235
|
-
<div className="mb-1 flex items-center justify-between">
|
|
236
|
-
<label className={`${LABEL} mb-0`} htmlFor="ps-seo-title">
|
|
237
|
-
SEO title
|
|
238
|
-
</label>
|
|
239
|
-
<SeoCharCount value={seoTitle} min={SEO_TITLE_MIN} max={SEO_TITLE_MAX} />
|
|
240
|
-
</div>
|
|
241
|
-
<input
|
|
242
|
-
id="ps-seo-title"
|
|
243
|
-
className={INPUT}
|
|
244
|
-
value={seoTitle}
|
|
245
|
-
disabled={!canEdit}
|
|
246
|
-
placeholder={title || 'Defaults to the page title'}
|
|
247
|
-
onChange={(e) => setSeoTitle(e.target.value)}
|
|
248
|
-
/>
|
|
249
|
-
</div>
|
|
250
|
-
<div>
|
|
251
|
-
<div className="mb-1 flex items-center justify-between">
|
|
252
|
-
<label className={`${LABEL} mb-0`} htmlFor="ps-seo-desc">
|
|
253
|
-
SEO description
|
|
254
|
-
</label>
|
|
255
|
-
<SeoCharCount value={seoDescription} min={SEO_DESC_MIN} max={SEO_DESC_MAX} />
|
|
256
|
-
</div>
|
|
257
|
-
<textarea
|
|
258
|
-
id="ps-seo-desc"
|
|
259
|
-
className={`${INPUT} min-h-[64px] resize-y`}
|
|
260
|
-
value={seoDescription}
|
|
261
|
-
disabled={!canEdit}
|
|
262
|
-
placeholder="Summarize the page for search results (120–160 characters)"
|
|
263
|
-
onChange={(e) => setSeoDescription(e.target.value)}
|
|
264
|
-
/>
|
|
265
|
-
</div>
|
|
266
222
|
</form>
|
|
267
223
|
</Modal>
|
|
268
224
|
)
|
|
@@ -109,7 +109,7 @@ export function SectionInspector({
|
|
|
109
109
|
aria-label={`${section.name} (read-only)`}
|
|
110
110
|
className={INSPECTOR_SHELL}
|
|
111
111
|
>
|
|
112
|
-
<div className="border-border flex items-center justify-between border-b px-
|
|
112
|
+
<div className="border-border flex items-center justify-between border-b px-6 py-4">
|
|
113
113
|
<div className="min-w-0">
|
|
114
114
|
<h2 className="text-foreground truncate text-sm font-medium">{section.name}</h2>
|
|
115
115
|
<p className="text-muted-foreground text-xs">Managed outside the editor</p>
|
|
@@ -142,7 +142,7 @@ export function SectionInspector({
|
|
|
142
142
|
|
|
143
143
|
return (
|
|
144
144
|
<ResizableAside role="dialog" aria-label={`Edit ${section.name}`} className={INSPECTOR_SHELL}>
|
|
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="min-w-0">
|
|
147
147
|
<h2 className="text-foreground truncate text-sm font-medium">{section.name}</h2>
|
|
148
148
|
<p className="text-muted-foreground text-xs">{def?.name ?? 'Section'}</p>
|
|
@@ -78,7 +78,7 @@ export function SectionsPanel({
|
|
|
78
78
|
className="bg-card border-border flex h-full w-72 shrink-0 flex-col border-r"
|
|
79
79
|
aria-label="Page sections"
|
|
80
80
|
>
|
|
81
|
-
<div className="border-border flex items-center justify-between border-b px-
|
|
81
|
+
<div className="border-border flex items-center justify-between border-b px-6 py-4">
|
|
82
82
|
<div className="flex items-center gap-2">
|
|
83
83
|
<h2 className="text-foreground text-sm font-medium">Sections</h2>
|
|
84
84
|
<span className="bg-muted text-muted-foreground rounded-full px-2 py-0.5 text-xs font-medium tabular-nums">
|
|
@@ -9,16 +9,9 @@ import { Button } from '../../components/ui/Button.js'
|
|
|
9
9
|
import { ConfirmDialog } from '../../components/ui/ConfirmDialog.js'
|
|
10
10
|
import { MediaPickerModal } from '../../components/MediaPickerModal.js'
|
|
11
11
|
import { TagInput } from '../../components/TagInput.js'
|
|
12
|
-
import { SeoCharCount } from '../../components/seo/SeoCharCount.js'
|
|
13
12
|
import { coauthorText } from '../../lib/coauthor-client.js'
|
|
14
|
-
import { generateSeoField } from '../../lib/seo-service.js'
|
|
15
13
|
import type { EditorPost } from '../../lib/post-editor-service.js'
|
|
16
14
|
|
|
17
|
-
const SEO_TITLE_MIN = 30
|
|
18
|
-
const SEO_TITLE_MAX = 60
|
|
19
|
-
const SEO_DESC_MIN = 120
|
|
20
|
-
const SEO_DESC_MAX = 160
|
|
21
|
-
|
|
22
15
|
/**
|
|
23
16
|
* The slice of a collection's field config the modal reads to pick controls:
|
|
24
17
|
* a `select` category renders a dropdown (structured vocabulary), an `array`
|
|
@@ -47,8 +40,6 @@ const INPUT =
|
|
|
47
40
|
|
|
48
41
|
const slugify = normalizeSlug
|
|
49
42
|
|
|
50
|
-
type AiTarget = 'excerpt' | 'seoTitle' | 'seoDescription'
|
|
51
|
-
|
|
52
43
|
type FormSnapshot = {
|
|
53
44
|
title: string
|
|
54
45
|
slug: string
|
|
@@ -56,8 +47,6 @@ type FormSnapshot = {
|
|
|
56
47
|
featuredImage: string
|
|
57
48
|
category: string
|
|
58
49
|
tags: string
|
|
59
|
-
seoTitle: string
|
|
60
|
-
seoDescription: string
|
|
61
50
|
}
|
|
62
51
|
|
|
63
52
|
function snapshotFromPost(post: EditorPost): FormSnapshot {
|
|
@@ -68,8 +57,6 @@ function snapshotFromPost(post: EditorPost): FormSnapshot {
|
|
|
68
57
|
featuredImage: post.featuredImage,
|
|
69
58
|
category: post.category,
|
|
70
59
|
tags: [...post.tags].sort().join('\0'),
|
|
71
|
-
seoTitle: post.seoTitle,
|
|
72
|
-
seoDescription: post.seoDescription,
|
|
73
60
|
}
|
|
74
61
|
}
|
|
75
62
|
|
|
@@ -80,8 +67,6 @@ function snapshotFromFields(input: {
|
|
|
80
67
|
featuredImage: string
|
|
81
68
|
category: string
|
|
82
69
|
tags: string[]
|
|
83
|
-
seoTitle: string
|
|
84
|
-
seoDescription: string
|
|
85
70
|
}): FormSnapshot {
|
|
86
71
|
return {
|
|
87
72
|
...input,
|
|
@@ -132,7 +117,7 @@ function AiFieldButton({
|
|
|
132
117
|
)
|
|
133
118
|
}
|
|
134
119
|
|
|
135
|
-
/** Edits post metadata: title, slug, excerpt, featured image, category, tags
|
|
120
|
+
/** Edits post metadata: title, slug, excerpt, featured image, category, and tags. SEO lives in the SEO tab. */
|
|
136
121
|
export function PostFieldsModal({
|
|
137
122
|
open,
|
|
138
123
|
post,
|
|
@@ -147,11 +132,9 @@ export function PostFieldsModal({
|
|
|
147
132
|
const [featuredImage, setFeaturedImage] = useState(post.featuredImage)
|
|
148
133
|
const [category, setCategory] = useState(post.category)
|
|
149
134
|
const [tags, setTags] = useState<string[]>(post.tags)
|
|
150
|
-
const [seoTitle, setSeoTitle] = useState(post.seoTitle)
|
|
151
|
-
const [seoDescription, setSeoDescription] = useState(post.seoDescription)
|
|
152
135
|
const [slugTouched, setSlugTouched] = useState(Boolean(post.slug))
|
|
153
136
|
const [mediaOpen, setMediaOpen] = useState(false)
|
|
154
|
-
const [aiBusy, setAiBusy] = useState
|
|
137
|
+
const [aiBusy, setAiBusy] = useState(false)
|
|
155
138
|
const [confirmDiscard, setConfirmDiscard] = useState(false)
|
|
156
139
|
const baselineRef = useRef<FormSnapshot | null>(null)
|
|
157
140
|
// Land the user in the Title field (name-it-first flow for new posts).
|
|
@@ -177,10 +160,8 @@ export function PostFieldsModal({
|
|
|
177
160
|
setFeaturedImage(post.featuredImage)
|
|
178
161
|
setCategory(post.category)
|
|
179
162
|
setTags(post.tags)
|
|
180
|
-
setSeoTitle(post.seoTitle)
|
|
181
|
-
setSeoDescription(post.seoDescription)
|
|
182
163
|
setSlugTouched(Boolean(post.slug))
|
|
183
|
-
setAiBusy(
|
|
164
|
+
setAiBusy(false)
|
|
184
165
|
baselineRef.current = snapshotFromPost(post)
|
|
185
166
|
}, [open, post])
|
|
186
167
|
|
|
@@ -193,11 +174,9 @@ export function PostFieldsModal({
|
|
|
193
174
|
featuredImage,
|
|
194
175
|
category,
|
|
195
176
|
tags,
|
|
196
|
-
seoTitle,
|
|
197
|
-
seoDescription,
|
|
198
177
|
})
|
|
199
178
|
return JSON.stringify(current) !== JSON.stringify(baselineRef.current)
|
|
200
|
-
}, [open, title, slug, excerpt, featuredImage, category, tags
|
|
179
|
+
}, [open, title, slug, excerpt, featuredImage, category, tags])
|
|
201
180
|
|
|
202
181
|
function requestDismiss() {
|
|
203
182
|
if (isDirty) {
|
|
@@ -214,7 +193,7 @@ export function PostFieldsModal({
|
|
|
214
193
|
|
|
215
194
|
const slugError = validateSlug(slug)
|
|
216
195
|
|
|
217
|
-
async function
|
|
196
|
+
async function handleAiGenerateExcerpt() {
|
|
218
197
|
const context = buildContentContext(title, post.body, excerpt)
|
|
219
198
|
if (!context.trim()) {
|
|
220
199
|
toast.error(
|
|
@@ -223,77 +202,29 @@ export function PostFieldsModal({
|
|
|
223
202
|
return
|
|
224
203
|
}
|
|
225
204
|
|
|
226
|
-
setAiBusy(
|
|
205
|
+
setAiBusy(true)
|
|
227
206
|
try {
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
toast.error(outcome.error)
|
|
236
|
-
return
|
|
237
|
-
}
|
|
238
|
-
setExcerpt(outcome.result.text.trim())
|
|
239
|
-
toast.success('Excerpt generated — review before saving.')
|
|
207
|
+
const outcome = await coauthorText('compress', context, {
|
|
208
|
+
instructions:
|
|
209
|
+
'Write a 1–2 sentence excerpt for blog listings and cards. Return only the excerpt text.',
|
|
210
|
+
targetLength: 45,
|
|
211
|
+
})
|
|
212
|
+
if (!outcome.ok) {
|
|
213
|
+
toast.error(outcome.error)
|
|
240
214
|
return
|
|
241
215
|
}
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
const field = target === 'seoTitle' ? 'metaTitle' : 'metaDescription'
|
|
245
|
-
const result = await generateSeoField(field, {
|
|
246
|
-
entityType: post.postType,
|
|
247
|
-
entityId: post.id,
|
|
248
|
-
})
|
|
249
|
-
if (result.unavailable) {
|
|
250
|
-
toast.error('AI is not configured. Add an AI provider to enable suggestions.')
|
|
251
|
-
return
|
|
252
|
-
}
|
|
253
|
-
if (result.text) {
|
|
254
|
-
if (target === 'seoTitle') setSeoTitle(result.text)
|
|
255
|
-
else setSeoDescription(result.text)
|
|
256
|
-
toast.success('AI suggestion applied — review before saving.')
|
|
257
|
-
} else {
|
|
258
|
-
toast.error('No suggestion was returned.')
|
|
259
|
-
}
|
|
260
|
-
return
|
|
261
|
-
}
|
|
262
|
-
|
|
263
|
-
if (target === 'seoTitle') {
|
|
264
|
-
const outcome = await coauthorText('rewrite', context, {
|
|
265
|
-
style: 'seo-optimized',
|
|
266
|
-
instructions:
|
|
267
|
-
'Write a single SEO page title under 60 characters. Return only the title — no quotes.',
|
|
268
|
-
})
|
|
269
|
-
if (!outcome.ok) {
|
|
270
|
-
toast.error(outcome.error)
|
|
271
|
-
return
|
|
272
|
-
}
|
|
273
|
-
setSeoTitle(outcome.result.text.trim().slice(0, SEO_TITLE_MAX))
|
|
274
|
-
} else {
|
|
275
|
-
const outcome = await coauthorText('compress', context, {
|
|
276
|
-
instructions:
|
|
277
|
-
'Write a compelling SEO meta description of 120–160 characters for search results. Return only the description.',
|
|
278
|
-
targetLength: 155,
|
|
279
|
-
})
|
|
280
|
-
if (!outcome.ok) {
|
|
281
|
-
toast.error(outcome.error)
|
|
282
|
-
return
|
|
283
|
-
}
|
|
284
|
-
setSeoDescription(outcome.result.text.trim().slice(0, SEO_DESC_MAX))
|
|
285
|
-
}
|
|
286
|
-
toast.success('AI suggestion applied — review before saving.')
|
|
216
|
+
setExcerpt(outcome.result.text.trim())
|
|
217
|
+
toast.success('Excerpt generated — review before saving.')
|
|
287
218
|
} catch {
|
|
288
219
|
toast.error('AI request failed.')
|
|
289
220
|
} finally {
|
|
290
|
-
setAiBusy(
|
|
221
|
+
setAiBusy(false)
|
|
291
222
|
}
|
|
292
223
|
}
|
|
293
224
|
|
|
294
225
|
function handleSave() {
|
|
295
226
|
if (slugError) return
|
|
296
|
-
onSave({ title, slug, excerpt, featuredImage, category, tags
|
|
227
|
+
onSave({ title, slug, excerpt, featuredImage, category, tags })
|
|
297
228
|
onClose()
|
|
298
229
|
}
|
|
299
230
|
|
|
@@ -421,9 +352,9 @@ export function PostFieldsModal({
|
|
|
421
352
|
</label>
|
|
422
353
|
<AiFieldButton
|
|
423
354
|
label="Generate excerpt from content"
|
|
424
|
-
busy={aiBusy
|
|
355
|
+
busy={aiBusy}
|
|
425
356
|
disabled={!canEdit}
|
|
426
|
-
onClick={() => void
|
|
357
|
+
onClick={() => void handleAiGenerateExcerpt()}
|
|
427
358
|
/>
|
|
428
359
|
</div>
|
|
429
360
|
<textarea
|
|
@@ -464,56 +395,6 @@ export function PostFieldsModal({
|
|
|
464
395
|
</div>
|
|
465
396
|
)}
|
|
466
397
|
</div>
|
|
467
|
-
|
|
468
|
-
<div>
|
|
469
|
-
<div className="mb-1 flex items-center justify-between gap-2">
|
|
470
|
-
<div className="flex min-w-0 items-center gap-2">
|
|
471
|
-
<label className={`${LABEL} mb-0`} htmlFor="pf-seo-title">
|
|
472
|
-
SEO title
|
|
473
|
-
</label>
|
|
474
|
-
<AiFieldButton
|
|
475
|
-
label="Generate SEO title from content"
|
|
476
|
-
busy={aiBusy === 'seoTitle'}
|
|
477
|
-
disabled={!canEdit}
|
|
478
|
-
onClick={() => void handleAiGenerate('seoTitle')}
|
|
479
|
-
/>
|
|
480
|
-
</div>
|
|
481
|
-
<SeoCharCount value={seoTitle} min={SEO_TITLE_MIN} max={SEO_TITLE_MAX} />
|
|
482
|
-
</div>
|
|
483
|
-
<input
|
|
484
|
-
id="pf-seo-title"
|
|
485
|
-
className={INPUT}
|
|
486
|
-
value={seoTitle}
|
|
487
|
-
disabled={!canEdit}
|
|
488
|
-
placeholder={title || 'Defaults to the post title'}
|
|
489
|
-
onChange={(e) => setSeoTitle(e.target.value)}
|
|
490
|
-
/>
|
|
491
|
-
</div>
|
|
492
|
-
|
|
493
|
-
<div>
|
|
494
|
-
<div className="mb-1 flex items-center justify-between gap-2">
|
|
495
|
-
<div className="flex min-w-0 items-center gap-2">
|
|
496
|
-
<label className={`${LABEL} mb-0`} htmlFor="pf-seo-desc">
|
|
497
|
-
SEO description
|
|
498
|
-
</label>
|
|
499
|
-
<AiFieldButton
|
|
500
|
-
label="Generate SEO description from content"
|
|
501
|
-
busy={aiBusy === 'seoDescription'}
|
|
502
|
-
disabled={!canEdit}
|
|
503
|
-
onClick={() => void handleAiGenerate('seoDescription')}
|
|
504
|
-
/>
|
|
505
|
-
</div>
|
|
506
|
-
<SeoCharCount value={seoDescription} min={SEO_DESC_MIN} max={SEO_DESC_MAX} />
|
|
507
|
-
</div>
|
|
508
|
-
<textarea
|
|
509
|
-
id="pf-seo-desc"
|
|
510
|
-
className={`${INPUT} min-h-[72px] resize-y`}
|
|
511
|
-
value={seoDescription}
|
|
512
|
-
disabled={!canEdit}
|
|
513
|
-
placeholder={excerpt || 'Summarize the post for search results (120–160 characters)'}
|
|
514
|
-
onChange={(e) => setSeoDescription(e.target.value)}
|
|
515
|
-
/>
|
|
516
|
-
</div>
|
|
517
398
|
</div>
|
|
518
399
|
</Modal>
|
|
519
400
|
|
|
@@ -33,7 +33,7 @@ export function PostHeaderPanel({ header, canEdit, onClose, onChange }: PostHead
|
|
|
33
33
|
aria-label="Edit post header"
|
|
34
34
|
className="bg-card border-border flex h-full flex-col border-l"
|
|
35
35
|
>
|
|
36
|
-
<div className="border-border flex items-center justify-between border-b px-
|
|
36
|
+
<div className="border-border flex items-center justify-between border-b px-6 py-4">
|
|
37
37
|
<div className="min-w-0">
|
|
38
38
|
<h2 className="text-foreground truncate text-sm font-medium">Post header</h2>
|
|
39
39
|
<p className="text-muted-foreground text-xs">Layout for every post of this type</p>
|