@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.
- package/dist/AdminRoot.d.ts.map +1 -1
- package/dist/AdminRoot.js +3 -3
- package/dist/AdminRoot.js.map +1 -1
- package/dist/__tests__/views/general-settings.render.test.d.ts +2 -0
- package/dist/__tests__/views/general-settings.render.test.d.ts.map +1 -0
- package/dist/__tests__/views/general-settings.render.test.js +105 -0
- package/dist/__tests__/views/general-settings.render.test.js.map +1 -0
- package/dist/actuate-admin.css +1 -1
- package/dist/views/Settings.d.ts +3 -1
- package/dist/views/Settings.d.ts.map +1 -1
- package/dist/views/Settings.js +9 -28
- package/dist/views/Settings.js.map +1 -1
- package/dist/views/settings/GeneralSettingsTab.d.ts +7 -0
- package/dist/views/settings/GeneralSettingsTab.d.ts.map +1 -0
- package/dist/views/settings/GeneralSettingsTab.js +19 -0
- package/dist/views/settings/GeneralSettingsTab.js.map +1 -0
- package/dist/views/settings/LanguageRegionCard.d.ts +10 -0
- package/dist/views/settings/LanguageRegionCard.d.ts.map +1 -0
- package/dist/views/settings/LanguageRegionCard.js +48 -0
- package/dist/views/settings/LanguageRegionCard.js.map +1 -0
- package/dist/views/settings/SeoRobotsDefaultsCard.d.ts +12 -0
- package/dist/views/settings/SeoRobotsDefaultsCard.d.ts.map +1 -0
- package/dist/views/settings/SeoRobotsDefaultsCard.js +45 -0
- package/dist/views/settings/SeoRobotsDefaultsCard.js.map +1 -0
- package/dist/views/settings/SiteIndexStatusCard.d.ts +12 -0
- package/dist/views/settings/SiteIndexStatusCard.d.ts.map +1 -0
- package/dist/views/settings/SiteIndexStatusCard.js +51 -0
- package/dist/views/settings/SiteIndexStatusCard.js.map +1 -0
- package/dist/views/settings/SiteInformationCard.d.ts +11 -0
- package/dist/views/settings/SiteInformationCard.d.ts.map +1 -0
- package/dist/views/settings/SiteInformationCard.js +21 -0
- package/dist/views/settings/SiteInformationCard.js.map +1 -0
- package/dist/views/settings/components.d.ts +38 -0
- package/dist/views/settings/components.d.ts.map +1 -0
- package/dist/views/settings/components.js +43 -0
- package/dist/views/settings/components.js.map +1 -0
- package/dist/views/settings/useGeneralSettings.d.ts +79 -0
- package/dist/views/settings/useGeneralSettings.d.ts.map +1 -0
- package/dist/views/settings/useGeneralSettings.js +286 -0
- package/dist/views/settings/useGeneralSettings.js.map +1 -0
- package/package.json +3 -3
- package/src/AdminRoot.tsx +5 -3
- package/src/__tests__/views/general-settings.render.test.tsx +135 -0
- package/src/views/Settings.tsx +17 -120
- package/src/views/settings/GeneralSettingsTab.tsx +126 -0
- package/src/views/settings/LanguageRegionCard.tsx +119 -0
- package/src/views/settings/SeoRobotsDefaultsCard.tsx +149 -0
- package/src/views/settings/SiteIndexStatusCard.tsx +184 -0
- package/src/views/settings/SiteInformationCard.tsx +122 -0
- package/src/views/settings/components.tsx +198 -0
- package/src/views/settings/useGeneralSettings.ts +376 -0
package/src/views/Settings.tsx
CHANGED
|
@@ -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({
|
|
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
|
-
|
|
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
|
-
'
|
|
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"
|
|
287
|
-
<
|
|
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 & 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 & 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 & 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
|
+
}
|