@actuate-media/cms-admin 0.7.1 → 0.7.3
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/dist/actuate-admin.css +1 -1
- package/dist/components/SEOPanel.d.ts +1 -0
- package/dist/components/SEOPanel.d.ts.map +1 -1
- package/dist/components/SEOPanel.js +44 -2
- package/dist/components/SEOPanel.js.map +1 -1
- package/dist/lib/search.d.ts +2 -2
- package/dist/lib/search.d.ts.map +1 -1
- package/dist/lib/search.js +5 -2
- package/dist/lib/search.js.map +1 -1
- package/dist/views/DocumentEdit.js +1 -1
- package/dist/views/DocumentEdit.js.map +1 -1
- package/dist/views/Pages.d.ts.map +1 -1
- package/dist/views/Pages.js +7 -4
- package/dist/views/Pages.js.map +1 -1
- package/dist/views/ScriptTags.d.ts.map +1 -1
- package/dist/views/ScriptTags.js +8 -6
- package/dist/views/ScriptTags.js.map +1 -1
- package/dist/views/Settings.d.ts.map +1 -1
- package/dist/views/Settings.js +9 -2
- package/dist/views/Settings.js.map +1 -1
- package/package.json +3 -2
- package/src/components/SEOPanel.tsx +83 -12
- package/src/lib/search.ts +8 -4
- package/src/views/DocumentEdit.tsx +1 -1
- package/src/views/Pages.tsx +8 -4
- package/src/views/ScriptTags.tsx +8 -6
- package/src/views/Settings.tsx +39 -4
|
@@ -1,10 +1,12 @@
|
|
|
1
1
|
'use client';
|
|
2
2
|
|
|
3
|
+
import * as Select from '@radix-ui/react-select';
|
|
3
4
|
import { useState, useMemo } from 'react';
|
|
4
5
|
import {
|
|
5
6
|
Search,
|
|
6
7
|
RefreshCw,
|
|
7
8
|
EyeOff,
|
|
9
|
+
Check,
|
|
8
10
|
ChevronDown,
|
|
9
11
|
ChevronUp,
|
|
10
12
|
Globe,
|
|
@@ -24,6 +26,7 @@ export interface SEOData {
|
|
|
24
26
|
metaDescription?: string;
|
|
25
27
|
focusKeyphrase?: string;
|
|
26
28
|
canonical?: string;
|
|
29
|
+
robotsPolicy?: 'inherit' | 'index-follow' | 'noindex-follow' | 'index-nofollow' | 'noindex-nofollow';
|
|
27
30
|
noIndex?: boolean;
|
|
28
31
|
noFollow?: boolean;
|
|
29
32
|
ogTitle?: string;
|
|
@@ -45,6 +48,42 @@ export interface SEOPanelProps {
|
|
|
45
48
|
siteUrl?: string;
|
|
46
49
|
}
|
|
47
50
|
|
|
51
|
+
const ROBOTS_POLICY_OPTIONS: Array<{ value: NonNullable<SEOData['robotsPolicy']>; label: string }> = [
|
|
52
|
+
{ value: 'inherit', label: 'Inherit site default' },
|
|
53
|
+
{ value: 'index-follow', label: 'Force index, follow' },
|
|
54
|
+
{ value: 'noindex-follow', label: 'Force noindex, follow' },
|
|
55
|
+
{ value: 'index-nofollow', label: 'Force index, nofollow' },
|
|
56
|
+
{ value: 'noindex-nofollow', label: 'Force noindex, nofollow' },
|
|
57
|
+
];
|
|
58
|
+
|
|
59
|
+
function getRobotsPolicy(seoData: SEOData): NonNullable<SEOData['robotsPolicy']> {
|
|
60
|
+
if (seoData.robotsPolicy) return seoData.robotsPolicy;
|
|
61
|
+
if (typeof seoData.noIndex === 'boolean' || typeof seoData.noFollow === 'boolean') {
|
|
62
|
+
const noIndex = seoData.noIndex === true;
|
|
63
|
+
const noFollow = seoData.noFollow === true;
|
|
64
|
+
if (noIndex && noFollow) return 'noindex-nofollow';
|
|
65
|
+
if (noIndex) return 'noindex-follow';
|
|
66
|
+
if (noFollow) return 'index-nofollow';
|
|
67
|
+
return 'index-follow';
|
|
68
|
+
}
|
|
69
|
+
return 'inherit';
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function robotsPolicyToBooleans(policy: NonNullable<SEOData['robotsPolicy']>): Pick<SEOData, 'noIndex' | 'noFollow'> {
|
|
73
|
+
switch (policy) {
|
|
74
|
+
case 'index-follow':
|
|
75
|
+
return { noIndex: false, noFollow: false };
|
|
76
|
+
case 'noindex-follow':
|
|
77
|
+
return { noIndex: true, noFollow: false };
|
|
78
|
+
case 'index-nofollow':
|
|
79
|
+
return { noIndex: false, noFollow: true };
|
|
80
|
+
case 'noindex-nofollow':
|
|
81
|
+
return { noIndex: true, noFollow: true };
|
|
82
|
+
default:
|
|
83
|
+
return { noIndex: undefined, noFollow: undefined };
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
48
87
|
interface SEOCheck {
|
|
49
88
|
id: string;
|
|
50
89
|
label: string;
|
|
@@ -459,6 +498,7 @@ export function SEOPanel({ title, slug, content = '', seoData, onChange, siteUrl
|
|
|
459
498
|
|
|
460
499
|
const metaTitle = seoData.metaTitle ?? '';
|
|
461
500
|
const metaDesc = seoData.metaDescription ?? '';
|
|
501
|
+
const robotsPolicy = getRobotsPolicy(seoData);
|
|
462
502
|
const displayTitle = seoData.ogTitle || metaTitle || title || 'Page Title';
|
|
463
503
|
const displayDesc = seoData.ogDescription || metaDesc || 'Add a meta description to see how this page will appear in search results.';
|
|
464
504
|
const fleschLabel =
|
|
@@ -609,18 +649,49 @@ export function SEOPanel({ title, slug, content = '', seoData, onChange, siteUrl
|
|
|
609
649
|
onToggle={toggleSection}
|
|
610
650
|
>
|
|
611
651
|
<div className="space-y-4">
|
|
612
|
-
<
|
|
613
|
-
label="
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
652
|
+
<div>
|
|
653
|
+
<label id="robots-policy-label" className="mb-1 block text-xs font-medium text-muted-foreground">
|
|
654
|
+
Robots Policy
|
|
655
|
+
</label>
|
|
656
|
+
<Select.Root
|
|
657
|
+
value={robotsPolicy}
|
|
658
|
+
onValueChange={(value) => {
|
|
659
|
+
const policy = value as NonNullable<SEOData['robotsPolicy']>;
|
|
660
|
+
update({ robotsPolicy: policy, ...robotsPolicyToBooleans(policy) });
|
|
661
|
+
}}
|
|
662
|
+
>
|
|
663
|
+
<Select.Trigger
|
|
664
|
+
aria-labelledby="robots-policy-label"
|
|
665
|
+
className="flex w-full items-center justify-between rounded-lg border border-border bg-background px-3 py-1.5 text-sm text-foreground focus:outline-none focus:ring-2 focus:ring-primary"
|
|
666
|
+
>
|
|
667
|
+
<Select.Value />
|
|
668
|
+
<Select.Icon>
|
|
669
|
+
<ChevronDown className="h-4 w-4 text-muted-foreground" />
|
|
670
|
+
</Select.Icon>
|
|
671
|
+
</Select.Trigger>
|
|
672
|
+
<Select.Portal>
|
|
673
|
+
<Select.Content className="z-50 overflow-hidden rounded-lg border border-border bg-card shadow-md">
|
|
674
|
+
<Select.Viewport className="p-1">
|
|
675
|
+
{ROBOTS_POLICY_OPTIONS.map((option) => (
|
|
676
|
+
<Select.Item
|
|
677
|
+
key={option.value}
|
|
678
|
+
value={option.value}
|
|
679
|
+
className="relative flex cursor-pointer select-none items-center rounded-md py-1.5 pl-8 pr-3 text-sm text-foreground outline-none hover:bg-muted focus:bg-muted"
|
|
680
|
+
>
|
|
681
|
+
<Select.ItemIndicator className="absolute left-2 inline-flex items-center">
|
|
682
|
+
<Check className="h-4 w-4" />
|
|
683
|
+
</Select.ItemIndicator>
|
|
684
|
+
<Select.ItemText>{option.label}</Select.ItemText>
|
|
685
|
+
</Select.Item>
|
|
686
|
+
))}
|
|
687
|
+
</Select.Viewport>
|
|
688
|
+
</Select.Content>
|
|
689
|
+
</Select.Portal>
|
|
690
|
+
</Select.Root>
|
|
691
|
+
<p className="mt-1 text-xs text-muted-foreground">
|
|
692
|
+
Use inheritance for most pages. Override only when a page needs different index/follow behavior.
|
|
693
|
+
</p>
|
|
694
|
+
</div>
|
|
624
695
|
</div>
|
|
625
696
|
</Section>
|
|
626
697
|
|
package/src/lib/search.ts
CHANGED
|
@@ -5,10 +5,14 @@ export interface SortConfig<K extends string = string> {
|
|
|
5
5
|
direction: SortDirection;
|
|
6
6
|
}
|
|
7
7
|
|
|
8
|
-
|
|
8
|
+
function toSearchText(value: unknown): string {
|
|
9
|
+
return String(value ?? '');
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export function scoreRelevance(text: unknown, query: unknown): number {
|
|
9
13
|
if (!query) return 0;
|
|
10
|
-
const lower = text.toLowerCase();
|
|
11
|
-
const q = query.toLowerCase();
|
|
14
|
+
const lower = toSearchText(text).toLowerCase();
|
|
15
|
+
const q = toSearchText(query).toLowerCase();
|
|
12
16
|
if (lower === q) return 100;
|
|
13
17
|
if (lower.startsWith(q)) return 80;
|
|
14
18
|
const wordBoundary = new RegExp(`\\b${q.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}`);
|
|
@@ -20,7 +24,7 @@ export function scoreRelevance(text: string, query: string): number {
|
|
|
20
24
|
export function sortByRelevance<T>(
|
|
21
25
|
items: T[],
|
|
22
26
|
query: string,
|
|
23
|
-
getSearchFields: (item: T) =>
|
|
27
|
+
getSearchFields: (item: T) => unknown[],
|
|
24
28
|
): T[] {
|
|
25
29
|
if (!query.trim()) return items;
|
|
26
30
|
return [...items].sort((a, b) => {
|
|
@@ -85,7 +85,7 @@ export function DocumentEdit({ collectionSlug, documentId, config, onNavigate }:
|
|
|
85
85
|
|
|
86
86
|
const SEO_FIELDS: (keyof SEOData)[] = [
|
|
87
87
|
'metaTitle', 'metaDescription', 'focusKeyphrase', 'canonical',
|
|
88
|
-
'noIndex', 'noFollow', 'ogTitle', 'ogDescription', 'ogImage',
|
|
88
|
+
'robotsPolicy', 'noIndex', 'noFollow', 'ogTitle', 'ogDescription', 'ogImage',
|
|
89
89
|
'twitterTitle', 'twitterDescription', 'twitterImage',
|
|
90
90
|
'isCornerstone', 'schemaType',
|
|
91
91
|
];
|
package/src/views/Pages.tsx
CHANGED
|
@@ -52,6 +52,10 @@ function computeSeoScore(data: Record<string, unknown> | null | undefined): numb
|
|
|
52
52
|
return score;
|
|
53
53
|
}
|
|
54
54
|
|
|
55
|
+
function textValue(value: unknown): string {
|
|
56
|
+
return String(value ?? '');
|
|
57
|
+
}
|
|
58
|
+
|
|
55
59
|
function SeoScoreBadge({ score }: { score: number }) {
|
|
56
60
|
const color = score >= 80 ? 'bg-green-500' : score >= 60 ? 'bg-amber-500' : 'bg-red-500';
|
|
57
61
|
return (
|
|
@@ -102,10 +106,10 @@ export function Pages({ onNavigate }: PagesProps) {
|
|
|
102
106
|
|
|
103
107
|
const filteredAndSorted = useMemo(() => {
|
|
104
108
|
let results = pages.filter((page: any) => {
|
|
105
|
-
const matchesSearch = (page.title
|
|
106
|
-
(page.author
|
|
107
|
-
const matchesStatus = filterStatus === 'all' || (page.status
|
|
108
|
-
const matchesTemplate = filterTemplate === 'all' || (page.template
|
|
109
|
+
const matchesSearch = textValue(page.title).toLowerCase().includes(searchQuery.toLowerCase()) ||
|
|
110
|
+
textValue(page.author).toLowerCase().includes(searchQuery.toLowerCase());
|
|
111
|
+
const matchesStatus = filterStatus === 'all' || textValue(page.status).toLowerCase() === filterStatus.toLowerCase();
|
|
112
|
+
const matchesTemplate = filterTemplate === 'all' || textValue(page.template).toLowerCase() === filterTemplate.toLowerCase();
|
|
109
113
|
return matchesSearch && matchesStatus && matchesTemplate;
|
|
110
114
|
});
|
|
111
115
|
|
package/src/views/ScriptTags.tsx
CHANGED
|
@@ -36,16 +36,18 @@ const PLACEMENT_COLORS: Record<string, string> = {
|
|
|
36
36
|
};
|
|
37
37
|
|
|
38
38
|
function scopeLabel(tag: ScriptTag): string {
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
39
|
+
const scope = String(tag.scope ?? '');
|
|
40
|
+
const targetPaths = Array.isArray(tag.targetPaths) ? tag.targetPaths : [];
|
|
41
|
+
if (scope === 'site') return 'Entire Site';
|
|
42
|
+
if (scope === 'parents') {
|
|
43
|
+
const count = targetPaths.length;
|
|
42
44
|
return `${count} parent path${count !== 1 ? 's' : ''} + children`;
|
|
43
45
|
}
|
|
44
|
-
if (
|
|
45
|
-
const count =
|
|
46
|
+
if (scope === 'urls') {
|
|
47
|
+
const count = targetPaths.length;
|
|
46
48
|
return `${count} URL${count !== 1 ? 's' : ''}`;
|
|
47
49
|
}
|
|
48
|
-
return
|
|
50
|
+
return scope || 'Custom';
|
|
49
51
|
}
|
|
50
52
|
|
|
51
53
|
export function ScriptTags({ onNavigate }: ScriptTagsProps) {
|
package/src/views/Settings.tsx
CHANGED
|
@@ -22,6 +22,9 @@ export function Settings({ config, ..._props }: SettingsProps = {}) {
|
|
|
22
22
|
const [siteUrl, setSiteUrl] = useState('https://example.com');
|
|
23
23
|
const [language, setLanguage] = useState('en');
|
|
24
24
|
const [timezone, setTimezone] = useState('UTC');
|
|
25
|
+
const [defaultNoIndex, setDefaultNoIndex] = useState(false);
|
|
26
|
+
const [defaultNoFollow, setDefaultNoFollow] = useState(false);
|
|
27
|
+
const [noIndexNonProduction, setNoIndexNonProduction] = useState(false);
|
|
25
28
|
const [twoFactorEnabled, setTwoFactorEnabled] = useState(false);
|
|
26
29
|
const [sessionTimeout, setSessionTimeout] = useState(false);
|
|
27
30
|
const [ipWhitelist, setIpWhitelist] = useState(false);
|
|
@@ -61,6 +64,9 @@ export function Settings({ config, ..._props }: SettingsProps = {}) {
|
|
|
61
64
|
setSiteUrl(data.siteUrl ?? '');
|
|
62
65
|
setLanguage(data.language ?? 'en');
|
|
63
66
|
setTimezone(data.timezone ?? 'UTC');
|
|
67
|
+
setDefaultNoIndex(data.defaultNoIndex ?? false);
|
|
68
|
+
setDefaultNoFollow(data.defaultNoFollow ?? false);
|
|
69
|
+
setNoIndexNonProduction(data.noIndexNonProduction ?? false);
|
|
64
70
|
setTwoFactorEnabled(data.twoFactorEnabled ?? false);
|
|
65
71
|
setSessionTimeout(data.sessionTimeout ?? false);
|
|
66
72
|
setIpWhitelist(data.ipWhitelist ?? false);
|
|
@@ -87,6 +93,7 @@ export function Settings({ config, ..._props }: SettingsProps = {}) {
|
|
|
87
93
|
method: 'PUT',
|
|
88
94
|
body: JSON.stringify({
|
|
89
95
|
siteTitle, tagline, siteUrl, language, timezone,
|
|
96
|
+
defaultNoIndex, defaultNoFollow, noIndexNonProduction,
|
|
90
97
|
twoFactorEnabled, sessionTimeout, ipWhitelist,
|
|
91
98
|
aiProvider, aiAltTags, aiMediaCategorize, aiMetaDescriptions,
|
|
92
99
|
aiReadability, aiSchema, aiBrandVoice, aiWritingAssistant,
|
|
@@ -197,6 +204,32 @@ export function Settings({ config, ..._props }: SettingsProps = {}) {
|
|
|
197
204
|
</div>
|
|
198
205
|
</div>
|
|
199
206
|
</div>
|
|
207
|
+
<div className="rounded-lg border border-border bg-card p-4">
|
|
208
|
+
<h3 className="mb-1 text-sm font-medium text-foreground">SEO & Robots Defaults</h3>
|
|
209
|
+
<p className="mb-4 text-xs text-muted-foreground">
|
|
210
|
+
Set the site-wide default for search engine indexing. Individual pages can inherit or override these rules in their SEO panel.
|
|
211
|
+
</p>
|
|
212
|
+
<div className="space-y-4">
|
|
213
|
+
<ToggleSetting
|
|
214
|
+
label="Default No Index"
|
|
215
|
+
description="Ask search engines not to index pages unless a page explicitly allows indexing"
|
|
216
|
+
checked={defaultNoIndex}
|
|
217
|
+
onChange={setDefaultNoIndex}
|
|
218
|
+
/>
|
|
219
|
+
<ToggleSetting
|
|
220
|
+
label="Default No Follow"
|
|
221
|
+
description="Ask search engines not to follow page links unless a page explicitly allows following"
|
|
222
|
+
checked={defaultNoFollow}
|
|
223
|
+
onChange={setDefaultNoFollow}
|
|
224
|
+
/>
|
|
225
|
+
<ToggleSetting
|
|
226
|
+
label="Noindex Non-Production Environments"
|
|
227
|
+
description="Force noindex when the deployed environment is not production"
|
|
228
|
+
checked={noIndexNonProduction}
|
|
229
|
+
onChange={setNoIndexNonProduction}
|
|
230
|
+
/>
|
|
231
|
+
</div>
|
|
232
|
+
</div>
|
|
200
233
|
</Tabs.Content>
|
|
201
234
|
|
|
202
235
|
<Tabs.Content value="appearance" className="space-y-4">
|
|
@@ -513,14 +546,16 @@ function ToggleSetting({
|
|
|
513
546
|
return (
|
|
514
547
|
<div className="flex items-center justify-between gap-4">
|
|
515
548
|
<div className="flex-1">
|
|
516
|
-
<label className="text-sm font-medium text-
|
|
517
|
-
<p className="mt-0.5 text-xs text-
|
|
549
|
+
<label className="text-sm font-medium text-foreground">{label}</label>
|
|
550
|
+
<p className="mt-0.5 text-xs text-muted-foreground">{description}</p>
|
|
518
551
|
</div>
|
|
519
552
|
<button
|
|
520
553
|
type="button"
|
|
554
|
+
role="switch"
|
|
555
|
+
aria-checked={checked}
|
|
556
|
+
aria-label={label}
|
|
521
557
|
onClick={() => onChange(!checked)}
|
|
522
|
-
className={`relative h-6 w-11 shrink-0 rounded-full transition-colors ${checked ? 'bg-
|
|
523
|
-
aria-pressed={checked}
|
|
558
|
+
className={`relative h-6 w-11 shrink-0 rounded-full transition-colors focus:outline-none focus:ring-2 focus:ring-primary ${checked ? 'bg-primary' : 'bg-muted'}`}
|
|
524
559
|
>
|
|
525
560
|
<span
|
|
526
561
|
className={`absolute top-0.5 block h-5 w-5 rounded-full bg-white transition-transform ${
|