@actuate-media/cms-admin 0.24.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/__tests__/views/page-builder-block-renderer.render.test.d.ts +2 -0
- package/dist/__tests__/views/page-builder-block-renderer.render.test.d.ts.map +1 -0
- package/dist/__tests__/views/page-builder-block-renderer.render.test.js +48 -0
- package/dist/__tests__/views/page-builder-block-renderer.render.test.js.map +1 -0
- package/dist/__tests__/views/page-builder-form-picker.render.test.d.ts +2 -0
- package/dist/__tests__/views/page-builder-form-picker.render.test.d.ts.map +1 -0
- package/dist/__tests__/views/page-builder-form-picker.render.test.js +44 -0
- package/dist/__tests__/views/page-builder-form-picker.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/page-builder/BlockEditor.d.ts.map +1 -1
- package/dist/views/page-builder/BlockEditor.js +2 -1
- package/dist/views/page-builder/BlockEditor.js.map +1 -1
- package/dist/views/page-builder/RelationshipField.d.ts +15 -0
- package/dist/views/page-builder/RelationshipField.d.ts.map +1 -0
- package/dist/views/page-builder/RelationshipField.js +65 -0
- package/dist/views/page-builder/RelationshipField.js.map +1 -0
- package/dist/views/page-builder/block-renderers/FormPreview.d.ts +6 -0
- package/dist/views/page-builder/block-renderers/FormPreview.d.ts.map +1 -1
- package/dist/views/page-builder/block-renderers/FormPreview.js +44 -6
- package/dist/views/page-builder/block-renderers/FormPreview.js.map +1 -1
- package/dist/views/page-builder/canvas/BlockRenderer.d.ts.map +1 -1
- package/dist/views/page-builder/canvas/BlockRenderer.js +3 -9
- package/dist/views/page-builder/canvas/BlockRenderer.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/__tests__/views/page-builder-block-renderer.render.test.tsx +66 -0
- package/src/__tests__/views/page-builder-form-picker.render.test.tsx +54 -0
- package/src/views/Settings.tsx +17 -120
- package/src/views/page-builder/BlockEditor.tsx +7 -10
- package/src/views/page-builder/RelationshipField.tsx +113 -0
- package/src/views/page-builder/block-renderers/FormPreview.tsx +85 -33
- package/src/views/page-builder/canvas/BlockRenderer.tsx +10 -12
- 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
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import * as Dialog from '@radix-ui/react-dialog'
|
|
4
|
+
import { AlertTriangle, Check, Loader2, Lock } from 'lucide-react'
|
|
5
|
+
import type { ReactNode } from 'react'
|
|
6
|
+
import type { SettingsSourceMetadata, ValidationIssue } from './useGeneralSettings.js'
|
|
7
|
+
|
|
8
|
+
const SOURCE_LABEL: Record<SettingsSourceMetadata['source'], string> = {
|
|
9
|
+
config: 'Config-managed',
|
|
10
|
+
environment: 'Environment-managed',
|
|
11
|
+
database: 'Editable',
|
|
12
|
+
plugin: 'Plugin setting',
|
|
13
|
+
computed: 'Computed',
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/** Small badge showing where a setting's value comes from. */
|
|
17
|
+
export function SettingsSourceBadge({ meta }: { meta?: SettingsSourceMetadata }) {
|
|
18
|
+
if (!meta || (meta.source === 'database' && meta.editable)) return null
|
|
19
|
+
const locked = !meta.editable
|
|
20
|
+
return (
|
|
21
|
+
<span
|
|
22
|
+
className="text-muted-foreground border-border bg-muted inline-flex items-center gap-1 rounded-md border px-1.5 py-0.5 text-xs"
|
|
23
|
+
title={meta.reason}
|
|
24
|
+
>
|
|
25
|
+
{locked && <Lock size={11} aria-hidden="true" />}
|
|
26
|
+
{SOURCE_LABEL[meta.source]}
|
|
27
|
+
</span>
|
|
28
|
+
)
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** A labelled switch row with description, used for the SEO defaults. */
|
|
32
|
+
export function SettingsToggleRow({
|
|
33
|
+
label,
|
|
34
|
+
description,
|
|
35
|
+
checked,
|
|
36
|
+
onChange,
|
|
37
|
+
disabled = false,
|
|
38
|
+
badge,
|
|
39
|
+
}: {
|
|
40
|
+
label: string
|
|
41
|
+
description: string
|
|
42
|
+
checked: boolean
|
|
43
|
+
onChange: (value: boolean) => void
|
|
44
|
+
disabled?: boolean
|
|
45
|
+
badge?: ReactNode
|
|
46
|
+
}) {
|
|
47
|
+
return (
|
|
48
|
+
<div className="flex items-start justify-between gap-4">
|
|
49
|
+
<div className="flex-1">
|
|
50
|
+
<div className="flex items-center gap-2">
|
|
51
|
+
<span className="text-foreground text-base font-medium">{label}</span>
|
|
52
|
+
{badge}
|
|
53
|
+
</div>
|
|
54
|
+
<p className="text-muted-foreground mt-0.5 text-sm">{description}</p>
|
|
55
|
+
</div>
|
|
56
|
+
<button
|
|
57
|
+
type="button"
|
|
58
|
+
role="switch"
|
|
59
|
+
aria-checked={checked}
|
|
60
|
+
aria-label={label}
|
|
61
|
+
disabled={disabled}
|
|
62
|
+
onClick={() => onChange(!checked)}
|
|
63
|
+
className={`focus-visible:ring-ring relative mt-0.5 h-6 w-11 shrink-0 rounded-full transition-colors focus-visible:ring-2 focus-visible:outline-none disabled:cursor-not-allowed disabled:opacity-50 ${
|
|
64
|
+
checked ? 'bg-brand' : 'bg-switch-background'
|
|
65
|
+
}`}
|
|
66
|
+
>
|
|
67
|
+
<span
|
|
68
|
+
className={`absolute top-0.5 block h-5 w-5 rounded-full bg-white shadow-sm transition-transform ${
|
|
69
|
+
checked ? 'translate-x-[22px]' : 'translate-x-0.5'
|
|
70
|
+
}`}
|
|
71
|
+
/>
|
|
72
|
+
</button>
|
|
73
|
+
</div>
|
|
74
|
+
)
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** Aggregated validation messages shown above the cards. */
|
|
78
|
+
export function SettingsValidationSummary({ issues }: { issues: ValidationIssue[] }) {
|
|
79
|
+
if (issues.length === 0) return null
|
|
80
|
+
const errors = issues.filter((i) => i.severity === 'error')
|
|
81
|
+
const warnings = issues.filter((i) => i.severity === 'warning')
|
|
82
|
+
const isError = errors.length > 0
|
|
83
|
+
return (
|
|
84
|
+
<div
|
|
85
|
+
role="alert"
|
|
86
|
+
aria-live="polite"
|
|
87
|
+
className={`flex items-start gap-3 rounded-lg border p-3 text-sm ${
|
|
88
|
+
isError
|
|
89
|
+
? 'border-destructive/30 bg-destructive/10 text-destructive'
|
|
90
|
+
: 'border-warning/30 bg-warning/10 text-warning'
|
|
91
|
+
}`}
|
|
92
|
+
>
|
|
93
|
+
<AlertTriangle size={16} className="mt-0.5 shrink-0" aria-hidden="true" />
|
|
94
|
+
<div className="space-y-1">
|
|
95
|
+
{[...errors, ...warnings].map((issue, i) => (
|
|
96
|
+
<p key={`${issue.field}-${i}`}>{issue.message}</p>
|
|
97
|
+
))}
|
|
98
|
+
</div>
|
|
99
|
+
</div>
|
|
100
|
+
)
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** Sticky footer with Reset + Save, gated on dirty state. */
|
|
104
|
+
export function SettingsSaveBar({
|
|
105
|
+
dirty,
|
|
106
|
+
canEdit,
|
|
107
|
+
hasErrors,
|
|
108
|
+
saveState,
|
|
109
|
+
onSave,
|
|
110
|
+
onReset,
|
|
111
|
+
}: {
|
|
112
|
+
dirty: boolean
|
|
113
|
+
canEdit: boolean
|
|
114
|
+
hasErrors: boolean
|
|
115
|
+
saveState: 'idle' | 'saving' | 'success' | 'error'
|
|
116
|
+
onSave: () => void
|
|
117
|
+
onReset: () => void
|
|
118
|
+
}) {
|
|
119
|
+
const saving = saveState === 'saving'
|
|
120
|
+
return (
|
|
121
|
+
<div className="border-border bg-background/80 sticky bottom-0 z-10 flex items-center justify-end gap-4 border-t py-3 backdrop-blur">
|
|
122
|
+
{saveState === 'success' && !dirty && (
|
|
123
|
+
<span className="text-success flex items-center gap-1.5 text-sm" aria-live="polite">
|
|
124
|
+
<Check size={16} aria-hidden="true" /> Saved
|
|
125
|
+
</span>
|
|
126
|
+
)}
|
|
127
|
+
<button
|
|
128
|
+
type="button"
|
|
129
|
+
onClick={onReset}
|
|
130
|
+
disabled={!dirty || saving}
|
|
131
|
+
className="text-muted-foreground hover:text-foreground rounded-md px-3 py-2 text-base transition-colors disabled:cursor-not-allowed disabled:opacity-40"
|
|
132
|
+
>
|
|
133
|
+
Reset
|
|
134
|
+
</button>
|
|
135
|
+
<button
|
|
136
|
+
type="button"
|
|
137
|
+
onClick={onSave}
|
|
138
|
+
disabled={!canEdit || !dirty || hasErrors || saving}
|
|
139
|
+
className="bg-brand text-brand-foreground focus-visible:ring-ring inline-flex items-center gap-2 rounded-md px-5 py-2 text-base font-medium transition-opacity hover:opacity-90 focus-visible:ring-2 focus-visible:outline-none disabled:cursor-not-allowed disabled:opacity-50"
|
|
140
|
+
>
|
|
141
|
+
{saving && <Loader2 size={16} className="animate-spin" aria-hidden="true" />}
|
|
142
|
+
{saving ? 'Saving…' : 'Save Changes'}
|
|
143
|
+
</button>
|
|
144
|
+
</div>
|
|
145
|
+
)
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/** Confirmation dialog for dangerous indexing changes. */
|
|
149
|
+
export function ConfirmDangerousSettingDialog({
|
|
150
|
+
open,
|
|
151
|
+
title,
|
|
152
|
+
description,
|
|
153
|
+
confirmLabel = 'Continue',
|
|
154
|
+
onConfirm,
|
|
155
|
+
onCancel,
|
|
156
|
+
}: {
|
|
157
|
+
open: boolean
|
|
158
|
+
title: string
|
|
159
|
+
description: string
|
|
160
|
+
confirmLabel?: string
|
|
161
|
+
onConfirm: () => void
|
|
162
|
+
onCancel: () => void
|
|
163
|
+
}) {
|
|
164
|
+
return (
|
|
165
|
+
<Dialog.Root open={open} onOpenChange={(o) => !o && onCancel()}>
|
|
166
|
+
<Dialog.Portal>
|
|
167
|
+
<Dialog.Overlay className="fixed inset-0 z-50 bg-black/50" />
|
|
168
|
+
<Dialog.Content className="bg-card text-card-foreground fixed top-1/2 left-1/2 z-50 w-full max-w-md -translate-x-1/2 -translate-y-1/2 rounded-lg p-6 shadow-lg">
|
|
169
|
+
<div className="mb-3 flex items-center gap-2">
|
|
170
|
+
<span className="bg-warning/15 text-warning flex h-9 w-9 items-center justify-center rounded-full">
|
|
171
|
+
<AlertTriangle size={20} aria-hidden="true" />
|
|
172
|
+
</span>
|
|
173
|
+
<Dialog.Title className="text-foreground text-lg font-medium">{title}</Dialog.Title>
|
|
174
|
+
</div>
|
|
175
|
+
<Dialog.Description className="text-muted-foreground mb-5 text-sm">
|
|
176
|
+
{description}
|
|
177
|
+
</Dialog.Description>
|
|
178
|
+
<div className="flex items-center justify-end gap-3">
|
|
179
|
+
<button
|
|
180
|
+
type="button"
|
|
181
|
+
onClick={onCancel}
|
|
182
|
+
className="border-border text-foreground hover:bg-accent rounded-md border px-4 py-2 text-base transition-colors"
|
|
183
|
+
>
|
|
184
|
+
Cancel
|
|
185
|
+
</button>
|
|
186
|
+
<button
|
|
187
|
+
type="button"
|
|
188
|
+
onClick={onConfirm}
|
|
189
|
+
className="bg-destructive text-destructive-foreground rounded-md px-4 py-2 text-base font-medium transition-opacity hover:opacity-90"
|
|
190
|
+
>
|
|
191
|
+
{confirmLabel}
|
|
192
|
+
</button>
|
|
193
|
+
</div>
|
|
194
|
+
</Dialog.Content>
|
|
195
|
+
</Dialog.Portal>
|
|
196
|
+
</Dialog.Root>
|
|
197
|
+
)
|
|
198
|
+
}
|
|
@@ -0,0 +1,376 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import { useCallback, useEffect, useMemo, useState } from 'react'
|
|
4
|
+
import { cmsApi } from '../../lib/api.js'
|
|
5
|
+
|
|
6
|
+
/** Editable + computed shape of the General tab form. */
|
|
7
|
+
export interface GeneralSettingsForm {
|
|
8
|
+
siteTitle: string
|
|
9
|
+
tagline: string
|
|
10
|
+
siteUrl: string
|
|
11
|
+
language: string
|
|
12
|
+
timezone: string
|
|
13
|
+
defaultNoIndex: boolean
|
|
14
|
+
defaultNoFollow: boolean
|
|
15
|
+
noIndexNonProduction: boolean
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export type SettingsSource = 'config' | 'environment' | 'database' | 'plugin' | 'computed'
|
|
19
|
+
|
|
20
|
+
export interface SettingsSourceMetadata {
|
|
21
|
+
source: SettingsSource
|
|
22
|
+
editable: boolean
|
|
23
|
+
reason?: string
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export type IndexStatusLevel =
|
|
27
|
+
| 'indexable'
|
|
28
|
+
| 'protected'
|
|
29
|
+
| 'blocked'
|
|
30
|
+
| 'partially_blocked'
|
|
31
|
+
| 'unknown'
|
|
32
|
+
|
|
33
|
+
export interface SiteIndexStatus {
|
|
34
|
+
status: IndexStatusLevel
|
|
35
|
+
severity: 'success' | 'warning' | 'danger' | 'neutral'
|
|
36
|
+
label: string
|
|
37
|
+
description: string
|
|
38
|
+
environment: string
|
|
39
|
+
checks: Array<{
|
|
40
|
+
key: string
|
|
41
|
+
label: string
|
|
42
|
+
value: string
|
|
43
|
+
status: 'pass' | 'warning' | 'fail' | 'unknown'
|
|
44
|
+
}>
|
|
45
|
+
actions: Array<{ label: string; href?: string; action?: string }>
|
|
46
|
+
lastAuditAt: string | null
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export interface ValidationIssue {
|
|
50
|
+
field: keyof GeneralSettingsForm
|
|
51
|
+
message: string
|
|
52
|
+
severity: 'error' | 'warning'
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const EMPTY_FORM: GeneralSettingsForm = {
|
|
56
|
+
siteTitle: '',
|
|
57
|
+
tagline: '',
|
|
58
|
+
siteUrl: '',
|
|
59
|
+
language: 'en',
|
|
60
|
+
timezone: 'UTC',
|
|
61
|
+
defaultNoIndex: false,
|
|
62
|
+
defaultNoFollow: false,
|
|
63
|
+
noIndexNonProduction: false,
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** Validate a site URL: absolute http(s), no trailing slash, parseable. */
|
|
67
|
+
export function validateSiteUrl(value: string): { valid: boolean; error?: string } {
|
|
68
|
+
const trimmed = value.trim()
|
|
69
|
+
if (!trimmed) return { valid: true }
|
|
70
|
+
let url: URL
|
|
71
|
+
try {
|
|
72
|
+
url = new URL(trimmed)
|
|
73
|
+
} catch {
|
|
74
|
+
return { valid: false, error: 'Enter a valid absolute URL, e.g. https://example.com' }
|
|
75
|
+
}
|
|
76
|
+
if (url.protocol !== 'http:' && url.protocol !== 'https:') {
|
|
77
|
+
return { valid: false, error: 'Site URL must start with http:// or https://' }
|
|
78
|
+
}
|
|
79
|
+
if (trimmed.endsWith('/')) {
|
|
80
|
+
return { valid: false, error: 'Remove the trailing slash (e.g. https://example.com)' }
|
|
81
|
+
}
|
|
82
|
+
return { valid: true }
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** Best-effort IANA timezone check using the Intl API. */
|
|
86
|
+
export function isValidTimeZone(tz: string): boolean {
|
|
87
|
+
if (!tz) return false
|
|
88
|
+
try {
|
|
89
|
+
new Intl.DateTimeFormat('en-US', { timeZone: tz })
|
|
90
|
+
return true
|
|
91
|
+
} catch {
|
|
92
|
+
return false
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** Best-effort BCP 47 locale check. */
|
|
97
|
+
export function isValidLocale(locale: string): boolean {
|
|
98
|
+
if (!locale) return false
|
|
99
|
+
try {
|
|
100
|
+
// Throws RangeError on structurally invalid tags.
|
|
101
|
+
new Intl.Locale(locale)
|
|
102
|
+
return /^[a-zA-Z]{2,3}([-_][a-zA-Z0-9]{2,8})*$/.test(locale)
|
|
103
|
+
} catch {
|
|
104
|
+
return false
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function validate(form: GeneralSettingsForm, isProduction: boolean): ValidationIssue[] {
|
|
109
|
+
const issues: ValidationIssue[] = []
|
|
110
|
+
if (!form.siteTitle.trim()) {
|
|
111
|
+
issues.push({ field: 'siteTitle', message: 'Site Title is required.', severity: 'error' })
|
|
112
|
+
}
|
|
113
|
+
const urlCheck = validateSiteUrl(form.siteUrl)
|
|
114
|
+
if (!urlCheck.valid) {
|
|
115
|
+
issues.push({ field: 'siteUrl', message: urlCheck.error!, severity: 'error' })
|
|
116
|
+
} else if (!form.siteUrl.trim() && isProduction) {
|
|
117
|
+
issues.push({
|
|
118
|
+
field: 'siteUrl',
|
|
119
|
+
message: 'Site URL is not set — canonical, sitemap, and Open Graph URLs may be invalid.',
|
|
120
|
+
severity: 'warning',
|
|
121
|
+
})
|
|
122
|
+
}
|
|
123
|
+
if (!isValidLocale(form.language)) {
|
|
124
|
+
issues.push({
|
|
125
|
+
field: 'language',
|
|
126
|
+
message: 'Language must be a valid locale code, e.g. en-US.',
|
|
127
|
+
severity: 'error',
|
|
128
|
+
})
|
|
129
|
+
}
|
|
130
|
+
if (!isValidTimeZone(form.timezone)) {
|
|
131
|
+
issues.push({
|
|
132
|
+
field: 'timezone',
|
|
133
|
+
message: 'Timezone must be a valid IANA name, e.g. America/Los_Angeles.',
|
|
134
|
+
severity: 'error',
|
|
135
|
+
})
|
|
136
|
+
}
|
|
137
|
+
return issues
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
interface SeoConfigResponse {
|
|
141
|
+
static?: { site?: Record<string, any> | null }
|
|
142
|
+
overrides?: { site?: Record<string, any> | null } | null
|
|
143
|
+
effective?: { site?: Record<string, any> | null }
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
type SaveState = 'idle' | 'saving' | 'success' | 'error'
|
|
147
|
+
|
|
148
|
+
export interface UseGeneralSettings {
|
|
149
|
+
loading: boolean
|
|
150
|
+
loadError: string | null
|
|
151
|
+
form: GeneralSettingsForm
|
|
152
|
+
setField: <K extends keyof GeneralSettingsForm>(key: K, value: GeneralSettingsForm[K]) => void
|
|
153
|
+
dirty: boolean
|
|
154
|
+
issues: ValidationIssue[]
|
|
155
|
+
hasErrors: boolean
|
|
156
|
+
indexStatus: SiteIndexStatus | null
|
|
157
|
+
sources: Record<string, SettingsSourceMetadata>
|
|
158
|
+
isProduction: boolean
|
|
159
|
+
saveState: SaveState
|
|
160
|
+
saveError: string | null
|
|
161
|
+
save: () => Promise<void>
|
|
162
|
+
reset: () => void
|
|
163
|
+
refetch: () => void
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* Loads the General tab from its three real backends — the `settings` global
|
|
168
|
+
* (site info / locale), the shared `/seo/config` (site URL + robots defaults),
|
|
169
|
+
* and the computed `/seo/index-status` — tracks a dirty diff, and on save
|
|
170
|
+
* writes each field back to its OWNING store (never a duplicate), preserving
|
|
171
|
+
* unrelated keys.
|
|
172
|
+
*/
|
|
173
|
+
export function useGeneralSettings(canEdit: boolean): UseGeneralSettings {
|
|
174
|
+
const [loading, setLoading] = useState(true)
|
|
175
|
+
const [loadError, setLoadError] = useState<string | null>(null)
|
|
176
|
+
const [baseline, setBaseline] = useState<GeneralSettingsForm>(EMPTY_FORM)
|
|
177
|
+
const [form, setForm] = useState<GeneralSettingsForm>(EMPTY_FORM)
|
|
178
|
+
const [indexStatus, setIndexStatus] = useState<SiteIndexStatus | null>(null)
|
|
179
|
+
const [sources, setSources] = useState<Record<string, SettingsSourceMetadata>>({})
|
|
180
|
+
// Full settings-global blob so a partial save never wipes AI/security keys.
|
|
181
|
+
const [settingsBlob, setSettingsBlob] = useState<Record<string, unknown>>({})
|
|
182
|
+
const [overrideSite, setOverrideSite] = useState<Record<string, any>>({})
|
|
183
|
+
const [saveState, setSaveState] = useState<SaveState>('idle')
|
|
184
|
+
const [saveError, setSaveError] = useState<string | null>(null)
|
|
185
|
+
const [reloadKey, setReloadKey] = useState(0)
|
|
186
|
+
|
|
187
|
+
useEffect(() => {
|
|
188
|
+
let cancelled = false
|
|
189
|
+
setLoading(true)
|
|
190
|
+
setLoadError(null)
|
|
191
|
+
Promise.all([
|
|
192
|
+
cmsApi<Record<string, any>>('/globals/settings'),
|
|
193
|
+
cmsApi<SeoConfigResponse>('/seo/config'),
|
|
194
|
+
cmsApi<SiteIndexStatus>('/seo/index-status'),
|
|
195
|
+
])
|
|
196
|
+
.then(([settingsRes, seoRes, statusRes]) => {
|
|
197
|
+
if (cancelled) return
|
|
198
|
+
if (settingsRes.error && settingsRes.status !== 404) {
|
|
199
|
+
setLoadError(settingsRes.error)
|
|
200
|
+
}
|
|
201
|
+
const settingsData = settingsRes.data ?? {}
|
|
202
|
+
const seo = seoRes.data ?? {}
|
|
203
|
+
const effectiveSite = seo.effective?.site ?? {}
|
|
204
|
+
const staticSite = seo.static?.site ?? {}
|
|
205
|
+
const overSite = seo.overrides?.site ?? {}
|
|
206
|
+
const robots = effectiveSite.robots ?? {}
|
|
207
|
+
|
|
208
|
+
const loaded: GeneralSettingsForm = {
|
|
209
|
+
siteTitle: String(settingsData.siteTitle ?? ''),
|
|
210
|
+
tagline: String(settingsData.tagline ?? ''),
|
|
211
|
+
siteUrl: String(effectiveSite.siteUrl ?? settingsData.siteUrl ?? ''),
|
|
212
|
+
language: String(settingsData.language ?? 'en'),
|
|
213
|
+
timezone: String(settingsData.timezone ?? 'UTC'),
|
|
214
|
+
defaultNoIndex: robots.defaultNoIndex === true,
|
|
215
|
+
defaultNoFollow: robots.defaultNoFollow === true,
|
|
216
|
+
noIndexNonProduction: robots.noIndexNonProduction === true,
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
// Site URL is config-managed when actuate.config.ts supplies it.
|
|
220
|
+
const siteUrlFromConfig = Boolean(staticSite.siteUrl)
|
|
221
|
+
setSources({
|
|
222
|
+
siteUrl: siteUrlFromConfig
|
|
223
|
+
? { source: 'config', editable: false, reason: 'Set in actuate.config.ts' }
|
|
224
|
+
: { source: 'database', editable: canEdit },
|
|
225
|
+
siteTitle: { source: 'database', editable: canEdit },
|
|
226
|
+
tagline: { source: 'database', editable: canEdit },
|
|
227
|
+
language: { source: 'database', editable: canEdit },
|
|
228
|
+
timezone: { source: 'database', editable: canEdit },
|
|
229
|
+
seoDefaults: {
|
|
230
|
+
source: 'plugin',
|
|
231
|
+
editable: canEdit,
|
|
232
|
+
reason: 'Shared with the SEO section',
|
|
233
|
+
},
|
|
234
|
+
})
|
|
235
|
+
|
|
236
|
+
setBaseline(loaded)
|
|
237
|
+
setForm(loaded)
|
|
238
|
+
setSettingsBlob(settingsData)
|
|
239
|
+
setOverrideSite(overSite ?? {})
|
|
240
|
+
if (!seoRes.error && statusRes.data) setIndexStatus(statusRes.data)
|
|
241
|
+
})
|
|
242
|
+
.catch(() => {
|
|
243
|
+
if (!cancelled) setLoadError('Failed to load settings.')
|
|
244
|
+
})
|
|
245
|
+
.finally(() => {
|
|
246
|
+
if (!cancelled) setLoading(false)
|
|
247
|
+
})
|
|
248
|
+
return () => {
|
|
249
|
+
cancelled = true
|
|
250
|
+
}
|
|
251
|
+
}, [reloadKey, canEdit])
|
|
252
|
+
|
|
253
|
+
const setField = useCallback(
|
|
254
|
+
<K extends keyof GeneralSettingsForm>(key: K, value: GeneralSettingsForm[K]) => {
|
|
255
|
+
setForm((prev) => ({ ...prev, [key]: value }))
|
|
256
|
+
setSaveState('idle')
|
|
257
|
+
},
|
|
258
|
+
[],
|
|
259
|
+
)
|
|
260
|
+
|
|
261
|
+
const isProduction = (indexStatus?.environment ?? '') === 'production'
|
|
262
|
+
|
|
263
|
+
const dirty = useMemo(
|
|
264
|
+
() =>
|
|
265
|
+
(Object.keys(form) as Array<keyof GeneralSettingsForm>).some((k) => form[k] !== baseline[k]),
|
|
266
|
+
[form, baseline],
|
|
267
|
+
)
|
|
268
|
+
|
|
269
|
+
const issues = useMemo(() => validate(form, isProduction), [form, isProduction])
|
|
270
|
+
const hasErrors = issues.some((i) => i.severity === 'error')
|
|
271
|
+
|
|
272
|
+
const refetchStatus = useCallback(async () => {
|
|
273
|
+
const res = await cmsApi<SiteIndexStatus>('/seo/index-status')
|
|
274
|
+
if (!res.error && res.data) setIndexStatus(res.data)
|
|
275
|
+
}, [])
|
|
276
|
+
|
|
277
|
+
const save = useCallback(async () => {
|
|
278
|
+
if (!canEdit || hasErrors) return
|
|
279
|
+
setSaveState('saving')
|
|
280
|
+
setSaveError(null)
|
|
281
|
+
|
|
282
|
+
const siteUrlEditable = sources.siteUrl?.editable !== false
|
|
283
|
+
const siteInfoChanged =
|
|
284
|
+
form.siteTitle !== baseline.siteTitle ||
|
|
285
|
+
form.tagline !== baseline.tagline ||
|
|
286
|
+
form.language !== baseline.language ||
|
|
287
|
+
form.timezone !== baseline.timezone
|
|
288
|
+
const siteUrlChanged = siteUrlEditable && form.siteUrl !== baseline.siteUrl
|
|
289
|
+
const robotsChanged =
|
|
290
|
+
form.defaultNoIndex !== baseline.defaultNoIndex ||
|
|
291
|
+
form.defaultNoFollow !== baseline.defaultNoFollow ||
|
|
292
|
+
form.noIndexNonProduction !== baseline.noIndexNonProduction
|
|
293
|
+
|
|
294
|
+
try {
|
|
295
|
+
// 1. Site info → the `settings` global (preserve AI/security/layout keys,
|
|
296
|
+
// and drop legacy robots/siteUrl duplicates so SEO config owns them).
|
|
297
|
+
// Read-modify-write against a fresh fetch so a concurrent save from the
|
|
298
|
+
// Security/AI tabs (same global blob) is never clobbered.
|
|
299
|
+
if (siteInfoChanged) {
|
|
300
|
+
const current = await cmsApi<Record<string, unknown>>('/globals/settings')
|
|
301
|
+
const blob = { ...(current.data ?? settingsBlob) }
|
|
302
|
+
delete blob.defaultNoIndex
|
|
303
|
+
delete blob.defaultNoFollow
|
|
304
|
+
delete blob.noIndexNonProduction
|
|
305
|
+
delete blob.siteUrl
|
|
306
|
+
const payload = {
|
|
307
|
+
...blob,
|
|
308
|
+
siteTitle: form.siteTitle.trim(),
|
|
309
|
+
tagline: form.tagline.trim(),
|
|
310
|
+
language: form.language,
|
|
311
|
+
timezone: form.timezone,
|
|
312
|
+
}
|
|
313
|
+
const res = await cmsApi('/globals/settings', {
|
|
314
|
+
method: 'PUT',
|
|
315
|
+
body: JSON.stringify(payload),
|
|
316
|
+
})
|
|
317
|
+
if (res.error) throw new Error(res.error)
|
|
318
|
+
setSettingsBlob(payload)
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
// 2. Site URL + robots defaults → the SHARED SEO config (merge over the
|
|
322
|
+
// current override site so SEO-tab fields aren't lost).
|
|
323
|
+
if (siteUrlChanged || robotsChanged) {
|
|
324
|
+
const nextSite: Record<string, any> = { ...overrideSite }
|
|
325
|
+
if (siteUrlChanged) nextSite.siteUrl = form.siteUrl.trim() || undefined
|
|
326
|
+
if (robotsChanged) {
|
|
327
|
+
nextSite.robots = {
|
|
328
|
+
...(overrideSite.robots ?? {}),
|
|
329
|
+
defaultNoIndex: form.defaultNoIndex,
|
|
330
|
+
defaultNoFollow: form.defaultNoFollow,
|
|
331
|
+
noIndexNonProduction: form.noIndexNonProduction,
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
const res = await cmsApi('/seo/config', {
|
|
335
|
+
method: 'PUT',
|
|
336
|
+
body: JSON.stringify({ site: nextSite }),
|
|
337
|
+
})
|
|
338
|
+
if (res.error) throw new Error(res.error)
|
|
339
|
+
setOverrideSite(nextSite)
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
setBaseline(form)
|
|
343
|
+
setSaveState('success')
|
|
344
|
+
await refetchStatus()
|
|
345
|
+
} catch (err) {
|
|
346
|
+
setSaveState('error')
|
|
347
|
+
setSaveError(err instanceof Error ? err.message : 'Failed to save settings.')
|
|
348
|
+
}
|
|
349
|
+
}, [canEdit, hasErrors, form, baseline, settingsBlob, overrideSite, sources, refetchStatus])
|
|
350
|
+
|
|
351
|
+
const reset = useCallback(() => {
|
|
352
|
+
setForm(baseline)
|
|
353
|
+
setSaveState('idle')
|
|
354
|
+
setSaveError(null)
|
|
355
|
+
}, [baseline])
|
|
356
|
+
|
|
357
|
+
const refetch = useCallback(() => setReloadKey((k) => k + 1), [])
|
|
358
|
+
|
|
359
|
+
return {
|
|
360
|
+
loading,
|
|
361
|
+
loadError,
|
|
362
|
+
form,
|
|
363
|
+
setField,
|
|
364
|
+
dirty,
|
|
365
|
+
issues,
|
|
366
|
+
hasErrors,
|
|
367
|
+
indexStatus,
|
|
368
|
+
sources,
|
|
369
|
+
isProduction,
|
|
370
|
+
saveState,
|
|
371
|
+
saveError,
|
|
372
|
+
save,
|
|
373
|
+
reset,
|
|
374
|
+
refetch,
|
|
375
|
+
}
|
|
376
|
+
}
|