@actuate-media/cms-admin 0.25.0 → 0.26.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.
Files changed (51) hide show
  1. package/dist/AdminRoot.d.ts.map +1 -1
  2. package/dist/AdminRoot.js +3 -3
  3. package/dist/AdminRoot.js.map +1 -1
  4. package/dist/__tests__/views/general-settings.render.test.d.ts +2 -0
  5. package/dist/__tests__/views/general-settings.render.test.d.ts.map +1 -0
  6. package/dist/__tests__/views/general-settings.render.test.js +105 -0
  7. package/dist/__tests__/views/general-settings.render.test.js.map +1 -0
  8. package/dist/actuate-admin.css +1 -1
  9. package/dist/views/Settings.d.ts +3 -1
  10. package/dist/views/Settings.d.ts.map +1 -1
  11. package/dist/views/Settings.js +9 -28
  12. package/dist/views/Settings.js.map +1 -1
  13. package/dist/views/settings/GeneralSettingsTab.d.ts +7 -0
  14. package/dist/views/settings/GeneralSettingsTab.d.ts.map +1 -0
  15. package/dist/views/settings/GeneralSettingsTab.js +19 -0
  16. package/dist/views/settings/GeneralSettingsTab.js.map +1 -0
  17. package/dist/views/settings/LanguageRegionCard.d.ts +10 -0
  18. package/dist/views/settings/LanguageRegionCard.d.ts.map +1 -0
  19. package/dist/views/settings/LanguageRegionCard.js +48 -0
  20. package/dist/views/settings/LanguageRegionCard.js.map +1 -0
  21. package/dist/views/settings/SeoRobotsDefaultsCard.d.ts +12 -0
  22. package/dist/views/settings/SeoRobotsDefaultsCard.d.ts.map +1 -0
  23. package/dist/views/settings/SeoRobotsDefaultsCard.js +45 -0
  24. package/dist/views/settings/SeoRobotsDefaultsCard.js.map +1 -0
  25. package/dist/views/settings/SiteIndexStatusCard.d.ts +12 -0
  26. package/dist/views/settings/SiteIndexStatusCard.d.ts.map +1 -0
  27. package/dist/views/settings/SiteIndexStatusCard.js +51 -0
  28. package/dist/views/settings/SiteIndexStatusCard.js.map +1 -0
  29. package/dist/views/settings/SiteInformationCard.d.ts +11 -0
  30. package/dist/views/settings/SiteInformationCard.d.ts.map +1 -0
  31. package/dist/views/settings/SiteInformationCard.js +21 -0
  32. package/dist/views/settings/SiteInformationCard.js.map +1 -0
  33. package/dist/views/settings/components.d.ts +38 -0
  34. package/dist/views/settings/components.d.ts.map +1 -0
  35. package/dist/views/settings/components.js +43 -0
  36. package/dist/views/settings/components.js.map +1 -0
  37. package/dist/views/settings/useGeneralSettings.d.ts +79 -0
  38. package/dist/views/settings/useGeneralSettings.d.ts.map +1 -0
  39. package/dist/views/settings/useGeneralSettings.js +286 -0
  40. package/dist/views/settings/useGeneralSettings.js.map +1 -0
  41. package/package.json +3 -3
  42. package/src/AdminRoot.tsx +5 -3
  43. package/src/__tests__/views/general-settings.render.test.tsx +135 -0
  44. package/src/views/Settings.tsx +17 -120
  45. package/src/views/settings/GeneralSettingsTab.tsx +126 -0
  46. package/src/views/settings/LanguageRegionCard.tsx +119 -0
  47. package/src/views/settings/SeoRobotsDefaultsCard.tsx +149 -0
  48. package/src/views/settings/SiteIndexStatusCard.tsx +184 -0
  49. package/src/views/settings/SiteInformationCard.tsx +122 -0
  50. package/src/views/settings/components.tsx +198 -0
  51. package/src/views/settings/useGeneralSettings.ts +376 -0
@@ -10,7 +10,6 @@ import {
10
10
  BookOpen,
11
11
  Sparkles,
12
12
  MessageSquare,
13
- Languages,
14
13
  Loader2,
15
14
  AlertTriangle,
16
15
  Download,
@@ -30,36 +29,36 @@ import { cmsApi } from '../lib/api.js'
30
29
  import { useTheme } from '../components/ThemeProvider.js'
31
30
  import { RelationshipField } from '../fields/RelationshipField.js'
32
31
  import { SEOConfigPanel } from '../components/SEOConfigPanel.js'
32
+ import { GeneralSettingsTab } from './settings/GeneralSettingsTab.js'
33
33
  import { Users as UsersView } from './Users.js'
34
34
  import { ApiKeys as ApiKeysView } from './ApiKeys.js'
35
35
 
36
36
  export interface SettingsProps {
37
37
  onNavigate?: (path: string) => void
38
38
  config?: any
39
+ /** Current admin session — used to gate editing on role. */
40
+ session?: any
39
41
  /** Initial tab when arriving without a `?tab=` query param (e.g. via the
40
42
  * legacy `/users` or `/api-keys` routes). */
41
43
  initialTab?: string
42
44
  }
43
45
 
44
46
  /** Tabs that drive their own persistence (no global "Save Changes" footer). */
45
- const SELF_MANAGED_TABS = new Set(['users', 'api-keys'])
47
+ const SELF_MANAGED_TABS = new Set(['general', 'users', 'api-keys'])
46
48
 
47
49
  function readSettingsTabFromUrl(): string | null {
48
50
  if (typeof window === 'undefined') return null
49
51
  return new URLSearchParams(window.location.search).get('tab')
50
52
  }
51
53
 
52
- export function Settings({ config, onNavigate, initialTab = 'general' }: SettingsProps = {}) {
54
+ export function Settings({
55
+ config,
56
+ onNavigate,
57
+ session,
58
+ initialTab = 'general',
59
+ }: SettingsProps = {}) {
53
60
  const { data, loading, error, refetch } = useApiData<any>('/globals/settings')
54
61
 
55
- const [siteTitle, setSiteTitle] = useState('My CMS')
56
- const [tagline, setTagline] = useState('A lightweight content management system')
57
- const [siteUrl, setSiteUrl] = useState('https://example.com')
58
- const [language, setLanguage] = useState('en')
59
- const [timezone, setTimezone] = useState('UTC')
60
- const [defaultNoIndex, setDefaultNoIndex] = useState(false)
61
- const [defaultNoFollow, setDefaultNoFollow] = useState(false)
62
- const [noIndexNonProduction, setNoIndexNonProduction] = useState(false)
63
62
  const [twoFactorEnabled, setTwoFactorEnabled] = useState(false)
64
63
  const [sessionTimeout, setSessionTimeout] = useState(false)
65
64
  const [ipWhitelist, setIpWhitelist] = useState(false)
@@ -139,14 +138,6 @@ export function Settings({ config, onNavigate, initialTab = 'general' }: Setting
139
138
 
140
139
  useEffect(() => {
141
140
  if (data) {
142
- setSiteTitle(data.siteTitle ?? 'My CMS')
143
- setTagline(data.tagline ?? '')
144
- setSiteUrl(data.siteUrl ?? '')
145
- setLanguage(data.language ?? 'en')
146
- setTimezone(data.timezone ?? 'UTC')
147
- setDefaultNoIndex(data.defaultNoIndex ?? false)
148
- setDefaultNoFollow(data.defaultNoFollow ?? false)
149
- setNoIndexNonProduction(data.noIndexNonProduction ?? false)
150
141
  setTwoFactorEnabled(data.twoFactorEnabled ?? false)
151
142
  setSessionTimeout(data.sessionTimeout ?? false)
152
143
  setIpWhitelist(data.ipWhitelist ?? false)
@@ -169,17 +160,13 @@ export function Settings({ config, onNavigate, initialTab = 'general' }: Setting
169
160
  const handleSave = async () => {
170
161
  setSaving(true)
171
162
  const layoutPayload = Object.keys(defaultLayout).length > 0 ? { defaultLayout } : {}
163
+ // Read-modify-write: the General tab owns site info / SEO defaults in the
164
+ // same global blob, so merge against a fresh fetch instead of overwriting.
165
+ const current = await cmsApi<Record<string, unknown>>('/globals/settings')
172
166
  const res = await cmsApi('/globals/settings', {
173
167
  method: 'PUT',
174
168
  body: JSON.stringify({
175
- siteTitle,
176
- tagline,
177
- siteUrl,
178
- language,
179
- timezone,
180
- defaultNoIndex,
181
- defaultNoFollow,
182
- noIndexNonProduction,
169
+ ...(current.data ?? {}),
183
170
  twoFactorEnabled,
184
171
  sessionTimeout,
185
172
  ipWhitelist,
@@ -205,7 +192,7 @@ export function Settings({ config, onNavigate, initialTab = 'general' }: Setting
205
192
  }
206
193
 
207
194
  const tabTriggerClass =
208
- 'px-4 py-2 text-sm font-medium text-gray-600 transition-colors hover:text-gray-900 data-[state=active]:border-b-2 data-[state=active]:border-blue-600 data-[state=active]:text-blue-600'
195
+ 'text-muted-foreground hover:text-foreground data-[state=active]:border-brand data-[state=active]:text-brand px-4 py-2 text-base font-medium transition-colors data-[state=active]:border-b-2'
209
196
 
210
197
  if (loading) {
211
198
  return (
@@ -283,98 +270,8 @@ export function Settings({ config, onNavigate, initialTab = 'general' }: Setting
283
270
  </Tabs.Trigger>
284
271
  </Tabs.List>
285
272
 
286
- <Tabs.Content value="general" className="space-y-4">
287
- <div className="rounded-lg border border-gray-200 bg-white p-4">
288
- <h3 className="mb-4 text-sm font-semibold text-gray-900">Site Information</h3>
289
- <div className="space-y-4">
290
- <div>
291
- <label className="mb-1 block text-sm font-medium text-gray-700">Site Title</label>
292
- <input
293
- type="text"
294
- value={siteTitle}
295
- onChange={(e) => setSiteTitle(e.target.value)}
296
- className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:ring-2 focus:ring-blue-500 focus:outline-none"
297
- />
298
- </div>
299
- <div>
300
- <label className="mb-1 block text-sm font-medium text-gray-700">Tagline</label>
301
- <input
302
- type="text"
303
- value={tagline}
304
- onChange={(e) => setTagline(e.target.value)}
305
- className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:ring-2 focus:ring-blue-500 focus:outline-none"
306
- />
307
- </div>
308
- <div>
309
- <label className="mb-1 block text-sm font-medium text-gray-700">Site URL</label>
310
- <input
311
- type="url"
312
- value={siteUrl}
313
- onChange={(e) => setSiteUrl(e.target.value)}
314
- className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:ring-2 focus:ring-blue-500 focus:outline-none"
315
- />
316
- </div>
317
- </div>
318
- </div>
319
- <div className="rounded-lg border border-gray-200 bg-white p-4">
320
- <h3 className="mb-4 text-sm font-semibold text-gray-900">Language & Region</h3>
321
- <div className="space-y-4">
322
- <div>
323
- <label className="mb-1 block text-sm font-medium text-gray-700">Language</label>
324
- <select
325
- value={language}
326
- onChange={(e) => setLanguage(e.target.value)}
327
- className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:ring-2 focus:ring-blue-500 focus:outline-none"
328
- >
329
- <option value="en">English</option>
330
- <option value="es">Spanish</option>
331
- <option value="fr">French</option>
332
- <option value="de">German</option>
333
- </select>
334
- </div>
335
- <div>
336
- <label className="mb-1 block text-sm font-medium text-gray-700">Timezone</label>
337
- <select
338
- value={timezone}
339
- onChange={(e) => setTimezone(e.target.value)}
340
- className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:ring-2 focus:ring-blue-500 focus:outline-none"
341
- >
342
- <option value="UTC">UTC</option>
343
- <option value="America/New_York">Eastern Time</option>
344
- <option value="America/Chicago">Central Time</option>
345
- <option value="America/Denver">Mountain Time</option>
346
- <option value="America/Los_Angeles">Pacific Time</option>
347
- </select>
348
- </div>
349
- </div>
350
- </div>
351
- <div className="border-border bg-card rounded-lg border p-4">
352
- <h3 className="text-foreground mb-1 text-sm font-medium">SEO & Robots Defaults</h3>
353
- <p className="text-muted-foreground mb-4 text-xs">
354
- Set the site-wide default for search engine indexing. Individual pages can inherit or
355
- override these rules in their SEO panel.
356
- </p>
357
- <div className="space-y-4">
358
- <ToggleSetting
359
- label="Default No Index"
360
- description="Ask search engines not to index pages unless a page explicitly allows indexing"
361
- checked={defaultNoIndex}
362
- onChange={setDefaultNoIndex}
363
- />
364
- <ToggleSetting
365
- label="Default No Follow"
366
- description="Ask search engines not to follow page links unless a page explicitly allows following"
367
- checked={defaultNoFollow}
368
- onChange={setDefaultNoFollow}
369
- />
370
- <ToggleSetting
371
- label="Noindex Non-Production Environments"
372
- description="Force noindex when the deployed environment is not production"
373
- checked={noIndexNonProduction}
374
- onChange={setNoIndexNonProduction}
375
- />
376
- </div>
377
- </div>
273
+ <Tabs.Content value="general">
274
+ <GeneralSettingsTab onNavigate={onNavigate} role={session?.user?.role} />
378
275
  </Tabs.Content>
379
276
 
380
277
  <Tabs.Content value="appearance" className="space-y-4">
@@ -0,0 +1,126 @@
1
+ 'use client'
2
+
3
+ import { AlertTriangle, Info } from 'lucide-react'
4
+ import { useGeneralSettings } from './useGeneralSettings.js'
5
+ import { SettingsSaveBar, SettingsValidationSummary } from './components.js'
6
+ import { SiteIndexStatusCard } from './SiteIndexStatusCard.js'
7
+ import { SiteInformationCard } from './SiteInformationCard.js'
8
+ import { LanguageRegionCard } from './LanguageRegionCard.js'
9
+ import { SeoRobotsDefaultsCard } from './SeoRobotsDefaultsCard.js'
10
+
11
+ export interface GeneralSettingsTabProps {
12
+ onNavigate?: (path: string) => void
13
+ /** Current user's role. Only ADMIN can persist changes. */
14
+ role?: string
15
+ }
16
+
17
+ export function GeneralSettingsTab({ onNavigate, role }: GeneralSettingsTabProps) {
18
+ const canEdit = role === 'ADMIN'
19
+ const settings = useGeneralSettings(canEdit)
20
+
21
+ const runAudit = () => onNavigate?.('/seo?tab=audit')
22
+
23
+ if (settings.loading) {
24
+ return (
25
+ <div className="space-y-4" aria-busy="true" aria-live="polite">
26
+ <span className="sr-only">Loading settings…</span>
27
+ {[0, 1, 2, 3].map((i) => (
28
+ <div key={i} className="border-border bg-card rounded-lg border p-4">
29
+ <div className="bg-muted mb-4 h-4 w-40 animate-pulse rounded" />
30
+ <div className="space-y-3">
31
+ <div className="bg-muted h-9 w-full animate-pulse rounded" />
32
+ <div className="bg-muted h-9 w-full animate-pulse rounded" />
33
+ </div>
34
+ </div>
35
+ ))}
36
+ </div>
37
+ )
38
+ }
39
+
40
+ return (
41
+ <div className="space-y-4">
42
+ {settings.loadError && (
43
+ <div
44
+ role="alert"
45
+ className="border-destructive/30 bg-destructive/10 text-destructive flex items-center gap-3 rounded-lg border p-3 text-sm"
46
+ >
47
+ <AlertTriangle size={16} className="shrink-0" aria-hidden="true" />
48
+ <span className="flex-1">{settings.loadError}</span>
49
+ <button
50
+ type="button"
51
+ onClick={settings.refetch}
52
+ className="border-destructive/40 rounded-md border px-3 py-1 transition-colors hover:opacity-80"
53
+ >
54
+ Retry
55
+ </button>
56
+ </div>
57
+ )}
58
+
59
+ {!canEdit && (
60
+ <div className="border-border bg-muted text-muted-foreground flex items-center gap-2 rounded-lg border p-3 text-sm">
61
+ <Info size={16} className="shrink-0" aria-hidden="true" />
62
+ You have read-only access to settings. Only administrators can make changes.
63
+ </div>
64
+ )}
65
+
66
+ <SettingsValidationSummary issues={settings.issues} />
67
+
68
+ <SiteIndexStatusCard
69
+ status={settings.indexStatus}
70
+ loading={settings.loading}
71
+ onNavigate={onNavigate}
72
+ onRunAudit={runAudit}
73
+ />
74
+
75
+ <SiteInformationCard
76
+ form={settings.form}
77
+ setField={settings.setField}
78
+ sources={settings.sources}
79
+ issues={settings.issues}
80
+ canEdit={canEdit}
81
+ />
82
+
83
+ <LanguageRegionCard
84
+ form={settings.form}
85
+ setField={settings.setField}
86
+ issues={settings.issues}
87
+ canEdit={canEdit}
88
+ />
89
+
90
+ <SeoRobotsDefaultsCard
91
+ form={settings.form}
92
+ setField={settings.setField}
93
+ isProduction={settings.isProduction}
94
+ indexStatus={settings.indexStatus}
95
+ canEdit={canEdit}
96
+ onNavigate={onNavigate}
97
+ />
98
+
99
+ {settings.saveState === 'error' && settings.saveError && (
100
+ <div
101
+ role="alert"
102
+ className="border-destructive/30 bg-destructive/10 text-destructive flex items-center gap-3 rounded-lg border p-3 text-sm"
103
+ >
104
+ <AlertTriangle size={16} className="shrink-0" aria-hidden="true" />
105
+ <span className="flex-1">{settings.saveError}</span>
106
+ <button
107
+ type="button"
108
+ onClick={settings.save}
109
+ className="border-destructive/40 rounded-md border px-3 py-1 transition-colors hover:opacity-80"
110
+ >
111
+ Retry
112
+ </button>
113
+ </div>
114
+ )}
115
+
116
+ <SettingsSaveBar
117
+ dirty={settings.dirty}
118
+ canEdit={canEdit}
119
+ hasErrors={settings.hasErrors}
120
+ saveState={settings.saveState}
121
+ onSave={settings.save}
122
+ onReset={settings.reset}
123
+ />
124
+ </div>
125
+ )
126
+ }
@@ -0,0 +1,119 @@
1
+ 'use client'
2
+
3
+ import { useId, useMemo } from 'react'
4
+ import type { GeneralSettingsForm, ValidationIssue } from './useGeneralSettings.js'
5
+
6
+ const INPUT_CLASS =
7
+ 'w-full rounded-md border border-border bg-input-background px-3 py-2 text-base text-foreground focus-visible:ring-2 focus-visible:ring-ring focus-visible:outline-none disabled:cursor-not-allowed disabled:opacity-60'
8
+ const LABEL_CLASS = 'mb-1 block text-sm font-medium text-foreground'
9
+
10
+ const LANGUAGES: Array<{ value: string; label: string }> = [
11
+ { value: 'en-US', label: 'English (US)' },
12
+ { value: 'en-GB', label: 'English (UK)' },
13
+ { value: 'es-ES', label: 'Spanish (Spain)' },
14
+ { value: 'es-MX', label: 'Spanish (Mexico)' },
15
+ { value: 'fr-FR', label: 'French (France)' },
16
+ { value: 'de-DE', label: 'German (Germany)' },
17
+ { value: 'en', label: 'English' },
18
+ ]
19
+
20
+ const TIMEZONES = [
21
+ 'UTC',
22
+ 'America/New_York',
23
+ 'America/Chicago',
24
+ 'America/Denver',
25
+ 'America/Los_Angeles',
26
+ 'America/Anchorage',
27
+ 'Europe/London',
28
+ 'Europe/Paris',
29
+ 'Europe/Berlin',
30
+ 'Asia/Tokyo',
31
+ 'Asia/Singapore',
32
+ 'Australia/Sydney',
33
+ ]
34
+
35
+ interface LanguageRegionCardProps {
36
+ form: GeneralSettingsForm
37
+ setField: <K extends keyof GeneralSettingsForm>(key: K, value: GeneralSettingsForm[K]) => void
38
+ issues: ValidationIssue[]
39
+ canEdit: boolean
40
+ }
41
+
42
+ export function LanguageRegionCard({ form, setField, issues, canEdit }: LanguageRegionCardProps) {
43
+ const langId = useId()
44
+ const tzId = useId()
45
+ const langIssue = issues.find((i) => i.field === 'language')
46
+ const tzIssue = issues.find((i) => i.field === 'timezone')
47
+
48
+ // Surface the loaded value even when it isn't one of our presets, so a
49
+ // seeded/config value is never silently dropped from the select.
50
+ const languageOptions = useMemo(() => {
51
+ if (LANGUAGES.some((l) => l.value === form.language)) return LANGUAGES
52
+ return [{ value: form.language, label: form.language }, ...LANGUAGES]
53
+ }, [form.language])
54
+
55
+ const timezoneOptions = useMemo(() => {
56
+ if (TIMEZONES.includes(form.timezone)) return TIMEZONES
57
+ return [form.timezone, ...TIMEZONES]
58
+ }, [form.timezone])
59
+
60
+ return (
61
+ <section
62
+ aria-labelledby={`${langId}-heading`}
63
+ className="border-border bg-card rounded-lg border p-4"
64
+ >
65
+ <h3 id={`${langId}-heading`} className="text-foreground mb-4 text-base font-medium">
66
+ Language &amp; Region
67
+ </h3>
68
+ <div className="space-y-4">
69
+ <div>
70
+ <label htmlFor={langId} className={LABEL_CLASS}>
71
+ Language
72
+ </label>
73
+ <select
74
+ id={langId}
75
+ value={form.language}
76
+ disabled={!canEdit}
77
+ aria-invalid={Boolean(langIssue)}
78
+ onChange={(e) => setField('language', e.target.value)}
79
+ className={INPUT_CLASS}
80
+ >
81
+ {languageOptions.map((l) => (
82
+ <option key={l.value} value={l.value}>
83
+ {l.label}
84
+ </option>
85
+ ))}
86
+ </select>
87
+ {langIssue && <p className="text-destructive mt-1 text-sm">{langIssue.message}</p>}
88
+ </div>
89
+
90
+ <div>
91
+ <label htmlFor={tzId} className={LABEL_CLASS}>
92
+ Timezone
93
+ </label>
94
+ <select
95
+ id={tzId}
96
+ value={form.timezone}
97
+ disabled={!canEdit}
98
+ aria-invalid={Boolean(tzIssue)}
99
+ onChange={(e) => setField('timezone', e.target.value)}
100
+ className={INPUT_CLASS}
101
+ >
102
+ {timezoneOptions.map((tz) => (
103
+ <option key={tz} value={tz}>
104
+ {tz}
105
+ </option>
106
+ ))}
107
+ </select>
108
+ {tzIssue ? (
109
+ <p className="text-destructive mt-1 text-sm">{tzIssue.message}</p>
110
+ ) : (
111
+ <p className="text-muted-foreground mt-1 text-sm">
112
+ Applied to dashboard dates, audit logs, scheduled publishing, and form submissions.
113
+ </p>
114
+ )}
115
+ </div>
116
+ </div>
117
+ </section>
118
+ )
119
+ }
@@ -0,0 +1,149 @@
1
+ 'use client'
2
+
3
+ import { useId, useState } from 'react'
4
+ import { ConfirmDangerousSettingDialog, SettingsToggleRow } from './components.js'
5
+ import type { GeneralSettingsForm, SiteIndexStatus } from './useGeneralSettings.js'
6
+
7
+ interface SeoRobotsDefaultsCardProps {
8
+ form: GeneralSettingsForm
9
+ setField: <K extends keyof GeneralSettingsForm>(key: K, value: GeneralSettingsForm[K]) => void
10
+ isProduction: boolean
11
+ indexStatus: SiteIndexStatus | null
12
+ canEdit: boolean
13
+ onNavigate?: (path: string) => void
14
+ }
15
+
16
+ interface PendingChange {
17
+ field: keyof GeneralSettingsForm
18
+ value: boolean
19
+ title: string
20
+ description: string
21
+ confirmLabel: string
22
+ }
23
+
24
+ export function SeoRobotsDefaultsCard({
25
+ form,
26
+ setField,
27
+ isProduction,
28
+ indexStatus,
29
+ canEdit,
30
+ onNavigate,
31
+ }: SeoRobotsDefaultsCardProps) {
32
+ const headingId = useId()
33
+ const [pending, setPending] = useState<PendingChange | null>(null)
34
+
35
+ // Some toggles can take the whole production site out of search results, so
36
+ // they route through a confirmation step before being applied.
37
+ function requestToggle(field: keyof GeneralSettingsForm, value: boolean) {
38
+ if (field === 'defaultNoIndex' && value && isProduction) {
39
+ setPending({
40
+ field,
41
+ value,
42
+ title: 'Block this production site from search engines?',
43
+ description:
44
+ 'This may prevent search engines from indexing your production site. Pages will be served with a noindex directive unless they explicitly opt in.',
45
+ confirmLabel: 'Enable No Index',
46
+ })
47
+ return
48
+ }
49
+ if (field === 'noIndexNonProduction' && !value) {
50
+ setPending({
51
+ field,
52
+ value,
53
+ title: 'Allow non-production environments to be indexed?',
54
+ description:
55
+ 'Preview and staging environments may become indexable if publicly accessible. Search engines could index unfinished content.',
56
+ confirmLabel: 'Turn Off',
57
+ })
58
+ return
59
+ }
60
+ setField(field, value)
61
+ }
62
+
63
+ const effectiveProd = form.defaultNoIndex ? 'Blocked' : 'Indexable'
64
+ const sitemapCheck = indexStatus?.checks.find((c) => c.key === 'sitemap')
65
+ const sitemapStatus = sitemapCheck ? (sitemapCheck.value === 'Yes' ? 'Enabled' : 'Disabled') : '—'
66
+
67
+ return (
68
+ <section aria-labelledby={headingId} className="border-border bg-card rounded-lg border p-4">
69
+ <h3 id={headingId} className="text-foreground mb-1 text-base font-medium">
70
+ SEO &amp; Robots Defaults
71
+ </h3>
72
+ <p className="text-muted-foreground mb-4 text-sm">
73
+ Set the site-wide default for search engine indexing. Individual pages can inherit or
74
+ override these rules in their SEO panel. These share the same configuration as the SEO
75
+ section.
76
+ </p>
77
+
78
+ <div className="space-y-4">
79
+ <SettingsToggleRow
80
+ label="Default No Index"
81
+ description="Ask search engines not to index pages unless a page explicitly allows indexing."
82
+ checked={form.defaultNoIndex}
83
+ disabled={!canEdit}
84
+ onChange={(v) => requestToggle('defaultNoIndex', v)}
85
+ />
86
+ <SettingsToggleRow
87
+ label="Default No Follow"
88
+ description="Ask search engines not to follow page links unless a page explicitly allows following."
89
+ checked={form.defaultNoFollow}
90
+ disabled={!canEdit}
91
+ onChange={(v) => requestToggle('defaultNoFollow', v)}
92
+ />
93
+ <SettingsToggleRow
94
+ label="Noindex Non-Production Environments"
95
+ description="Force noindex when the deployed environment is not production."
96
+ checked={form.noIndexNonProduction}
97
+ disabled={!canEdit}
98
+ onChange={(v) => requestToggle('noIndexNonProduction', v)}
99
+ />
100
+ </div>
101
+
102
+ <div className="border-border mt-4 space-y-2 border-t pt-4">
103
+ <SummaryRow
104
+ label="Effective production indexing"
105
+ value={effectiveProd}
106
+ danger={form.defaultNoIndex}
107
+ />
108
+ <SummaryRow
109
+ label="Effective current environment"
110
+ value={indexStatus?.label ?? '—'}
111
+ danger={indexStatus?.severity === 'danger'}
112
+ />
113
+ <SummaryRow label="Sitemap status" value={sitemapStatus} />
114
+ {onNavigate && (
115
+ <button
116
+ type="button"
117
+ onClick={() => onNavigate('/seo?tab=technical')}
118
+ className="text-brand mt-1 text-sm hover:underline"
119
+ >
120
+ Manage advanced robots.txt &amp; sitemap in SEO →
121
+ </button>
122
+ )}
123
+ </div>
124
+
125
+ <ConfirmDangerousSettingDialog
126
+ open={pending !== null}
127
+ title={pending?.title ?? ''}
128
+ description={pending?.description ?? ''}
129
+ confirmLabel={pending?.confirmLabel}
130
+ onConfirm={() => {
131
+ if (pending) setField(pending.field, pending.value)
132
+ setPending(null)
133
+ }}
134
+ onCancel={() => setPending(null)}
135
+ />
136
+ </section>
137
+ )
138
+ }
139
+
140
+ function SummaryRow({ label, value, danger }: { label: string; value: string; danger?: boolean }) {
141
+ return (
142
+ <div className="flex items-center justify-between gap-2 text-sm">
143
+ <span className="text-muted-foreground">{label}</span>
144
+ <span className={danger ? 'text-destructive font-medium' : 'text-foreground font-medium'}>
145
+ {value}
146
+ </span>
147
+ </div>
148
+ )
149
+ }