@actuate-media/cms-admin 0.49.3 → 0.50.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 +61 -0
- package/dist/AdminRoot.js +2 -2
- package/dist/AdminRoot.js.map +1 -1
- package/dist/__tests__/components/seo-char-count.test.d.ts +2 -0
- package/dist/__tests__/components/seo-char-count.test.d.ts.map +1 -0
- package/dist/__tests__/components/seo-char-count.test.js +25 -0
- package/dist/__tests__/components/seo-char-count.test.js.map +1 -0
- package/dist/__tests__/lib/page-editor-service.test.js +12 -0
- package/dist/__tests__/lib/page-editor-service.test.js.map +1 -1
- package/dist/__tests__/lib/post-editor-service.test.js +4 -0
- package/dist/__tests__/lib/post-editor-service.test.js.map +1 -1
- package/dist/__tests__/views/seo-settings.render.test.js +1 -1
- package/dist/__tests__/views/seo-settings.render.test.js.map +1 -1
- package/dist/actuate-admin.css +1 -1
- package/dist/components/CoauthorResultPopover.js +1 -1
- package/dist/components/CoauthorResultPopover.js.map +1 -1
- package/dist/components/CommentSidePanel.js +1 -1
- package/dist/components/CommentSidePanel.js.map +1 -1
- package/dist/components/ContentOverviewChart.js +1 -1
- package/dist/components/ContentOverviewChart.js.map +1 -1
- package/dist/components/MediaPickerModal.d.ts.map +1 -1
- package/dist/components/MediaPickerModal.js +3 -1
- package/dist/components/MediaPickerModal.js.map +1 -1
- package/dist/components/NotificationBell.js +1 -1
- package/dist/components/NotificationBell.js.map +1 -1
- package/dist/components/PresenceChips.js +1 -1
- package/dist/components/PresenceChips.js.map +1 -1
- package/dist/components/SEOConfigPanel.js +2 -2
- package/dist/components/SEOConfigPanel.js.map +1 -1
- package/dist/components/SEOPanel.d.ts +1 -0
- package/dist/components/SEOPanel.d.ts.map +1 -1
- package/dist/components/SEOPanel.js +3 -3
- package/dist/components/SEOPanel.js.map +1 -1
- package/dist/components/SEOPerformance.js +2 -2
- package/dist/components/SEOPerformance.js.map +1 -1
- package/dist/components/forms/EntriesTable.js +1 -1
- package/dist/components/forms/EntriesTable.js.map +1 -1
- package/dist/components/seo/SeoCharCount.d.ts +15 -0
- package/dist/components/seo/SeoCharCount.d.ts.map +1 -0
- package/dist/components/seo/SeoCharCount.js +25 -0
- package/dist/components/seo/SeoCharCount.js.map +1 -0
- package/dist/components/seo/SeoEditorDrawer.d.ts.map +1 -1
- package/dist/components/seo/SeoEditorDrawer.js +3 -1
- package/dist/components/seo/SeoEditorDrawer.js.map +1 -1
- package/dist/components/ui/Card.js +1 -1
- package/dist/components/ui/Card.js.map +1 -1
- package/dist/components/ui/EmptyState.js +1 -1
- package/dist/components/ui/EmptyState.js.map +1 -1
- package/dist/fields/SlugField.d.ts.map +1 -1
- package/dist/fields/SlugField.js +6 -2
- package/dist/fields/SlugField.js.map +1 -1
- package/dist/layout/Sidebar.js +1 -1
- package/dist/layout/Sidebar.js.map +1 -1
- package/dist/layout/primitives/PageContainer.js +1 -1
- package/dist/layout/primitives/PageContainer.js.map +1 -1
- package/dist/lib/page-editor-service.d.ts.map +1 -1
- package/dist/lib/page-editor-service.js +6 -0
- package/dist/lib/page-editor-service.js.map +1 -1
- package/dist/lib/post-editor-service.d.ts.map +1 -1
- package/dist/lib/post-editor-service.js +6 -0
- package/dist/lib/post-editor-service.js.map +1 -1
- package/dist/lib/seo-service.d.ts +3 -1
- package/dist/lib/seo-service.d.ts.map +1 -1
- package/dist/lib/seo-service.js.map +1 -1
- package/dist/views/CollectionList.js +1 -1
- package/dist/views/CollectionList.js.map +1 -1
- package/dist/views/Dashboard.js +8 -8
- package/dist/views/Dashboard.js.map +1 -1
- package/dist/views/DocumentEdit.d.ts.map +1 -1
- package/dist/views/DocumentEdit.js +2 -1
- package/dist/views/DocumentEdit.js.map +1 -1
- package/dist/views/ForgotPassword.js +1 -1
- package/dist/views/ForgotPassword.js.map +1 -1
- package/dist/views/FormEditor.d.ts.map +1 -1
- package/dist/views/FormEditor.js +1 -1
- package/dist/views/FormEditor.js.map +1 -1
- package/dist/views/Login.js +1 -1
- package/dist/views/Login.js.map +1 -1
- package/dist/views/MediaBrowser.d.ts.map +1 -1
- package/dist/views/MediaBrowser.js +3 -3
- package/dist/views/MediaBrowser.js.map +1 -1
- package/dist/views/PageEditor.js +1 -1
- package/dist/views/PageEditor.js.map +1 -1
- package/dist/views/Pages/PagesListView.js +3 -3
- package/dist/views/Pages/PagesListView.js.map +1 -1
- package/dist/views/Pages.js +1 -1
- package/dist/views/Pages.js.map +1 -1
- package/dist/views/Posts/PostsListView.js +1 -1
- package/dist/views/Posts/PostsListView.js.map +1 -1
- package/dist/views/Redirects.js +2 -2
- package/dist/views/Redirects.js.map +1 -1
- package/dist/views/ResetPassword.js +2 -2
- package/dist/views/ResetPassword.js.map +1 -1
- package/dist/views/ScriptTagEditor.js +1 -1
- package/dist/views/ScriptTagEditor.js.map +1 -1
- package/dist/views/ScriptTags.js +1 -1
- package/dist/views/ScriptTags.js.map +1 -1
- package/dist/views/SetupWizard.js +2 -2
- package/dist/views/SetupWizard.js.map +1 -1
- package/dist/views/page-editor/PageSettingsModal.d.ts.map +1 -1
- package/dist/views/page-editor/PageSettingsModal.js +12 -9
- package/dist/views/page-editor/PageSettingsModal.js.map +1 -1
- package/dist/views/page-editor/SectionInspector.js +2 -2
- package/dist/views/page-editor/SectionInspector.js.map +1 -1
- package/dist/views/post-editor/PostFieldsModal.d.ts.map +1 -1
- package/dist/views/post-editor/PostFieldsModal.js +12 -9
- package/dist/views/post-editor/PostFieldsModal.js.map +1 -1
- package/dist/views/post-editor/PostHeader.d.ts.map +1 -1
- package/dist/views/post-editor/PostHeader.js +2 -2
- package/dist/views/post-editor/PostHeader.js.map +1 -1
- package/dist/views/post-editor/PostHeaderPanel.js +1 -1
- package/dist/views/post-editor/PostHeaderPanel.js.map +1 -1
- package/package.json +2 -2
- package/src/AdminRoot.tsx +2 -2
- package/src/__tests__/components/seo-char-count.test.tsx +27 -0
- package/src/__tests__/lib/page-editor-service.test.ts +14 -0
- package/src/__tests__/lib/post-editor-service.test.ts +9 -0
- package/src/__tests__/views/seo-settings.render.test.tsx +1 -1
- package/src/components/CoauthorResultPopover.tsx +1 -1
- package/src/components/CommentSidePanel.tsx +1 -1
- package/src/components/ContentOverviewChart.tsx +1 -1
- package/src/components/MediaPickerModal.tsx +5 -1
- package/src/components/NotificationBell.tsx +2 -2
- package/src/components/PresenceChips.tsx +1 -1
- package/src/components/SEOConfigPanel.tsx +3 -3
- package/src/components/SEOPanel.tsx +17 -6
- package/src/components/SEOPerformance.tsx +5 -5
- package/src/components/forms/EntriesTable.tsx +1 -1
- package/src/components/seo/SeoCharCount.tsx +44 -0
- package/src/components/seo/SeoEditorDrawer.tsx +27 -1
- package/src/components/ui/Card.tsx +1 -1
- package/src/components/ui/EmptyState.tsx +1 -1
- package/src/fields/SlugField.tsx +6 -2
- package/src/layout/Sidebar.tsx +1 -1
- package/src/layout/primitives/PageContainer.tsx +1 -1
- package/src/lib/page-editor-service.ts +6 -0
- package/src/lib/post-editor-service.ts +6 -0
- package/src/lib/seo-service.ts +9 -1
- package/src/views/CollectionList.tsx +1 -1
- package/src/views/Dashboard.tsx +12 -12
- package/src/views/DocumentEdit.tsx +5 -4
- package/src/views/ForgotPassword.tsx +1 -1
- package/src/views/FormEditor.tsx +1 -3
- package/src/views/Login.tsx +2 -2
- package/src/views/MediaBrowser.tsx +7 -9
- package/src/views/PageEditor.tsx +2 -2
- package/src/views/Pages/PagesListView.tsx +3 -3
- package/src/views/Pages.tsx +1 -1
- package/src/views/Posts/PostsListView.tsx +1 -1
- package/src/views/Redirects.tsx +2 -2
- package/src/views/ResetPassword.tsx +2 -2
- package/src/views/ScriptTagEditor.tsx +2 -2
- package/src/views/ScriptTags.tsx +2 -2
- package/src/views/SetupWizard.tsx +2 -2
- package/src/views/page-editor/PageSettingsModal.tsx +33 -14
- package/src/views/page-editor/SectionInspector.tsx +4 -4
- package/src/views/post-editor/PostFieldsModal.tsx +33 -14
- package/src/views/post-editor/PostHeader.tsx +2 -4
- package/src/views/post-editor/PostHeaderPanel.tsx +2 -2
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"PostHeader.js","sourceRoot":"","sources":["../../../src/views/post-editor/PostHeader.tsx"],"names":[],"mappings":";AAAA,OAAO,EAAE,kBAAkB,EAAE,MAAM,uCAAuC,CAAA;AAS1E,SAAS,UAAU,CAAC,KAAqB;IACvC,IAAI,CAAC,KAAK;QAAE,OAAO,EAAE,CAAA;IACrB,MAAM,CAAC,GAAG,IAAI,IAAI,CAAC,KAAK,CAAC,CAAA;IACzB,IAAI,MAAM,CAAC,KAAK,CAAC,CAAC,CAAC,OAAO,EAAE,CAAC;QAAE,OAAO,EAAE,CAAA;IACxC,OAAO,CAAC,CAAC,kBAAkB,CAAC,SAAS,EAAE,EAAE,IAAI,EAAE,SAAS,EAAE,KAAK,EAAE,MAAM,EAAE,GAAG,EAAE,SAAS,EAAE,CAAC,CAAA;AAC5F,CAAC;AAED;;;;;GAKG;AACH,MAAM,UAAU,UAAU,CAAC,EAAE,MAAM,EAAE,OAAO,EAAmB;IAC7D,MAAM,KAAK,GAAG,OAAO,CAAC,KAAK,IAAI,eAAe,CAAA;IAC9C,MAAM,IAAI,GAAG,MAAM,CAAC,QAAQ,CAAC,CAAC,CAAC,UAAU,CAAC,OAAO,CAAC,WAAW,CAAC,CAAC,CAAC,CAAC,EAAE,CAAA;IACnE,MAAM,OAAO,GACX,MAAM,CAAC,MAAM,KAAK,SAAS,IAAI,MAAM,CAAC,iBAAiB,IAAI,OAAO,CAAC,gBAAgB,CAAA;IACrF,MAAM,KAAK,GAAG,MAAM,CAAC,MAAM,KAAK,UAAU,CAAC,CAAC,CAAC,kCAAkC,CAAC,CAAC,CAAC,WAAW,CAAA;IAE7F,MAAM,IAAI,GAAG,CACX,eACE,SAAS,EAAE,+CACT,MAAM,CAAC,MAAM,KAAK,UAAU,CAAC,CAAC,CAAC,gBAAgB,CAAC,CAAC,CAAC,EACpD,IAAI,OAAO,CAAC,CAAC,CAAC,eAAe,CAAC,CAAC,CAAC,eAAe,EAAE,aAEhD,MAAM,CAAC,YAAY,IAAI,OAAO,CAAC,QAAQ,IAAI,CAC1C,eAAM,SAAS,EAAC,6BAA6B,YAAE,OAAO,CAAC,QAAQ,GAAQ,CACxE,EACA,MAAM,CAAC,UAAU,IAAI,yBAAO,OAAO,CAAC,MAAM,EAAE,IAAI,IAAI,aAAa,GAAQ,EACzE,IAAI,IAAI,yBAAO,IAAI,GAAQ,EAC3B,MAAM,CAAC,eAAe,IAAI,2BAAO,kBAAkB,CAAC,OAAO,CAAC,IAAI,CAAC,iBAAiB,IAC/E,CACP,CAAA;IAED,IAAI,OAAO,EAAE,CAAC;QACZ,OAAO,CACL,kBAAQ,SAAS,EAAC,UAAU,aAC1B,cAAK,GAAG,EAAE,OAAO,CAAC,gBAAgB,EAAE,GAAG,EAAC,EAAE,EAAC,SAAS,EAAC,+BAA+B,GAAG,EACvF,cAAK,SAAS,EAAC,2DAA2D,wBAAe,EACzF,cAAK,SAAS,EAAC,uCAAuC,YACpD,eAAK,SAAS,EAAE,mCAAmC,KAAK,EAAE,aACxD,aAAI,SAAS,EAAC,
|
|
1
|
+
{"version":3,"file":"PostHeader.js","sourceRoot":"","sources":["../../../src/views/post-editor/PostHeader.tsx"],"names":[],"mappings":"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"}
|
|
@@ -13,7 +13,7 @@ const TOGGLES = [
|
|
|
13
13
|
];
|
|
14
14
|
/** Inspector panel for the post header layout (template-level design config). */
|
|
15
15
|
export function PostHeaderPanel({ header, canEdit, onClose, onChange }) {
|
|
16
|
-
return (_jsxs("aside", { role: "dialog", "aria-label": "Edit post header", className: "bg-card border-border flex h-full w-80 shrink-0 flex-col border-l", children: [_jsxs("div", { className: "border-border flex items-center justify-between border-b px-4 py-3", children: [_jsxs("div", { className: "min-w-0", children: [_jsx("h2", { className: "text-foreground truncate text-sm font-medium", children: "Post header" }), _jsx("p", { className: "text-muted-foreground text-xs", children: "Layout for every post of this type" })] }), _jsx("button", { type: "button", onClick: onClose, "aria-label": "Close header panel", className: "text-muted-foreground hover:text-foreground hover:bg-accent rounded p-1", children: _jsx(X, { className: "h-4 w-4", "aria-hidden": true }) })] }), _jsxs("div", { className: "flex-1 space-y-6 overflow-y-auto p-4", children: [_jsxs("fieldset", { disabled: !canEdit, className: "space-y-3", children: [_jsx("legend", { className: "text-muted-foreground mb-2 text-[10px] font-
|
|
16
|
+
return (_jsxs("aside", { role: "dialog", "aria-label": "Edit post header", className: "bg-card border-border flex h-full w-80 shrink-0 flex-col border-l", children: [_jsxs("div", { className: "border-border flex items-center justify-between border-b px-4 py-3", children: [_jsxs("div", { className: "min-w-0", children: [_jsx("h2", { className: "text-foreground truncate text-sm font-medium", children: "Post header" }), _jsx("p", { className: "text-muted-foreground text-xs", children: "Layout for every post of this type" })] }), _jsx("button", { type: "button", onClick: onClose, "aria-label": "Close header panel", className: "text-muted-foreground hover:text-foreground hover:bg-accent rounded p-1", children: _jsx(X, { className: "h-4 w-4", "aria-hidden": true }) })] }), _jsxs("div", { className: "flex-1 space-y-6 overflow-y-auto p-4", children: [_jsxs("fieldset", { disabled: !canEdit, className: "space-y-3", children: [_jsx("legend", { className: "text-muted-foreground mb-2 text-[10px] font-medium tracking-wider uppercase", children: "Layout" }), _jsxs("div", { children: [_jsx("label", { className: LABEL, htmlFor: "ph-layout", children: "Header style" }), _jsxs("select", { id: "ph-layout", className: INPUT, value: header.layout, onChange: (e) => onChange({ layout: e.target.value }), children: [_jsx("option", { value: "centered", children: "Centered" }), _jsx("option", { value: "left", children: "Left-aligned" }), _jsx("option", { value: "overlay", children: "Image overlay" })] })] })] }), _jsxs("fieldset", { disabled: !canEdit, className: "space-y-2", children: [_jsx("legend", { className: "text-muted-foreground mb-2 text-[10px] font-medium tracking-wider uppercase", children: "Show in header" }), TOGGLES.map((t) => {
|
|
17
17
|
const value = header[t.key] === true;
|
|
18
18
|
return (_jsxs("button", { type: "button", role: "switch", "aria-checked": value, disabled: !canEdit, onClick: () => onChange({ [t.key]: !value }), className: "border-border text-foreground hover:bg-accent flex w-full items-center justify-between rounded-md border px-2.5 py-1.5 text-sm disabled:opacity-50", children: [_jsx("span", { children: t.label }), _jsx("span", { "aria-hidden": true, className: `relative inline-flex h-5 w-9 shrink-0 items-center rounded-full transition-colors ${value ? 'bg-primary' : 'bg-muted'}`, children: _jsx("span", { className: `inline-block h-4 w-4 transform rounded-full bg-white transition-transform ${value ? 'translate-x-4' : 'translate-x-0.5'}` }) })] }, t.key));
|
|
19
19
|
})] })] })] }));
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"PostHeaderPanel.js","sourceRoot":"","sources":["../../../src/views/post-editor/PostHeaderPanel.tsx"],"names":[],"mappings":"AAAA,YAAY,CAAA;;AAEZ,OAAO,EAAE,CAAC,EAAE,MAAM,cAAc,CAAA;AAUhC,MAAM,KAAK,GAAG,gDAAgD,CAAA;AAC9D,MAAM,KAAK,GACT,yKAAyK,CAAA;AAE3K,MAAM,OAAO,GAA0D;IACrE,EAAE,GAAG,EAAE,mBAAmB,EAAE,KAAK,EAAE,gBAAgB,EAAE;IACrD,EAAE,GAAG,EAAE,aAAa,EAAE,KAAK,EAAE,SAAS,EAAE;IACxC,EAAE,GAAG,EAAE,YAAY,EAAE,KAAK,EAAE,QAAQ,EAAE;IACtC,EAAE,GAAG,EAAE,UAAU,EAAE,KAAK,EAAE,MAAM,EAAE;IAClC,EAAE,GAAG,EAAE,cAAc,EAAE,KAAK,EAAE,UAAU,EAAE;IAC1C,EAAE,GAAG,EAAE,iBAAiB,EAAE,KAAK,EAAE,cAAc,EAAE;CAClD,CAAA;AAED,iFAAiF;AACjF,MAAM,UAAU,eAAe,CAAC,EAAE,MAAM,EAAE,OAAO,EAAE,OAAO,EAAE,QAAQ,EAAwB;IAC1F,OAAO,CACL,iBACE,IAAI,EAAC,QAAQ,gBACF,kBAAkB,EAC7B,SAAS,EAAC,mEAAmE,aAE7E,eAAK,SAAS,EAAC,oEAAoE,aACjF,eAAK,SAAS,EAAC,SAAS,aACtB,aAAI,SAAS,EAAC,8CAA8C,4BAAiB,EAC7E,YAAG,SAAS,EAAC,+BAA+B,mDAAuC,IAC/E,EACN,iBACE,IAAI,EAAC,QAAQ,EACb,OAAO,EAAE,OAAO,gBACL,oBAAoB,EAC/B,SAAS,EAAC,yEAAyE,YAEnF,KAAC,CAAC,IAAC,SAAS,EAAC,SAAS,wBAAe,GAC9B,IACL,EAEN,eAAK,SAAS,EAAC,sCAAsC,aACnD,oBAAU,QAAQ,EAAE,CAAC,OAAO,EAAE,SAAS,EAAC,WAAW,aACjD,iBAAQ,SAAS,EAAC
|
|
1
|
+
{"version":3,"file":"PostHeaderPanel.js","sourceRoot":"","sources":["../../../src/views/post-editor/PostHeaderPanel.tsx"],"names":[],"mappings":"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"}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@actuate-media/cms-admin",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.50.0",
|
|
4
4
|
"repository": {
|
|
5
5
|
"type": "git",
|
|
6
6
|
"url": "https://github.com/actuate-media/actuatecms.git",
|
|
@@ -89,7 +89,7 @@
|
|
|
89
89
|
"tailwindcss": "^4.0.0",
|
|
90
90
|
"typescript": "^5.7.0",
|
|
91
91
|
"vitest": "^4.1.0",
|
|
92
|
-
"@actuate-media/cms-core": "0.
|
|
92
|
+
"@actuate-media/cms-core": "0.67.0",
|
|
93
93
|
"@actuate-media/component-blocks": "0.2.1"
|
|
94
94
|
},
|
|
95
95
|
"scripts": {
|
package/src/AdminRoot.tsx
CHANGED
|
@@ -310,7 +310,7 @@ function AdminShell({
|
|
|
310
310
|
return (
|
|
311
311
|
<div className="bg-background flex min-h-screen items-center justify-center">
|
|
312
312
|
<div className="text-center">
|
|
313
|
-
<h1 className="text-foreground mb-2 text-xl font-
|
|
313
|
+
<h1 className="text-foreground mb-2 text-xl font-medium">Unauthorized</h1>
|
|
314
314
|
<p className="text-muted-foreground text-sm">Please log in to access the admin panel.</p>
|
|
315
315
|
</div>
|
|
316
316
|
</div>
|
|
@@ -662,7 +662,7 @@ function AdminShell({
|
|
|
662
662
|
|
|
663
663
|
return (
|
|
664
664
|
<div className="flex min-h-[400px] flex-col items-center justify-center p-6 text-center">
|
|
665
|
-
<h1 className="text-foreground mb-2 text-4xl font-
|
|
665
|
+
<h1 className="text-foreground mb-2 text-4xl font-medium">404</h1>
|
|
666
666
|
<p className="text-muted-foreground mb-4">The page you are looking for does not exist.</p>
|
|
667
667
|
<button
|
|
668
668
|
onClick={() => navigate('/')}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
// @vitest-environment happy-dom
|
|
2
|
+
import { describe, expect, it } from 'vitest'
|
|
3
|
+
import { render, screen } from '@testing-library/react'
|
|
4
|
+
import { SeoCharCount } from '../../components/seo/SeoCharCount.js'
|
|
5
|
+
|
|
6
|
+
describe('SeoCharCount', () => {
|
|
7
|
+
it('shows a neutral empty state', () => {
|
|
8
|
+
render(<SeoCharCount value="" min={30} max={60} />)
|
|
9
|
+
expect(screen.getByText('0/60')).toBeTruthy()
|
|
10
|
+
})
|
|
11
|
+
|
|
12
|
+
it('marks a value within the ideal range as good', () => {
|
|
13
|
+
render(<SeoCharCount value={'x'.repeat(40)} min={30} max={60} />)
|
|
14
|
+
expect(screen.getByText(/40\/60 · good/)).toBeTruthy()
|
|
15
|
+
})
|
|
16
|
+
|
|
17
|
+
it('flags a value over the max as too long (destructive)', () => {
|
|
18
|
+
const { container } = render(<SeoCharCount value={'x'.repeat(70)} min={30} max={60} />)
|
|
19
|
+
expect(screen.getByText(/70\/60 · too long/)).toBeTruthy()
|
|
20
|
+
expect(container.querySelector('.text-destructive')).toBeTruthy()
|
|
21
|
+
})
|
|
22
|
+
|
|
23
|
+
it('hints to aim for the range when below min', () => {
|
|
24
|
+
render(<SeoCharCount value={'x'.repeat(10)} min={30} max={60} />)
|
|
25
|
+
expect(screen.getByText(/aim for 30–60/)).toBeTruthy()
|
|
26
|
+
})
|
|
27
|
+
})
|
|
@@ -146,6 +146,20 @@ describe('validatePage', () => {
|
|
|
146
146
|
const { errors } = validatePage(basePage([]), false)
|
|
147
147
|
expect(errors.some((e) => e.includes('visible section'))).toBe(false)
|
|
148
148
|
})
|
|
149
|
+
|
|
150
|
+
it('rejects an invalid slug (mirrors the server publish gate)', () => {
|
|
151
|
+
const page = basePage([])
|
|
152
|
+
page.slug = 'About Us!'
|
|
153
|
+
const { errors } = validatePage(page, false)
|
|
154
|
+
expect(errors.some((e) => /slug/i.test(e))).toBe(true)
|
|
155
|
+
})
|
|
156
|
+
|
|
157
|
+
it('rejects a reserved slug', () => {
|
|
158
|
+
const page = basePage([])
|
|
159
|
+
page.slug = 'admin'
|
|
160
|
+
const { errors } = validatePage(page, false)
|
|
161
|
+
expect(errors.some((e) => /reserved/i.test(e))).toBe(true)
|
|
162
|
+
})
|
|
149
163
|
})
|
|
150
164
|
|
|
151
165
|
describe('section type registry', () => {
|
|
@@ -52,6 +52,15 @@ describe('validatePost', () => {
|
|
|
52
52
|
expect(r.errors).toContain('Post slug is required.')
|
|
53
53
|
})
|
|
54
54
|
|
|
55
|
+
it('rejects an invalid or reserved slug (mirrors the server publish gate)', () => {
|
|
56
|
+
expect(
|
|
57
|
+
validatePost(basePost({ slug: 'Hello World' }), false).errors.some((e) => /slug/i.test(e)),
|
|
58
|
+
).toBe(true)
|
|
59
|
+
expect(
|
|
60
|
+
validatePost(basePost({ slug: 'api' }), false).errors.some((e) => /reserved/i.test(e)),
|
|
61
|
+
).toBe(true)
|
|
62
|
+
})
|
|
63
|
+
|
|
55
64
|
it('does not run section validation for draft saves', () => {
|
|
56
65
|
const r = validatePost(
|
|
57
66
|
basePost({
|
|
@@ -39,7 +39,7 @@ const cmsApi = vi.fn(async (endpoint: string, options?: { method?: string }) =>
|
|
|
39
39
|
if (endpoint === '/seo/sitemap/status' && method === 'GET') {
|
|
40
40
|
return {
|
|
41
41
|
data: {
|
|
42
|
-
sitemapUrl: 'https://acme.test/
|
|
42
|
+
sitemapUrl: 'https://acme.test/sitemap.xml',
|
|
43
43
|
lastGeneratedAt: '2026-01-02T03:04:05.000Z',
|
|
44
44
|
pagesIncluded: 12,
|
|
45
45
|
pagesExcluded: 1,
|
|
@@ -84,7 +84,7 @@ export function CoauthorResultPopover({
|
|
|
84
84
|
className="w-full max-w-2xl rounded-lg bg-white shadow-2xl outline-none"
|
|
85
85
|
>
|
|
86
86
|
<div className="flex items-center justify-between border-b border-gray-200 px-4 py-3">
|
|
87
|
-
<div className="flex items-center gap-2 text-sm font-
|
|
87
|
+
<div className="flex items-center gap-2 text-sm font-medium text-gray-900">
|
|
88
88
|
<Sparkles className="h-4 w-4 text-blue-600" aria-hidden />
|
|
89
89
|
<span>AI {coauthorActionLabel(action)}</span>
|
|
90
90
|
{loading && (
|
|
@@ -233,7 +233,7 @@ export function CommentSidePanel({
|
|
|
233
233
|
return (
|
|
234
234
|
<aside className={wrapperClass} data-testid="comment-side-panel">
|
|
235
235
|
<header className="flex items-center justify-between">
|
|
236
|
-
<h2 className="text-sm font-
|
|
236
|
+
<h2 className="text-sm font-medium text-gray-900">Comments</h2>
|
|
237
237
|
<label className="flex cursor-pointer items-center gap-1 text-xs text-gray-600">
|
|
238
238
|
<input
|
|
239
239
|
type="checkbox"
|
|
@@ -60,7 +60,7 @@ export function ContentOverviewChart({ published, drafts, scheduled }: ContentOv
|
|
|
60
60
|
<div className="min-w-0">
|
|
61
61
|
<span className="text-sm text-gray-700">{entry.name}</span>
|
|
62
62
|
<div className="flex items-baseline gap-2">
|
|
63
|
-
<span className="text-lg font-
|
|
63
|
+
<span className="text-lg font-medium text-gray-900">
|
|
64
64
|
{entry.value.toLocaleString()}
|
|
65
65
|
</span>
|
|
66
66
|
<span className="text-xs text-gray-400">{pct}%</span>
|
|
@@ -14,6 +14,8 @@ interface MediaItem {
|
|
|
14
14
|
storageKey: string
|
|
15
15
|
/** Normalized public URL from the media list endpoint (preferred for display). */
|
|
16
16
|
url?: string
|
|
17
|
+
/** Curated alt text from the media library (normalized as `altTag`). */
|
|
18
|
+
altTag?: string
|
|
17
19
|
mimeType: string
|
|
18
20
|
fileSize: number
|
|
19
21
|
width?: number
|
|
@@ -118,7 +120,9 @@ export function MediaPickerModal({
|
|
|
118
120
|
width: item.width,
|
|
119
121
|
height: item.height,
|
|
120
122
|
})
|
|
121
|
-
|
|
123
|
+
// Prefer the asset's curated alt text from the library so inserted images
|
|
124
|
+
// carry meaningful alt instead of a filename. Callers can still override.
|
|
125
|
+
onSelect(url, item.altTag?.trim() || item.filename)
|
|
122
126
|
onClose()
|
|
123
127
|
}
|
|
124
128
|
|
|
@@ -256,7 +256,7 @@ export function NotificationBell({
|
|
|
256
256
|
<BellIcon />
|
|
257
257
|
{unread > 0 && (
|
|
258
258
|
<span
|
|
259
|
-
className="absolute -top-0.5 -right-0.5 inline-flex h-4 min-w-4 items-center justify-center rounded-full bg-red-600 px-1 text-[10px] font-
|
|
259
|
+
className="absolute -top-0.5 -right-0.5 inline-flex h-4 min-w-4 items-center justify-center rounded-full bg-red-600 px-1 text-[10px] font-medium text-white"
|
|
260
260
|
data-testid="bell-badge"
|
|
261
261
|
>
|
|
262
262
|
{unread > 99 ? '99+' : unread}
|
|
@@ -270,7 +270,7 @@ export function NotificationBell({
|
|
|
270
270
|
data-testid="bell-dropdown"
|
|
271
271
|
>
|
|
272
272
|
<header className="flex items-center justify-between border-b border-gray-100 px-3 py-2">
|
|
273
|
-
<span className="text-sm font-
|
|
273
|
+
<span className="text-sm font-medium text-gray-900">Notifications</span>
|
|
274
274
|
<button
|
|
275
275
|
type="button"
|
|
276
276
|
className="text-xs text-gray-600 hover:text-gray-900 disabled:opacity-50"
|
|
@@ -125,7 +125,7 @@ function Chip({ name, color, avatarUrl }: { name: string; color: string; avatarU
|
|
|
125
125
|
) : (
|
|
126
126
|
<span
|
|
127
127
|
aria-hidden="true"
|
|
128
|
-
className="flex h-4 w-4 items-center justify-center rounded-full text-[8px] font-
|
|
128
|
+
className="flex h-4 w-4 items-center justify-center rounded-full text-[8px] font-medium text-white"
|
|
129
129
|
style={{ backgroundColor: color }}
|
|
130
130
|
>
|
|
131
131
|
{initials}
|
|
@@ -242,7 +242,7 @@ export function SEOConfigPanel() {
|
|
|
242
242
|
<section className="rounded-lg border border-gray-200 bg-white">
|
|
243
243
|
<header className="flex items-center gap-2 border-b border-gray-200 px-4 py-3">
|
|
244
244
|
<Globe className="h-4 w-4 text-blue-600" />
|
|
245
|
-
<h3 className="text-sm font-
|
|
245
|
+
<h3 className="text-sm font-medium text-gray-900">Site-wide defaults</h3>
|
|
246
246
|
</header>
|
|
247
247
|
<div className="grid grid-cols-1 gap-4 p-4 md:grid-cols-2">
|
|
248
248
|
<Field
|
|
@@ -333,7 +333,7 @@ export function SEOConfigPanel() {
|
|
|
333
333
|
<section className="rounded-lg border border-gray-200 bg-white">
|
|
334
334
|
<header className="flex items-center gap-2 border-b border-gray-200 px-4 py-3">
|
|
335
335
|
<FileText className="h-4 w-4 text-blue-600" />
|
|
336
|
-
<h3 className="text-sm font-
|
|
336
|
+
<h3 className="text-sm font-medium text-gray-900">Per-collection defaults</h3>
|
|
337
337
|
<span className="text-xs text-gray-500">
|
|
338
338
|
— shown placeholders are the static defaults
|
|
339
339
|
</span>
|
|
@@ -346,7 +346,7 @@ export function SEOConfigPanel() {
|
|
|
346
346
|
<div key={col.slug} className="p-4">
|
|
347
347
|
<div className="mb-3 flex items-baseline justify-between gap-4">
|
|
348
348
|
<div>
|
|
349
|
-
<h4 className="text-sm font-
|
|
349
|
+
<h4 className="text-sm font-medium text-gray-900">{col.label}</h4>
|
|
350
350
|
<p className="text-xs text-gray-500">
|
|
351
351
|
<code className="rounded bg-gray-100 px-1 py-0.5">{col.slug}</code>
|
|
352
352
|
{col.urlPrefix !== undefined && (
|
|
@@ -26,6 +26,7 @@ export interface SEOData {
|
|
|
26
26
|
metaTitle?: string
|
|
27
27
|
metaDescription?: string
|
|
28
28
|
focusKeyphrase?: string
|
|
29
|
+
keyTakeaway?: string
|
|
29
30
|
canonical?: string
|
|
30
31
|
robotsPolicy?:
|
|
31
32
|
| 'inherit'
|
|
@@ -414,7 +415,7 @@ function ScoreRing({ score }: { score: number }) {
|
|
|
414
415
|
className="transition-all duration-500"
|
|
415
416
|
/>
|
|
416
417
|
</svg>
|
|
417
|
-
<span className="absolute text-sm font-
|
|
418
|
+
<span className="absolute text-sm font-medium" style={{ color }}>
|
|
418
419
|
{score}
|
|
419
420
|
</span>
|
|
420
421
|
</div>
|
|
@@ -624,7 +625,7 @@ export function SEOPanel({
|
|
|
624
625
|
<div className="rounded-lg border border-[var(--border)] bg-[var(--card)]">
|
|
625
626
|
{/* Header */}
|
|
626
627
|
<div className="flex items-center justify-between border-b border-[var(--border)] px-4 py-3">
|
|
627
|
-
<h3 className="flex items-center gap-2 text-sm font-
|
|
628
|
+
<h3 className="flex items-center gap-2 text-sm font-medium text-[var(--foreground)]">
|
|
628
629
|
<Search className="h-4 w-4" />
|
|
629
630
|
SEO
|
|
630
631
|
</h3>
|
|
@@ -692,24 +693,26 @@ export function SEOPanel({
|
|
|
692
693
|
<div className="grid grid-cols-2 gap-3">
|
|
693
694
|
<div className="rounded-lg bg-[var(--muted)] p-2.5">
|
|
694
695
|
<p className="text-xs text-[var(--muted-foreground)]">Flesch Score</p>
|
|
695
|
-
<p className={`text-lg font-
|
|
696
|
+
<p className={`text-lg font-medium ${fleschColor}`}>{readability.fleschScore}</p>
|
|
696
697
|
<p className={`text-[10px] ${fleschColor}`}>{fleschLabel}</p>
|
|
697
698
|
</div>
|
|
698
699
|
<div className="rounded-lg bg-[var(--muted)] p-2.5">
|
|
699
700
|
<p className="text-xs text-[var(--muted-foreground)]">Word Count</p>
|
|
700
|
-
<p className="text-lg font-
|
|
701
|
+
<p className="text-lg font-medium text-[var(--foreground)]">
|
|
702
|
+
{readability.wordCount}
|
|
703
|
+
</p>
|
|
701
704
|
<p className="text-[10px] text-[var(--muted-foreground)]">words</p>
|
|
702
705
|
</div>
|
|
703
706
|
<div className="rounded-lg bg-[var(--muted)] p-2.5">
|
|
704
707
|
<p className="text-xs text-[var(--muted-foreground)]">Avg. Sentence</p>
|
|
705
|
-
<p className="text-lg font-
|
|
708
|
+
<p className="text-lg font-medium text-[var(--foreground)]">
|
|
706
709
|
{readability.avgSentenceLength.toFixed(1)}
|
|
707
710
|
</p>
|
|
708
711
|
<p className="text-[10px] text-[var(--muted-foreground)]">words/sentence</p>
|
|
709
712
|
</div>
|
|
710
713
|
<div className="rounded-lg bg-[var(--muted)] p-2.5">
|
|
711
714
|
<p className="text-xs text-[var(--muted-foreground)]">Reading Time</p>
|
|
712
|
-
<p className="text-lg font-
|
|
715
|
+
<p className="text-lg font-medium text-[var(--foreground)]">
|
|
713
716
|
{readability.readingTime}
|
|
714
717
|
</p>
|
|
715
718
|
<p className="text-[10px] text-[var(--muted-foreground)]">min</p>
|
|
@@ -760,6 +763,14 @@ export function SEOPanel({
|
|
|
760
763
|
onChange={(v) => update({ focusKeyphrase: v })}
|
|
761
764
|
placeholder="Primary keyword or phrase"
|
|
762
765
|
/>
|
|
766
|
+
<TextareaField
|
|
767
|
+
label="Key Takeaway"
|
|
768
|
+
value={seoData.keyTakeaway ?? ''}
|
|
769
|
+
onChange={(v) => update({ keyTakeaway: v })}
|
|
770
|
+
placeholder="One or two sentences AI answer engines can quote"
|
|
771
|
+
charCount={(seoData.keyTakeaway ?? '').length}
|
|
772
|
+
charTarget="(ideal: under 240)"
|
|
773
|
+
/>
|
|
763
774
|
<InputField
|
|
764
775
|
label="Canonical URL"
|
|
765
776
|
value={seoData.canonical ?? ''}
|
|
@@ -27,7 +27,7 @@ function ScoreBadge({ score }: { score: number }) {
|
|
|
27
27
|
const bg = score >= 80 ? 'bg-green-50' : score >= 60 ? 'bg-amber-50' : 'bg-red-50'
|
|
28
28
|
return (
|
|
29
29
|
<span
|
|
30
|
-
className={`inline-flex h-9 w-9 items-center justify-center rounded-full text-sm font-
|
|
30
|
+
className={`inline-flex h-9 w-9 items-center justify-center rounded-full text-sm font-medium ${color} ${bg}`}
|
|
31
31
|
>
|
|
32
32
|
{score}
|
|
33
33
|
</span>
|
|
@@ -57,7 +57,7 @@ export function SEOPerformance({ onNavigate }: SEOPerformanceProps) {
|
|
|
57
57
|
<div className="flex items-center justify-between border-b border-gray-200 p-4">
|
|
58
58
|
<div className="flex items-center gap-2">
|
|
59
59
|
<Search className="h-4 w-4 text-gray-500" />
|
|
60
|
-
<h2 className="text-sm font-
|
|
60
|
+
<h2 className="text-sm font-medium text-gray-900">SEO Performance</h2>
|
|
61
61
|
</div>
|
|
62
62
|
{totalIssues > 0 && (
|
|
63
63
|
<span className="text-xs text-gray-500">
|
|
@@ -120,7 +120,7 @@ export function SEOPerformance({ onNavigate }: SEOPerformanceProps) {
|
|
|
120
120
|
<FileWarning className="h-4 w-4 text-red-400" />
|
|
121
121
|
<span className="text-sm text-gray-700">Missing meta descriptions</span>
|
|
122
122
|
</div>
|
|
123
|
-
<span className="text-sm font-
|
|
123
|
+
<span className="text-sm font-medium text-gray-900">
|
|
124
124
|
{issues.missingMetaDescriptions}
|
|
125
125
|
</span>
|
|
126
126
|
</div>
|
|
@@ -129,7 +129,7 @@ export function SEOPerformance({ onNavigate }: SEOPerformanceProps) {
|
|
|
129
129
|
<LinkIcon className="h-4 w-4 text-red-400" />
|
|
130
130
|
<span className="text-sm text-gray-700">Broken internal links</span>
|
|
131
131
|
</div>
|
|
132
|
-
<span className="text-sm font-
|
|
132
|
+
<span className="text-sm font-medium text-gray-900">
|
|
133
133
|
{issues.brokenInternalLinks}
|
|
134
134
|
</span>
|
|
135
135
|
</div>
|
|
@@ -138,7 +138,7 @@ export function SEOPerformance({ onNavigate }: SEOPerformanceProps) {
|
|
|
138
138
|
<ImageOff className="h-4 w-4 text-red-400" />
|
|
139
139
|
<span className="text-sm text-gray-700">Missing alt text</span>
|
|
140
140
|
</div>
|
|
141
|
-
<span className="text-sm font-
|
|
141
|
+
<span className="text-sm font-medium text-gray-900">{issues.missingAltText}</span>
|
|
142
142
|
</div>
|
|
143
143
|
</div>
|
|
144
144
|
{totalIssues > 0 && (
|
|
@@ -130,7 +130,7 @@ export function EntriesTable({
|
|
|
130
130
|
/>
|
|
131
131
|
)}
|
|
132
132
|
<span
|
|
133
|
-
className={`text-foreground truncate ${entry.unread ? 'font-
|
|
133
|
+
className={`text-foreground truncate ${entry.unread ? 'font-medium' : 'font-medium'}`}
|
|
134
134
|
>
|
|
135
135
|
{name}
|
|
136
136
|
</span>
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import { Check, AlertTriangle } from 'lucide-react'
|
|
4
|
+
|
|
5
|
+
export interface SeoCharCountProps {
|
|
6
|
+
value: string
|
|
7
|
+
/** Recommended minimum length (inclusive). */
|
|
8
|
+
min: number
|
|
9
|
+
/** Recommended/hard maximum length (inclusive). */
|
|
10
|
+
max: number
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* A length counter with an accessible quality band for SEO fields. Color is
|
|
15
|
+
* always paired with an icon + label so meaning is never conveyed by color
|
|
16
|
+
* alone (WCAG). "Good" = within [min, max]; "long" (over max) is the only
|
|
17
|
+
* destructive state; below min is a neutral hint.
|
|
18
|
+
*/
|
|
19
|
+
export function SeoCharCount({ value, min, max }: SeoCharCountProps) {
|
|
20
|
+
const len = value.trim().length
|
|
21
|
+
const empty = len === 0
|
|
22
|
+
const long = len > max
|
|
23
|
+
const good = !empty && len >= min && len <= max
|
|
24
|
+
|
|
25
|
+
const tone = long ? 'text-destructive' : 'text-muted-foreground'
|
|
26
|
+
const label = empty
|
|
27
|
+
? `0/${max}`
|
|
28
|
+
: long
|
|
29
|
+
? `${len}/${max} · too long`
|
|
30
|
+
: good
|
|
31
|
+
? `${len}/${max} · good`
|
|
32
|
+
: `${len}/${max} · aim for ${min}–${max}`
|
|
33
|
+
|
|
34
|
+
return (
|
|
35
|
+
<span className={`inline-flex items-center gap-1 text-xs ${tone}`}>
|
|
36
|
+
{long ? (
|
|
37
|
+
<AlertTriangle size={12} aria-hidden="true" />
|
|
38
|
+
) : good ? (
|
|
39
|
+
<Check size={12} aria-hidden="true" />
|
|
40
|
+
) : null}
|
|
41
|
+
{label}
|
|
42
|
+
</span>
|
|
43
|
+
)
|
|
44
|
+
}
|
|
@@ -16,7 +16,15 @@ import { SeoLoading, SeoErrorState, btnPrimary, btnSecondary, gradeMeta } from '
|
|
|
16
16
|
const META_TITLE_MAX = 60
|
|
17
17
|
const META_DESC_MAX = 160
|
|
18
18
|
|
|
19
|
-
type AiField =
|
|
19
|
+
type AiField =
|
|
20
|
+
| 'metaTitle'
|
|
21
|
+
| 'metaDescription'
|
|
22
|
+
| 'ogTitle'
|
|
23
|
+
| 'ogDescription'
|
|
24
|
+
| 'focusKeyword'
|
|
25
|
+
| 'keyTakeaway'
|
|
26
|
+
|
|
27
|
+
const KEY_TAKEAWAY_MAX = 240
|
|
20
28
|
|
|
21
29
|
function CharCount({ value, max }: { value: string; max: number }) {
|
|
22
30
|
const over = value.length > max
|
|
@@ -100,6 +108,7 @@ export function SeoEditorDrawer({
|
|
|
100
108
|
metaDescription: rec.metaDescription ?? '',
|
|
101
109
|
canonicalUrl: rec.canonicalUrl ?? '',
|
|
102
110
|
focusKeyword: rec.focusKeyword ?? '',
|
|
111
|
+
keyTakeaway: rec.keyTakeaway ?? '',
|
|
103
112
|
ogTitle: rec.ogTitle ?? '',
|
|
104
113
|
ogDescription: rec.ogDescription ?? '',
|
|
105
114
|
noindex: rec.noindex,
|
|
@@ -248,6 +257,23 @@ export function SeoEditorDrawer({
|
|
|
248
257
|
/>
|
|
249
258
|
</Field>
|
|
250
259
|
|
|
260
|
+
<Field
|
|
261
|
+
label="Key takeaway"
|
|
262
|
+
hint={
|
|
263
|
+
<span className="flex items-center gap-2">
|
|
264
|
+
<CharCount value={form.keyTakeaway ?? ''} max={KEY_TAKEAWAY_MAX} />
|
|
265
|
+
{aiButton('keyTakeaway')}
|
|
266
|
+
</span>
|
|
267
|
+
}
|
|
268
|
+
>
|
|
269
|
+
<textarea
|
|
270
|
+
className={`${inputCls} min-h-16 resize-y`}
|
|
271
|
+
value={form.keyTakeaway ?? ''}
|
|
272
|
+
onChange={(e) => update({ keyTakeaway: e.target.value })}
|
|
273
|
+
placeholder="One or two sentences AI answer engines can quote — what is this page about?"
|
|
274
|
+
/>
|
|
275
|
+
</Field>
|
|
276
|
+
|
|
251
277
|
<Field label="Canonical URL">
|
|
252
278
|
<input
|
|
253
279
|
className={inputCls}
|
|
@@ -62,7 +62,7 @@ export function CardTitle({
|
|
|
62
62
|
}: HTMLAttributes<HTMLHeadingElement>) {
|
|
63
63
|
return (
|
|
64
64
|
<h3
|
|
65
|
-
className={`text-lg leading-tight font-
|
|
65
|
+
className={`text-lg leading-tight font-medium tracking-tight text-[var(--card-foreground)] ${className}`}
|
|
66
66
|
{...rest}
|
|
67
67
|
>
|
|
68
68
|
{children}
|
|
@@ -13,7 +13,7 @@ export function EmptyState({ icon, title, description, actionLabel, onAction }:
|
|
|
13
13
|
return (
|
|
14
14
|
<div className="flex flex-col items-center justify-center py-16 text-center">
|
|
15
15
|
{icon && <div className="mb-4 text-[var(--muted-foreground)]">{icon}</div>}
|
|
16
|
-
<h3 className="text-lg font-
|
|
16
|
+
<h3 className="text-lg font-medium">{title}</h3>
|
|
17
17
|
{description && (
|
|
18
18
|
<p className="mt-1 max-w-sm text-sm text-[var(--muted-foreground)]">{description}</p>
|
|
19
19
|
)}
|
package/src/fields/SlugField.tsx
CHANGED
|
@@ -11,11 +11,15 @@ export interface SlugFieldProps {
|
|
|
11
11
|
helpText?: string
|
|
12
12
|
}
|
|
13
13
|
|
|
14
|
+
// Mirrors `normalizeSlug` in @actuate-media/cms-core so the live-edited value
|
|
15
|
+
// matches what the server will accept (lowercase alphanumerics + single
|
|
16
|
+
// hyphens; underscores and other punctuation are collapsed to hyphens).
|
|
14
17
|
function toSlug(text: string): string {
|
|
15
18
|
return text
|
|
16
19
|
.toLowerCase()
|
|
17
|
-
.
|
|
18
|
-
.replace(/[\
|
|
20
|
+
.normalize('NFKD')
|
|
21
|
+
.replace(/[\u0300-\u036f]/g, '')
|
|
22
|
+
.replace(/[^a-z0-9]+/g, '-')
|
|
19
23
|
.replace(/^-+|-+$/g, '')
|
|
20
24
|
}
|
|
21
25
|
|
package/src/layout/Sidebar.tsx
CHANGED
|
@@ -390,7 +390,7 @@ function renderNavTree(items: NavItem[], ctx: NavRenderContext): ReactNode {
|
|
|
390
390
|
</div>
|
|
391
391
|
) : (
|
|
392
392
|
<div key={`g-${item.group}`} className="px-3 pt-4 pb-1">
|
|
393
|
-
<span className="text-sidebar-foreground/55 text-[10px] font-
|
|
393
|
+
<span className="text-sidebar-foreground/55 text-[10px] font-medium tracking-wider uppercase">
|
|
394
394
|
{item.group}
|
|
395
395
|
</span>
|
|
396
396
|
</div>
|
|
@@ -79,7 +79,7 @@ export function PageContainer({
|
|
|
79
79
|
{(title || description) && (
|
|
80
80
|
<div className="min-w-0">
|
|
81
81
|
{title && (
|
|
82
|
-
<h1 className="text-foreground truncate text-2xl font-
|
|
82
|
+
<h1 className="text-foreground truncate text-2xl font-medium tracking-tight">
|
|
83
83
|
{title}
|
|
84
84
|
</h1>
|
|
85
85
|
)}
|
|
@@ -33,6 +33,7 @@ import {
|
|
|
33
33
|
validateSectionContent,
|
|
34
34
|
type PageSection,
|
|
35
35
|
} from '@actuate-media/cms-core/page-sections'
|
|
36
|
+
import { validateSlug } from '@actuate-media/cms-core/client'
|
|
36
37
|
|
|
37
38
|
import { missingCollectionFields } from './collection-schema.js'
|
|
38
39
|
|
|
@@ -108,6 +109,11 @@ export function validatePage(page: EditorPage, forPublish = false): ValidationRe
|
|
|
108
109
|
if (isBlank(page.title)) errors.push('Page title is required.')
|
|
109
110
|
if (isBlank(page.path)) errors.push('Page path is required.')
|
|
110
111
|
|
|
112
|
+
// Mirror the server publish gate so an invalid/reserved slug surfaces in the
|
|
113
|
+
// validation summary rather than only as a toast after the publish attempt.
|
|
114
|
+
const slugError = validateSlug(page.slug)
|
|
115
|
+
if (slugError) errors.push(slugError)
|
|
116
|
+
|
|
111
117
|
if (forPublish) {
|
|
112
118
|
const visible = page.sections.filter((s) => s.visible)
|
|
113
119
|
if (visible.length === 0) {
|
|
@@ -41,6 +41,7 @@ import {
|
|
|
41
41
|
type PageSection,
|
|
42
42
|
type PostHeaderConfig,
|
|
43
43
|
} from '@actuate-media/cms-core/page-sections'
|
|
44
|
+
import { validateSlug } from '@actuate-media/cms-core/client'
|
|
44
45
|
|
|
45
46
|
export type { PageSection, PostHeaderConfig } from '@actuate-media/cms-core/page-sections'
|
|
46
47
|
|
|
@@ -139,6 +140,11 @@ export function validatePost(post: EditorPost, forPublish = false): ValidationRe
|
|
|
139
140
|
if (isBlank(post.title)) errors.push('Post title is required.')
|
|
140
141
|
if (isBlank(post.slug)) errors.push('Post slug is required.')
|
|
141
142
|
|
|
143
|
+
// Mirror the server publish gate so an invalid/reserved slug surfaces in the
|
|
144
|
+
// validation summary rather than only as a toast after the publish attempt.
|
|
145
|
+
const slugError = validateSlug(post.slug)
|
|
146
|
+
if (slugError) errors.push(slugError)
|
|
147
|
+
|
|
142
148
|
if (forPublish) {
|
|
143
149
|
const visible = post.sections.filter((s) => s.visible)
|
|
144
150
|
// Unmanaged (externally-managed) sections have no registered field schema,
|
package/src/lib/seo-service.ts
CHANGED
|
@@ -150,6 +150,7 @@ export interface SeoContentRecord {
|
|
|
150
150
|
metaTitle: string | null
|
|
151
151
|
metaDescription: string | null
|
|
152
152
|
focusKeyword: string | null
|
|
153
|
+
keyTakeaway: string | null
|
|
153
154
|
ogTitle: string | null
|
|
154
155
|
ogDescription: string | null
|
|
155
156
|
ogImageId: string | null
|
|
@@ -190,6 +191,7 @@ export interface SeoFieldPayload {
|
|
|
190
191
|
metaDescription?: string
|
|
191
192
|
canonicalUrl?: string
|
|
192
193
|
focusKeyword?: string
|
|
194
|
+
keyTakeaway?: string
|
|
193
195
|
ogTitle?: string
|
|
194
196
|
ogDescription?: string
|
|
195
197
|
ogImage?: string
|
|
@@ -620,7 +622,13 @@ function isAiUnavailable(status?: number): boolean {
|
|
|
620
622
|
}
|
|
621
623
|
|
|
622
624
|
export async function generateSeoField(
|
|
623
|
-
field:
|
|
625
|
+
field:
|
|
626
|
+
| 'metaTitle'
|
|
627
|
+
| 'metaDescription'
|
|
628
|
+
| 'ogTitle'
|
|
629
|
+
| 'ogDescription'
|
|
630
|
+
| 'focusKeyword'
|
|
631
|
+
| 'keyTakeaway',
|
|
624
632
|
context: { entityType: string; entityId: string },
|
|
625
633
|
): Promise<SeoAiResult> {
|
|
626
634
|
const res = await cmsApi<SeoAiResult>('/ai/seo/generate-field', {
|
|
@@ -284,7 +284,7 @@ export function CollectionList({ collectionSlug, config, onNavigate }: Collectio
|
|
|
284
284
|
<div className="mb-4 flex flex-wrap items-center justify-between gap-3">
|
|
285
285
|
<div>
|
|
286
286
|
<h1
|
|
287
|
-
className="text-xl font-
|
|
287
|
+
className="text-xl font-medium sm:text-2xl"
|
|
288
288
|
style={{ color: 'var(--actuate-text, #111827)' }}
|
|
289
289
|
>
|
|
290
290
|
{labels.plural}
|