@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.
@@ -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
- <ToggleSwitch
613
- label="No Index"
614
- description="Prevent search engines from indexing this page"
615
- checked={seoData.noIndex ?? false}
616
- onChange={(v) => update({ noIndex: v })}
617
- />
618
- <ToggleSwitch
619
- label="No Follow"
620
- description="Prevent search engines from following links"
621
- checked={seoData.noFollow ?? false}
622
- onChange={(v) => update({ noFollow: v })}
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
- export function scoreRelevance(text: string, query: string): number {
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) => string[],
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
  ];
@@ -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 ?? '').toLowerCase().includes(searchQuery.toLowerCase()) ||
106
- (page.author ?? '').toLowerCase().includes(searchQuery.toLowerCase());
107
- const matchesStatus = filterStatus === 'all' || (page.status ?? '').toLowerCase() === filterStatus.toLowerCase();
108
- const matchesTemplate = filterTemplate === 'all' || (page.template ?? '').toLowerCase() === filterTemplate.toLowerCase();
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
 
@@ -36,16 +36,18 @@ const PLACEMENT_COLORS: Record<string, string> = {
36
36
  };
37
37
 
38
38
  function scopeLabel(tag: ScriptTag): string {
39
- if (tag.scope === 'site') return 'Entire Site';
40
- if (tag.scope === 'parents') {
41
- const count = tag.targetPaths?.length ?? 0;
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 (tag.scope === 'urls') {
45
- const count = tag.targetPaths?.length ?? 0;
46
+ if (scope === 'urls') {
47
+ const count = targetPaths.length;
46
48
  return `${count} URL${count !== 1 ? 's' : ''}`;
47
49
  }
48
- return tag.scope;
50
+ return scope || 'Custom';
49
51
  }
50
52
 
51
53
  export function ScriptTags({ onNavigate }: ScriptTagsProps) {
@@ -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-gray-700">{label}</label>
517
- <p className="mt-0.5 text-xs text-gray-500">{description}</p>
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-blue-600' : 'bg-gray-300'}`}
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 ${