@prenta/admin 1.10.1 → 1.12.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/CHANGELOG.md +32 -0
- package/dist/AdminRoot.d.ts.map +1 -1
- package/dist/AdminRoot.js +3 -1
- package/dist/AdminRoot.js.map +1 -1
- package/dist/__tests__/components/seo-editor-pane.render.test.js +15 -0
- package/dist/__tests__/components/seo-editor-pane.render.test.js.map +1 -1
- package/dist/__tests__/lib/page-editor-service.test.js +23 -0
- package/dist/__tests__/lib/page-editor-service.test.js.map +1 -1
- package/dist/__tests__/lib/seo-service-errors.test.js +54 -1
- package/dist/__tests__/lib/seo-service-errors.test.js.map +1 -1
- package/dist/__tests__/views/ai-settings.render.test.js +26 -8
- package/dist/__tests__/views/ai-settings.render.test.js.map +1 -1
- package/dist/__tests__/views/redirects-pane.render.test.js +20 -2
- package/dist/__tests__/views/redirects-pane.render.test.js.map +1 -1
- package/dist/__tests__/views/seo-settings.render.test.js +40 -2
- package/dist/__tests__/views/seo-settings.render.test.js.map +1 -1
- package/dist/components/seo/SeoCopilotDrawer.d.ts.map +1 -1
- package/dist/components/seo/SeoCopilotDrawer.js +4 -0
- package/dist/components/seo/SeoCopilotDrawer.js.map +1 -1
- package/dist/components/seo/SeoEditorPane.d.ts.map +1 -1
- package/dist/components/seo/SeoEditorPane.js +8 -1
- package/dist/components/seo/SeoEditorPane.js.map +1 -1
- package/dist/components/seo/SeoIssueFixDrawer.d.ts.map +1 -1
- package/dist/components/seo/SeoIssueFixDrawer.js +4 -0
- package/dist/components/seo/SeoIssueFixDrawer.js.map +1 -1
- package/dist/components/seo/SeoSearchStats.d.ts +4 -0
- package/dist/components/seo/SeoSearchStats.d.ts.map +1 -0
- package/dist/components/seo/SeoSearchStats.js +91 -0
- package/dist/components/seo/SeoSearchStats.js.map +1 -0
- package/dist/lib/editor-seo-content.d.ts.map +1 -1
- package/dist/lib/editor-seo-content.js +2 -2
- package/dist/lib/editor-seo-content.js.map +1 -1
- package/dist/lib/page-editor-service.d.ts.map +1 -1
- package/dist/lib/page-editor-service.js +2 -2
- package/dist/lib/page-editor-service.js.map +1 -1
- package/dist/lib/post-editor-service.d.ts.map +1 -1
- package/dist/lib/post-editor-service.js +3 -3
- package/dist/lib/post-editor-service.js.map +1 -1
- package/dist/lib/seo-service.d.ts +41 -0
- package/dist/lib/seo-service.d.ts.map +1 -1
- package/dist/lib/seo-service.js +22 -1
- package/dist/lib/seo-service.js.map +1 -1
- package/dist/prenta-admin.css +1 -1
- package/dist/views/page-editor/SectionInspector.d.ts.map +1 -1
- package/dist/views/page-editor/SectionInspector.js +3 -2
- package/dist/views/page-editor/SectionInspector.js.map +1 -1
- package/dist/views/page-editor/SectionsPanel.d.ts.map +1 -1
- package/dist/views/page-editor/SectionsPanel.js +5 -1
- package/dist/views/page-editor/SectionsPanel.js.map +1 -1
- package/dist/views/page-editor/section-types.d.ts +1 -1
- package/dist/views/page-editor/section-types.d.ts.map +1 -1
- package/dist/views/page-editor/section-types.js +1 -1
- package/dist/views/page-editor/section-types.js.map +1 -1
- package/dist/views/page-editor/sections/SectionRenderer.js +2 -2
- package/dist/views/page-editor/sections/SectionRenderer.js.map +1 -1
- package/dist/views/seo/OverviewTab.js +1 -1
- package/dist/views/seo/OverviewTab.js.map +1 -1
- package/dist/views/seo/RedirectsTab.d.ts.map +1 -1
- package/dist/views/seo/RedirectsTab.js +6 -0
- package/dist/views/seo/RedirectsTab.js.map +1 -1
- package/dist/views/settings/AISettingsTab.d.ts.map +1 -1
- package/dist/views/settings/AISettingsTab.js +1 -1
- package/dist/views/settings/AISettingsTab.js.map +1 -1
- package/dist/views/settings/AiFeaturesCard.d.ts +8 -3
- package/dist/views/settings/AiFeaturesCard.d.ts.map +1 -1
- package/dist/views/settings/AiFeaturesCard.js +7 -6
- package/dist/views/settings/AiFeaturesCard.js.map +1 -1
- package/dist/views/settings/SearchDataCard.d.ts +10 -0
- package/dist/views/settings/SearchDataCard.d.ts.map +1 -0
- package/dist/views/settings/SearchDataCard.js +126 -0
- package/dist/views/settings/SearchDataCard.js.map +1 -0
- package/dist/views/settings/SeoSettingsTab.d.ts.map +1 -1
- package/dist/views/settings/SeoSettingsTab.js +2 -1
- package/dist/views/settings/SeoSettingsTab.js.map +1 -1
- package/dist/views/settings/useAiSettings.d.ts +12 -0
- package/dist/views/settings/useAiSettings.d.ts.map +1 -1
- package/dist/views/settings/useAiSettings.js +29 -1
- package/dist/views/settings/useAiSettings.js.map +1 -1
- package/package.json +3 -3
- package/src/AdminRoot.tsx +4 -0
- package/src/__tests__/components/seo-editor-pane.render.test.tsx +25 -3
- package/src/__tests__/lib/page-editor-service.test.ts +23 -0
- package/src/__tests__/lib/seo-service-errors.test.ts +73 -0
- package/src/__tests__/views/ai-settings.render.test.tsx +38 -8
- package/src/__tests__/views/redirects-pane.render.test.tsx +28 -2
- package/src/__tests__/views/seo-settings.render.test.tsx +55 -2
- package/src/components/seo/SeoCopilotDrawer.tsx +4 -0
- package/src/components/seo/SeoEditorPane.tsx +13 -0
- package/src/components/seo/SeoIssueFixDrawer.tsx +4 -0
- package/src/components/seo/SeoSearchStats.tsx +183 -0
- package/src/lib/editor-seo-content.ts +6 -2
- package/src/lib/page-editor-service.ts +2 -1
- package/src/lib/post-editor-service.ts +5 -2
- package/src/lib/seo-service.ts +64 -2
- package/src/views/page-editor/SectionInspector.tsx +23 -20
- package/src/views/page-editor/SectionsPanel.tsx +27 -17
- package/src/views/page-editor/section-types.ts +2 -0
- package/src/views/page-editor/sections/SectionRenderer.tsx +2 -2
- package/src/views/seo/OverviewTab.tsx +1 -1
- package/src/views/seo/RedirectsTab.tsx +6 -0
- package/src/views/settings/AISettingsTab.tsx +1 -0
- package/src/views/settings/AiFeaturesCard.tsx +59 -2
- package/src/views/settings/SearchDataCard.tsx +390 -0
- package/src/views/settings/SeoSettingsTab.tsx +2 -0
- package/src/views/settings/useAiSettings.ts +71 -2
|
@@ -0,0 +1,390 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import { useCallback, useEffect, useId, useState } from 'react'
|
|
4
|
+
import { AlertTriangle, CheckCircle2, KeyRound, Loader2, PlugZap, RefreshCw, X } from 'lucide-react'
|
|
5
|
+
import { toast } from 'sonner'
|
|
6
|
+
import { cmsApi } from '../../lib/api.js'
|
|
7
|
+
import { SettingsCard } from './components.js'
|
|
8
|
+
|
|
9
|
+
const INPUT_CLASS =
|
|
10
|
+
'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'
|
|
11
|
+
const LABEL_CLASS = 'mb-1 flex items-center gap-2 text-sm font-medium text-foreground'
|
|
12
|
+
|
|
13
|
+
interface SearchConsoleStatus {
|
|
14
|
+
propertyUrl: string
|
|
15
|
+
hasServiceAccountKey: boolean
|
|
16
|
+
serviceAccountEmail: string | null
|
|
17
|
+
hasCruxApiKey: boolean
|
|
18
|
+
connected: boolean
|
|
19
|
+
lastSyncAt: string | null
|
|
20
|
+
syncStatus: {
|
|
21
|
+
state: 'ok' | 'partial' | 'error' | 'not-configured'
|
|
22
|
+
message?: string
|
|
23
|
+
parts?: {
|
|
24
|
+
searchConsole?: { ok: boolean; message?: string; rowsUpserted?: number }
|
|
25
|
+
crux?: { ok: boolean; message?: string; pagesChecked?: number }
|
|
26
|
+
}
|
|
27
|
+
} | null
|
|
28
|
+
suggestedProperty: string | null
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function KeyStoredBadge() {
|
|
32
|
+
return (
|
|
33
|
+
<span 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">
|
|
34
|
+
<CheckCircle2 size={11} className="text-success" aria-hidden="true" />
|
|
35
|
+
Key stored
|
|
36
|
+
</span>
|
|
37
|
+
)
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Settings → SEO "Search data" card: Google Search Console service-account
|
|
42
|
+
* credentials, CrUX API key, connection test, and manual sync. Key material is
|
|
43
|
+
* write-only — the server stores it encrypted and never echoes it back, so the
|
|
44
|
+
* inputs stay empty with a "Key stored" badge once saved.
|
|
45
|
+
*/
|
|
46
|
+
export function SearchDataCard({ canEdit }: { canEdit: boolean }) {
|
|
47
|
+
const propertyId = useId()
|
|
48
|
+
const keyId = useId()
|
|
49
|
+
const cruxId = useId()
|
|
50
|
+
|
|
51
|
+
const [status, setStatus] = useState<SearchConsoleStatus | null>(null)
|
|
52
|
+
const [loading, setLoading] = useState(true)
|
|
53
|
+
const [loadError, setLoadError] = useState<string | null>(null)
|
|
54
|
+
|
|
55
|
+
const [property, setProperty] = useState('')
|
|
56
|
+
const [serviceKey, setServiceKey] = useState('')
|
|
57
|
+
const [cruxKey, setCruxKey] = useState('')
|
|
58
|
+
const [saving, setSaving] = useState(false)
|
|
59
|
+
const [testing, setTesting] = useState(false)
|
|
60
|
+
const [syncing, setSyncing] = useState(false)
|
|
61
|
+
|
|
62
|
+
const load = useCallback(async () => {
|
|
63
|
+
setLoading(true)
|
|
64
|
+
setLoadError(null)
|
|
65
|
+
const res = await cmsApi<SearchConsoleStatus>('/seo/search-console')
|
|
66
|
+
if (res.data) {
|
|
67
|
+
setStatus(res.data)
|
|
68
|
+
setProperty(res.data.propertyUrl ?? '')
|
|
69
|
+
} else {
|
|
70
|
+
setLoadError(res.error ?? 'Failed to load search data settings')
|
|
71
|
+
}
|
|
72
|
+
setLoading(false)
|
|
73
|
+
}, [])
|
|
74
|
+
|
|
75
|
+
useEffect(() => {
|
|
76
|
+
void load()
|
|
77
|
+
}, [load])
|
|
78
|
+
|
|
79
|
+
const dirty =
|
|
80
|
+
property.trim() !== (status?.propertyUrl ?? '') ||
|
|
81
|
+
serviceKey.trim() !== '' ||
|
|
82
|
+
cruxKey.trim() !== ''
|
|
83
|
+
|
|
84
|
+
async function save(extra?: { serviceAccountKey?: string; cruxApiKey?: string }) {
|
|
85
|
+
setSaving(true)
|
|
86
|
+
const body: Record<string, string> = { propertyUrl: property.trim(), ...extra }
|
|
87
|
+
if (!extra) {
|
|
88
|
+
if (serviceKey.trim()) body.serviceAccountKey = serviceKey.trim()
|
|
89
|
+
if (cruxKey.trim()) body.cruxApiKey = cruxKey.trim()
|
|
90
|
+
}
|
|
91
|
+
const res = await cmsApi('/seo/search-console', {
|
|
92
|
+
method: 'PUT',
|
|
93
|
+
body: JSON.stringify(body),
|
|
94
|
+
})
|
|
95
|
+
setSaving(false)
|
|
96
|
+
if (res.error) {
|
|
97
|
+
toast.error(res.error)
|
|
98
|
+
return
|
|
99
|
+
}
|
|
100
|
+
toast.success('Search data settings saved')
|
|
101
|
+
setServiceKey('')
|
|
102
|
+
setCruxKey('')
|
|
103
|
+
await load()
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
async function testConnection() {
|
|
107
|
+
setTesting(true)
|
|
108
|
+
const res = await cmsApi<{ ok: boolean; code?: string; message?: string }>(
|
|
109
|
+
'/seo/search-console/test',
|
|
110
|
+
{ method: 'POST' },
|
|
111
|
+
)
|
|
112
|
+
setTesting(false)
|
|
113
|
+
if (res.error) {
|
|
114
|
+
toast.error(res.error)
|
|
115
|
+
return
|
|
116
|
+
}
|
|
117
|
+
if (res.data?.ok) {
|
|
118
|
+
toast.success('Connected to Google Search Console')
|
|
119
|
+
} else {
|
|
120
|
+
toast.error(res.data?.message ?? 'Connection test failed')
|
|
121
|
+
}
|
|
122
|
+
await load()
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
async function syncNow() {
|
|
126
|
+
setSyncing(true)
|
|
127
|
+
const res = await cmsApi<{
|
|
128
|
+
status: NonNullable<SearchConsoleStatus['syncStatus']>
|
|
129
|
+
rowsUpserted: number
|
|
130
|
+
cruxChecked: number
|
|
131
|
+
}>('/seo/search-sync', { method: 'POST' })
|
|
132
|
+
setSyncing(false)
|
|
133
|
+
if (res.error) {
|
|
134
|
+
toast.error(res.error)
|
|
135
|
+
return
|
|
136
|
+
}
|
|
137
|
+
const state = res.data?.status.state
|
|
138
|
+
if (state === 'ok') {
|
|
139
|
+
toast.success(`Sync complete — ${res.data?.rowsUpserted ?? 0} rows updated`)
|
|
140
|
+
} else if (state === 'partial') {
|
|
141
|
+
toast.warning(res.data?.status.message ?? 'Sync finished with warnings')
|
|
142
|
+
} else if (state === 'not-configured') {
|
|
143
|
+
toast.error('Add a service account key and property first.')
|
|
144
|
+
} else {
|
|
145
|
+
toast.error(res.data?.status.message ?? 'Sync failed')
|
|
146
|
+
}
|
|
147
|
+
await load()
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
if (loading) {
|
|
151
|
+
return (
|
|
152
|
+
<SettingsCard
|
|
153
|
+
title="Search data"
|
|
154
|
+
description="Google Search Console and Core Web Vitals feed real search performance into the SEO workspace."
|
|
155
|
+
>
|
|
156
|
+
<div className="space-y-3" aria-busy="true" aria-live="polite">
|
|
157
|
+
<span className="sr-only">Loading search data settings…</span>
|
|
158
|
+
<div className="bg-muted h-9 w-full animate-pulse rounded" />
|
|
159
|
+
<div className="bg-muted h-20 w-full animate-pulse rounded" />
|
|
160
|
+
</div>
|
|
161
|
+
</SettingsCard>
|
|
162
|
+
)
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
if (loadError) {
|
|
166
|
+
return (
|
|
167
|
+
<SettingsCard
|
|
168
|
+
title="Search data"
|
|
169
|
+
description="Google Search Console and Core Web Vitals feed real search performance into the SEO workspace."
|
|
170
|
+
>
|
|
171
|
+
<div
|
|
172
|
+
role="alert"
|
|
173
|
+
className="border-destructive/30 bg-destructive/10 text-destructive flex items-center gap-3 rounded-lg border p-4 text-sm"
|
|
174
|
+
>
|
|
175
|
+
<AlertTriangle size={16} className="shrink-0" aria-hidden="true" />
|
|
176
|
+
<span className="flex-1">{loadError}</span>
|
|
177
|
+
<button
|
|
178
|
+
type="button"
|
|
179
|
+
onClick={() => void load()}
|
|
180
|
+
className="border-destructive/40 rounded-md border px-3 py-1 transition-colors hover:opacity-80"
|
|
181
|
+
>
|
|
182
|
+
Retry
|
|
183
|
+
</button>
|
|
184
|
+
</div>
|
|
185
|
+
</SettingsCard>
|
|
186
|
+
)
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
const lastSync = status?.lastSyncAt ? new Date(status.lastSyncAt).toLocaleString() : 'Never'
|
|
190
|
+
|
|
191
|
+
return (
|
|
192
|
+
<SettingsCard
|
|
193
|
+
title="Search data"
|
|
194
|
+
description="Connect Google Search Console (service account) and the CrUX API to feed real clicks, impressions, and Core Web Vitals into audits, quick wins, and AI suggestions."
|
|
195
|
+
>
|
|
196
|
+
<div className="space-y-4">
|
|
197
|
+
{/* Connection status */}
|
|
198
|
+
<div className="flex items-center justify-between gap-2 text-sm">
|
|
199
|
+
<span className="text-muted-foreground">Status</span>
|
|
200
|
+
{status?.connected ? (
|
|
201
|
+
<span className="text-success inline-flex items-center gap-1 font-medium">
|
|
202
|
+
<CheckCircle2 size={14} aria-hidden="true" />
|
|
203
|
+
Connected
|
|
204
|
+
</span>
|
|
205
|
+
) : (
|
|
206
|
+
<span className="text-muted-foreground inline-flex items-center gap-1 font-medium">
|
|
207
|
+
<X size={14} aria-hidden="true" />
|
|
208
|
+
Not connected
|
|
209
|
+
</span>
|
|
210
|
+
)}
|
|
211
|
+
</div>
|
|
212
|
+
|
|
213
|
+
{/* Property */}
|
|
214
|
+
<div>
|
|
215
|
+
<label htmlFor={propertyId} className={LABEL_CLASS}>
|
|
216
|
+
Search Console property
|
|
217
|
+
</label>
|
|
218
|
+
<input
|
|
219
|
+
id={propertyId}
|
|
220
|
+
type="text"
|
|
221
|
+
autoComplete="off"
|
|
222
|
+
spellCheck={false}
|
|
223
|
+
value={property}
|
|
224
|
+
disabled={!canEdit}
|
|
225
|
+
placeholder={status?.suggestedProperty ?? 'sc-domain:example.com'}
|
|
226
|
+
onChange={(e) => setProperty(e.target.value)}
|
|
227
|
+
className={INPUT_CLASS}
|
|
228
|
+
/>
|
|
229
|
+
<p className="text-muted-foreground mt-1 text-sm">
|
|
230
|
+
Domain property (<code>sc-domain:example.com</code>) or URL prefix (
|
|
231
|
+
<code>https://example.com/</code>) exactly as it appears in Search Console.
|
|
232
|
+
{status?.suggestedProperty && !property.trim() && canEdit && (
|
|
233
|
+
<>
|
|
234
|
+
{' '}
|
|
235
|
+
<button
|
|
236
|
+
type="button"
|
|
237
|
+
onClick={() => setProperty(status.suggestedProperty ?? '')}
|
|
238
|
+
className="text-brand hover:underline"
|
|
239
|
+
>
|
|
240
|
+
Use {status.suggestedProperty}
|
|
241
|
+
</button>
|
|
242
|
+
</>
|
|
243
|
+
)}
|
|
244
|
+
</p>
|
|
245
|
+
</div>
|
|
246
|
+
|
|
247
|
+
{/* Service account key */}
|
|
248
|
+
<div>
|
|
249
|
+
<label htmlFor={keyId} className={LABEL_CLASS}>
|
|
250
|
+
Service account key (JSON)
|
|
251
|
+
{status?.hasServiceAccountKey && <KeyStoredBadge />}
|
|
252
|
+
</label>
|
|
253
|
+
<textarea
|
|
254
|
+
id={keyId}
|
|
255
|
+
rows={3}
|
|
256
|
+
value={serviceKey}
|
|
257
|
+
disabled={!canEdit}
|
|
258
|
+
autoComplete="off"
|
|
259
|
+
spellCheck={false}
|
|
260
|
+
placeholder={
|
|
261
|
+
status?.hasServiceAccountKey
|
|
262
|
+
? 'Paste a new key to replace the stored one'
|
|
263
|
+
: '{ "type": "service_account", "client_email": … }'
|
|
264
|
+
}
|
|
265
|
+
onChange={(e) => setServiceKey(e.target.value)}
|
|
266
|
+
className={`${INPUT_CLASS} resize-y font-mono text-sm`}
|
|
267
|
+
/>
|
|
268
|
+
<p className="text-muted-foreground mt-1 text-sm">
|
|
269
|
+
{status?.serviceAccountEmail ? (
|
|
270
|
+
<>
|
|
271
|
+
Grant{' '}
|
|
272
|
+
<span className="text-foreground font-medium">{status.serviceAccountEmail}</span>{' '}
|
|
273
|
+
access in Search Console → Settings → Users and permissions.
|
|
274
|
+
</>
|
|
275
|
+
) : (
|
|
276
|
+
'Create a service account in Google Cloud, enable the Search Console API, and add its email as a user on the property.'
|
|
277
|
+
)}
|
|
278
|
+
</p>
|
|
279
|
+
{status?.hasServiceAccountKey && canEdit && (
|
|
280
|
+
<button
|
|
281
|
+
type="button"
|
|
282
|
+
onClick={() => void save({ serviceAccountKey: '' })}
|
|
283
|
+
className="text-muted-foreground hover:text-destructive mt-1 inline-flex items-center gap-1 rounded-md text-sm transition-colors"
|
|
284
|
+
>
|
|
285
|
+
<X size={13} aria-hidden="true" />
|
|
286
|
+
Remove stored key
|
|
287
|
+
</button>
|
|
288
|
+
)}
|
|
289
|
+
</div>
|
|
290
|
+
|
|
291
|
+
{/* CrUX key */}
|
|
292
|
+
<div>
|
|
293
|
+
<label htmlFor={cruxId} className={LABEL_CLASS}>
|
|
294
|
+
CrUX API key
|
|
295
|
+
{status?.hasCruxApiKey && <KeyStoredBadge />}
|
|
296
|
+
</label>
|
|
297
|
+
<input
|
|
298
|
+
id={cruxId}
|
|
299
|
+
type="password"
|
|
300
|
+
autoComplete="off"
|
|
301
|
+
value={cruxKey}
|
|
302
|
+
disabled={!canEdit}
|
|
303
|
+
placeholder={
|
|
304
|
+
status?.hasCruxApiKey ? 'Paste a new key to replace the stored one' : 'AIza…'
|
|
305
|
+
}
|
|
306
|
+
onChange={(e) => setCruxKey(e.target.value)}
|
|
307
|
+
className={INPUT_CLASS}
|
|
308
|
+
/>
|
|
309
|
+
<p className="text-muted-foreground mt-1 text-sm">
|
|
310
|
+
Optional — collects real-user Core Web Vitals (LCP, INP, CLS) from the Chrome UX Report.
|
|
311
|
+
</p>
|
|
312
|
+
{status?.hasCruxApiKey && canEdit && (
|
|
313
|
+
<button
|
|
314
|
+
type="button"
|
|
315
|
+
onClick={() => void save({ cruxApiKey: '' })}
|
|
316
|
+
className="text-muted-foreground hover:text-destructive mt-1 inline-flex items-center gap-1 rounded-md text-sm transition-colors"
|
|
317
|
+
>
|
|
318
|
+
<X size={13} aria-hidden="true" />
|
|
319
|
+
Remove stored key
|
|
320
|
+
</button>
|
|
321
|
+
)}
|
|
322
|
+
</div>
|
|
323
|
+
|
|
324
|
+
{/* Actions */}
|
|
325
|
+
<div className="border-border flex flex-wrap items-center gap-2 border-t pt-4">
|
|
326
|
+
<button
|
|
327
|
+
type="button"
|
|
328
|
+
onClick={() => void save()}
|
|
329
|
+
disabled={!canEdit || saving || !dirty}
|
|
330
|
+
className="bg-primary text-primary-foreground inline-flex items-center gap-2 rounded-md px-4 py-2 text-sm font-medium transition-opacity hover:opacity-90 disabled:cursor-not-allowed disabled:opacity-60"
|
|
331
|
+
>
|
|
332
|
+
{saving ? (
|
|
333
|
+
<Loader2 size={14} className="animate-spin" aria-hidden="true" />
|
|
334
|
+
) : (
|
|
335
|
+
<KeyRound size={14} aria-hidden="true" />
|
|
336
|
+
)}
|
|
337
|
+
Save credentials
|
|
338
|
+
</button>
|
|
339
|
+
<button
|
|
340
|
+
type="button"
|
|
341
|
+
onClick={() => void testConnection()}
|
|
342
|
+
disabled={!canEdit || testing || !status?.hasServiceAccountKey}
|
|
343
|
+
className="border-border text-foreground hover:bg-accent inline-flex items-center gap-2 rounded-md border px-4 py-2 text-sm transition-colors disabled:cursor-not-allowed disabled:opacity-60"
|
|
344
|
+
>
|
|
345
|
+
{testing ? (
|
|
346
|
+
<Loader2 size={14} className="animate-spin" aria-hidden="true" />
|
|
347
|
+
) : (
|
|
348
|
+
<PlugZap size={14} aria-hidden="true" />
|
|
349
|
+
)}
|
|
350
|
+
Test connection
|
|
351
|
+
</button>
|
|
352
|
+
<button
|
|
353
|
+
type="button"
|
|
354
|
+
onClick={() => void syncNow()}
|
|
355
|
+
disabled={!canEdit || syncing || !status?.connected}
|
|
356
|
+
className="border-border text-foreground hover:bg-accent inline-flex items-center gap-2 rounded-md border px-4 py-2 text-sm transition-colors disabled:cursor-not-allowed disabled:opacity-60"
|
|
357
|
+
>
|
|
358
|
+
{syncing ? (
|
|
359
|
+
<Loader2 size={14} className="animate-spin" aria-hidden="true" />
|
|
360
|
+
) : (
|
|
361
|
+
<RefreshCw size={14} aria-hidden="true" />
|
|
362
|
+
)}
|
|
363
|
+
Sync now
|
|
364
|
+
</button>
|
|
365
|
+
</div>
|
|
366
|
+
|
|
367
|
+
{/* Sync status */}
|
|
368
|
+
<div className="space-y-2 text-sm">
|
|
369
|
+
<div className="flex items-center justify-between gap-2">
|
|
370
|
+
<span className="text-muted-foreground">Last sync</span>
|
|
371
|
+
<span className="text-foreground font-medium">{lastSync}</span>
|
|
372
|
+
</div>
|
|
373
|
+
{status?.syncStatus && status.syncStatus.state !== 'ok' && status.syncStatus.message && (
|
|
374
|
+
<div
|
|
375
|
+
role="status"
|
|
376
|
+
className={
|
|
377
|
+
status.syncStatus.state === 'error'
|
|
378
|
+
? 'border-destructive/30 bg-destructive/10 text-destructive flex items-start gap-2 rounded-md border p-3'
|
|
379
|
+
: 'border-warning/30 bg-warning/10 text-warning flex items-start gap-2 rounded-md border p-3'
|
|
380
|
+
}
|
|
381
|
+
>
|
|
382
|
+
<AlertTriangle size={14} className="mt-0.5 shrink-0" aria-hidden="true" />
|
|
383
|
+
<span>{status.syncStatus.message}</span>
|
|
384
|
+
</div>
|
|
385
|
+
)}
|
|
386
|
+
</div>
|
|
387
|
+
</div>
|
|
388
|
+
</SettingsCard>
|
|
389
|
+
)
|
|
390
|
+
}
|
|
@@ -16,6 +16,7 @@ import {
|
|
|
16
16
|
import { siteUrlMatchesAdminOrigin } from '../../lib/preview-link.js'
|
|
17
17
|
import { MediaPickerModal } from '../../components/MediaPickerModal.js'
|
|
18
18
|
import { SEOConfigPanel } from '../../components/SEOConfigPanel.js'
|
|
19
|
+
import { SearchDataCard } from './SearchDataCard.js'
|
|
19
20
|
import {
|
|
20
21
|
ConfirmDangerousSettingDialog,
|
|
21
22
|
SettingsCard,
|
|
@@ -149,6 +150,7 @@ export function SeoSettingsTab({ onNavigate, role }: SeoSettingsTabProps) {
|
|
|
149
150
|
<MetaDefaultsCard seo={seo} canEdit={canEdit} />
|
|
150
151
|
<OrganizationCard seo={seo} canEdit={canEdit} />
|
|
151
152
|
<SearchEngineVerificationCard seo={seo} canEdit={canEdit} />
|
|
153
|
+
<SearchDataCard canEdit={canEdit} />
|
|
152
154
|
<SitemapDefaultsCard seo={seo} canEdit={canEdit} onNavigate={onNavigate} />
|
|
153
155
|
|
|
154
156
|
<details className="border-border bg-card group rounded-lg border">
|
|
@@ -86,6 +86,22 @@ export interface AiFeatureView {
|
|
|
86
86
|
modelId?: string
|
|
87
87
|
/** Per-feature provider connection override. Usually derived from the model. */
|
|
88
88
|
providerId?: string
|
|
89
|
+
/** AI output must be human-approved before it is applied. */
|
|
90
|
+
approvalRequired: boolean
|
|
91
|
+
/** Permit applying many AI results in one operation. */
|
|
92
|
+
allowBulkApply: boolean
|
|
93
|
+
/** Site-wide daily request cap; empty string = unlimited (string-buffered input). */
|
|
94
|
+
maxRequestsPerDay: string
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** Raw feature shape on the wire (`GET /ai/settings`): governance fields optional, cap numeric. */
|
|
98
|
+
type AiFeatureWire = Omit<
|
|
99
|
+
AiFeatureView,
|
|
100
|
+
'approvalRequired' | 'allowBulkApply' | 'maxRequestsPerDay'
|
|
101
|
+
> & {
|
|
102
|
+
approvalRequired?: boolean
|
|
103
|
+
allowBulkApply?: boolean
|
|
104
|
+
maxRequestsPerDay?: number | null
|
|
89
105
|
}
|
|
90
106
|
|
|
91
107
|
export interface AiUsageView {
|
|
@@ -143,7 +159,7 @@ interface AiSettingsResponse {
|
|
|
143
159
|
settings: {
|
|
144
160
|
defaultProviderId?: string
|
|
145
161
|
defaultModelId?: string
|
|
146
|
-
features:
|
|
162
|
+
features: AiFeatureWire[]
|
|
147
163
|
budget: { monthlyTokenLimit?: number }
|
|
148
164
|
brandVoice?: {
|
|
149
165
|
enabled?: boolean
|
|
@@ -172,6 +188,12 @@ function formFromResponse(res: AiSettingsResponse): AiSettingsForm {
|
|
|
172
188
|
...f,
|
|
173
189
|
modelId: f.modelId ?? '',
|
|
174
190
|
providerId: f.providerId ?? '',
|
|
191
|
+
approvalRequired: (f as { approvalRequired?: boolean }).approvalRequired !== false,
|
|
192
|
+
allowBulkApply: (f as { allowBulkApply?: boolean }).allowBulkApply === true,
|
|
193
|
+
maxRequestsPerDay:
|
|
194
|
+
typeof (f as { maxRequestsPerDay?: number }).maxRequestsPerDay === 'number'
|
|
195
|
+
? String((f as { maxRequestsPerDay?: number }).maxRequestsPerDay)
|
|
196
|
+
: '',
|
|
175
197
|
})),
|
|
176
198
|
monthlyTokenLimit:
|
|
177
199
|
typeof res.settings.budget.monthlyTokenLimit === 'number'
|
|
@@ -241,6 +263,15 @@ export interface UseAiSettings {
|
|
|
241
263
|
toggleFeature: (key: string, enabled: boolean) => void
|
|
242
264
|
/** Set a per-feature model override. Empty string reverts to the default model. */
|
|
243
265
|
setFeatureModel: (key: string, modelId: string) => void
|
|
266
|
+
/** Patch per-feature governance controls (approval, bulk apply, daily cap). */
|
|
267
|
+
setFeatureGovernance: (
|
|
268
|
+
key: string,
|
|
269
|
+
patch: Partial<{
|
|
270
|
+
approvalRequired: boolean
|
|
271
|
+
allowBulkApply: boolean
|
|
272
|
+
maxRequestsPerDay: string
|
|
273
|
+
}>,
|
|
274
|
+
) => void
|
|
244
275
|
/** Patch one or more brand voice fields. */
|
|
245
276
|
setBrandVoice: (patch: Partial<BrandVoiceForm>) => void
|
|
246
277
|
/** Whether saves should trust the markdown document or quick-edit fields. */
|
|
@@ -374,6 +405,34 @@ export function useAiSettings(canEdit: boolean): UseAiSettings {
|
|
|
374
405
|
setSaveState('idle')
|
|
375
406
|
}, [])
|
|
376
407
|
|
|
408
|
+
const setFeatureGovernance = useCallback(
|
|
409
|
+
(
|
|
410
|
+
key: string,
|
|
411
|
+
patch: Partial<{
|
|
412
|
+
approvalRequired: boolean
|
|
413
|
+
allowBulkApply: boolean
|
|
414
|
+
maxRequestsPerDay: string
|
|
415
|
+
}>,
|
|
416
|
+
) => {
|
|
417
|
+
setForm((prev) => ({
|
|
418
|
+
...prev,
|
|
419
|
+
features: prev.features.map((f) =>
|
|
420
|
+
f.key === key
|
|
421
|
+
? {
|
|
422
|
+
...f,
|
|
423
|
+
...patch,
|
|
424
|
+
...(patch.maxRequestsPerDay !== undefined
|
|
425
|
+
? { maxRequestsPerDay: patch.maxRequestsPerDay.replace(/[^\d]/g, '') }
|
|
426
|
+
: {}),
|
|
427
|
+
}
|
|
428
|
+
: f,
|
|
429
|
+
),
|
|
430
|
+
}))
|
|
431
|
+
setSaveState('idle')
|
|
432
|
+
},
|
|
433
|
+
[],
|
|
434
|
+
)
|
|
435
|
+
|
|
377
436
|
const setBrandVoice = useCallback((patch: Partial<BrandVoiceForm>) => {
|
|
378
437
|
setForm((prev) => ({ ...prev, brandVoice: { ...prev.brandVoice, ...patch } }))
|
|
379
438
|
setSaveState('idle')
|
|
@@ -474,7 +533,13 @@ export function useAiSettings(canEdit: boolean): UseAiSettings {
|
|
|
474
533
|
return true
|
|
475
534
|
return form.features.some((f, i) => {
|
|
476
535
|
const base = baseline.features[i]
|
|
477
|
-
return
|
|
536
|
+
return (
|
|
537
|
+
f.enabled !== base?.enabled ||
|
|
538
|
+
(f.modelId ?? '') !== (base?.modelId ?? '') ||
|
|
539
|
+
f.approvalRequired !== base?.approvalRequired ||
|
|
540
|
+
f.allowBulkApply !== base?.allowBulkApply ||
|
|
541
|
+
f.maxRequestsPerDay !== base?.maxRequestsPerDay
|
|
542
|
+
)
|
|
478
543
|
})
|
|
479
544
|
}, [form, baseline])
|
|
480
545
|
|
|
@@ -492,6 +557,9 @@ export function useAiSettings(canEdit: boolean): UseAiSettings {
|
|
|
492
557
|
key: f.key,
|
|
493
558
|
enabled: f.enabled,
|
|
494
559
|
modelId: f.modelId ? f.modelId : null,
|
|
560
|
+
approvalRequired: f.approvalRequired,
|
|
561
|
+
allowBulkApply: f.allowBulkApply,
|
|
562
|
+
maxRequestsPerDay: f.maxRequestsPerDay === '' ? null : Number(f.maxRequestsPerDay),
|
|
495
563
|
})),
|
|
496
564
|
budget: {
|
|
497
565
|
monthlyTokenLimit:
|
|
@@ -661,6 +729,7 @@ export function useAiSettings(canEdit: boolean): UseAiSettings {
|
|
|
661
729
|
setMonthlyTokenLimit,
|
|
662
730
|
toggleFeature,
|
|
663
731
|
setFeatureModel,
|
|
732
|
+
setFeatureGovernance,
|
|
664
733
|
setBrandVoice,
|
|
665
734
|
analyzeBrandVoice,
|
|
666
735
|
scoreBrandVoice,
|