@actuate-media/cms-admin 0.32.0 → 0.34.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/__tests__/views/ai-settings.render.test.d.ts +2 -0
- package/dist/__tests__/views/ai-settings.render.test.d.ts.map +1 -0
- package/dist/__tests__/views/ai-settings.render.test.js +182 -0
- package/dist/__tests__/views/ai-settings.render.test.js.map +1 -0
- package/dist/__tests__/views/seo-settings.render.test.d.ts +2 -0
- package/dist/__tests__/views/seo-settings.render.test.d.ts.map +1 -0
- package/dist/__tests__/views/seo-settings.render.test.js +152 -0
- package/dist/__tests__/views/seo-settings.render.test.js.map +1 -0
- package/dist/__tests__/views/settings-save-footer.render.test.js +16 -9
- package/dist/__tests__/views/settings-save-footer.render.test.js.map +1 -1
- package/dist/actuate-admin.css +1 -1
- package/dist/components/MediaPickerModal.d.ts +15 -1
- package/dist/components/MediaPickerModal.d.ts.map +1 -1
- package/dist/components/MediaPickerModal.js +17 -1
- package/dist/components/MediaPickerModal.js.map +1 -1
- package/dist/views/Settings.d.ts.map +1 -1
- package/dist/views/Settings.js +3 -47
- package/dist/views/Settings.js.map +1 -1
- package/dist/views/settings/AISettingsTab.d.ts +6 -0
- package/dist/views/settings/AISettingsTab.d.ts.map +1 -0
- package/dist/views/settings/AISettingsTab.js +138 -0
- package/dist/views/settings/AISettingsTab.js.map +1 -0
- package/dist/views/settings/AiFeaturesCard.d.ts +12 -0
- package/dist/views/settings/AiFeaturesCard.d.ts.map +1 -0
- package/dist/views/settings/AiFeaturesCard.js +14 -0
- package/dist/views/settings/AiFeaturesCard.js.map +1 -0
- package/dist/views/settings/CapabilityBadges.d.ts +5 -0
- package/dist/views/settings/CapabilityBadges.d.ts.map +1 -0
- package/dist/views/settings/CapabilityBadges.js +26 -0
- package/dist/views/settings/CapabilityBadges.js.map +1 -0
- package/dist/views/settings/ModelSelector.d.ts +12 -0
- package/dist/views/settings/ModelSelector.d.ts.map +1 -0
- package/dist/views/settings/ModelSelector.js +29 -0
- package/dist/views/settings/ModelSelector.js.map +1 -0
- package/dist/views/settings/MonthlyUsageCard.d.ts +14 -0
- package/dist/views/settings/MonthlyUsageCard.d.ts.map +1 -0
- package/dist/views/settings/MonthlyUsageCard.js +30 -0
- package/dist/views/settings/MonthlyUsageCard.js.map +1 -0
- package/dist/views/settings/ProviderConnectionCard.d.ts +13 -0
- package/dist/views/settings/ProviderConnectionCard.d.ts.map +1 -0
- package/dist/views/settings/ProviderConnectionCard.js +48 -0
- package/dist/views/settings/ProviderConnectionCard.js.map +1 -0
- package/dist/views/settings/SeoSettingsTab.d.ts +7 -0
- package/dist/views/settings/SeoSettingsTab.d.ts.map +1 -0
- package/dist/views/settings/SeoSettingsTab.js +126 -0
- package/dist/views/settings/SeoSettingsTab.js.map +1 -0
- package/dist/views/settings/useAiSettings.d.ts +143 -0
- package/dist/views/settings/useAiSettings.d.ts.map +1 -0
- package/dist/views/settings/useAiSettings.js +278 -0
- package/dist/views/settings/useAiSettings.js.map +1 -0
- package/dist/views/settings/useSeoSettings.d.ts +79 -0
- package/dist/views/settings/useSeoSettings.d.ts.map +1 -0
- package/dist/views/settings/useSeoSettings.js +278 -0
- package/dist/views/settings/useSeoSettings.js.map +1 -0
- package/package.json +2 -2
- package/src/__tests__/views/ai-settings.render.test.tsx +225 -0
- package/src/__tests__/views/seo-settings.render.test.tsx +219 -0
- package/src/__tests__/views/settings-save-footer.render.test.tsx +16 -9
- package/src/components/MediaPickerModal.tsx +39 -2
- package/src/views/Settings.tsx +4 -141
- package/src/views/settings/AISettingsTab.tsx +411 -0
- package/src/views/settings/AiFeaturesCard.tsx +49 -0
- package/src/views/settings/CapabilityBadges.tsx +66 -0
- package/src/views/settings/ModelSelector.tsx +115 -0
- package/src/views/settings/MonthlyUsageCard.tsx +118 -0
- package/src/views/settings/ProviderConnectionCard.tsx +245 -0
- package/src/views/settings/SeoSettingsTab.tsx +658 -0
- package/src/views/settings/useAiSettings.ts +448 -0
- package/src/views/settings/useSeoSettings.ts +374 -0
|
@@ -0,0 +1,278 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
import { useCallback, useEffect, useMemo, useState } from 'react';
|
|
3
|
+
import { cmsApi } from '../../lib/api.js';
|
|
4
|
+
function formFromResponse(res) {
|
|
5
|
+
return {
|
|
6
|
+
defaultProviderId: res.settings.defaultProviderId ?? '',
|
|
7
|
+
defaultModelId: res.settings.defaultModelId ?? '',
|
|
8
|
+
features: res.settings.features.map((f) => ({ ...f })),
|
|
9
|
+
monthlyTokenLimit: typeof res.settings.budget.monthlyTokenLimit === 'number'
|
|
10
|
+
? String(res.settings.budget.monthlyTokenLimit)
|
|
11
|
+
: '',
|
|
12
|
+
};
|
|
13
|
+
}
|
|
14
|
+
const EMPTY_FORM = {
|
|
15
|
+
defaultProviderId: '',
|
|
16
|
+
defaultModelId: '',
|
|
17
|
+
features: [],
|
|
18
|
+
monthlyTokenLimit: '',
|
|
19
|
+
};
|
|
20
|
+
export function useAiSettings(canEdit) {
|
|
21
|
+
const [loading, setLoading] = useState(true);
|
|
22
|
+
const [loadError, setLoadError] = useState(null);
|
|
23
|
+
const [providers, setProviders] = useState([]);
|
|
24
|
+
const [models, setModels] = useState([]);
|
|
25
|
+
const [usage, setUsage] = useState(null);
|
|
26
|
+
const [defaultModel, setDefaultModel] = useState(null);
|
|
27
|
+
const [baseline, setBaseline] = useState(EMPTY_FORM);
|
|
28
|
+
const [form, setForm] = useState(EMPTY_FORM);
|
|
29
|
+
const [saveState, setSaveState] = useState('idle');
|
|
30
|
+
const [saveError, setSaveError] = useState(null);
|
|
31
|
+
const [busyProviderId, setBusyProviderId] = useState(null);
|
|
32
|
+
const [reloadKey, setReloadKey] = useState(0);
|
|
33
|
+
const loadModels = useCallback(async () => {
|
|
34
|
+
const res = await cmsApi('/ai/models');
|
|
35
|
+
if (!res.error && res.data)
|
|
36
|
+
setModels(res.data.models ?? []);
|
|
37
|
+
}, []);
|
|
38
|
+
useEffect(() => {
|
|
39
|
+
let cancelled = false;
|
|
40
|
+
setLoading(true);
|
|
41
|
+
setLoadError(null);
|
|
42
|
+
Promise.all([
|
|
43
|
+
cmsApi('/ai/settings'),
|
|
44
|
+
cmsApi('/ai/models'),
|
|
45
|
+
cmsApi('/ai/usage/summary'),
|
|
46
|
+
])
|
|
47
|
+
.then(([settingsRes, modelsRes, usageRes]) => {
|
|
48
|
+
if (cancelled)
|
|
49
|
+
return;
|
|
50
|
+
if (settingsRes.error) {
|
|
51
|
+
setLoadError(settingsRes.error);
|
|
52
|
+
return;
|
|
53
|
+
}
|
|
54
|
+
if (settingsRes.data) {
|
|
55
|
+
const loaded = formFromResponse(settingsRes.data);
|
|
56
|
+
setBaseline(loaded);
|
|
57
|
+
setForm(loaded);
|
|
58
|
+
setProviders(settingsRes.data.providers ?? []);
|
|
59
|
+
setDefaultModel(settingsRes.data.defaultModel ?? null);
|
|
60
|
+
}
|
|
61
|
+
if (!modelsRes.error && modelsRes.data)
|
|
62
|
+
setModels(modelsRes.data.models ?? []);
|
|
63
|
+
if (!usageRes.error && usageRes.data)
|
|
64
|
+
setUsage(usageRes.data);
|
|
65
|
+
})
|
|
66
|
+
.catch(() => {
|
|
67
|
+
if (!cancelled)
|
|
68
|
+
setLoadError('Failed to load AI settings.');
|
|
69
|
+
})
|
|
70
|
+
.finally(() => {
|
|
71
|
+
if (!cancelled)
|
|
72
|
+
setLoading(false);
|
|
73
|
+
});
|
|
74
|
+
return () => {
|
|
75
|
+
cancelled = true;
|
|
76
|
+
};
|
|
77
|
+
}, [reloadKey]);
|
|
78
|
+
const setDefaultProvider = useCallback((id) => {
|
|
79
|
+
setForm((prev) => ({ ...prev, defaultProviderId: id, defaultModelId: '' }));
|
|
80
|
+
setSaveState('idle');
|
|
81
|
+
}, []);
|
|
82
|
+
const setDefaultModelId = useCallback((id) => {
|
|
83
|
+
setForm((prev) => ({ ...prev, defaultModelId: id }));
|
|
84
|
+
setSaveState('idle');
|
|
85
|
+
}, []);
|
|
86
|
+
const setMonthlyTokenLimit = useCallback((value) => {
|
|
87
|
+
setForm((prev) => ({ ...prev, monthlyTokenLimit: value.replace(/[^\d]/g, '') }));
|
|
88
|
+
setSaveState('idle');
|
|
89
|
+
}, []);
|
|
90
|
+
const toggleFeature = useCallback((key, enabled) => {
|
|
91
|
+
setForm((prev) => ({
|
|
92
|
+
...prev,
|
|
93
|
+
features: prev.features.map((f) => (f.key === key ? { ...f, enabled } : f)),
|
|
94
|
+
}));
|
|
95
|
+
setSaveState('idle');
|
|
96
|
+
}, []);
|
|
97
|
+
const budgetError = useMemo(() => {
|
|
98
|
+
if (form.monthlyTokenLimit === '')
|
|
99
|
+
return null;
|
|
100
|
+
const n = Number(form.monthlyTokenLimit);
|
|
101
|
+
if (!Number.isFinite(n) || n < 0)
|
|
102
|
+
return 'Monthly token limit must be a non-negative number.';
|
|
103
|
+
return null;
|
|
104
|
+
}, [form.monthlyTokenLimit]);
|
|
105
|
+
const dirty = useMemo(() => {
|
|
106
|
+
if (form.defaultProviderId !== baseline.defaultProviderId)
|
|
107
|
+
return true;
|
|
108
|
+
if (form.defaultModelId !== baseline.defaultModelId)
|
|
109
|
+
return true;
|
|
110
|
+
if (form.monthlyTokenLimit !== baseline.monthlyTokenLimit)
|
|
111
|
+
return true;
|
|
112
|
+
if (form.features.length !== baseline.features.length)
|
|
113
|
+
return true;
|
|
114
|
+
return form.features.some((f, i) => f.enabled !== baseline.features[i]?.enabled);
|
|
115
|
+
}, [form, baseline]);
|
|
116
|
+
const save = useCallback(async () => {
|
|
117
|
+
if (!canEdit || budgetError)
|
|
118
|
+
return;
|
|
119
|
+
setSaveState('saving');
|
|
120
|
+
setSaveError(null);
|
|
121
|
+
try {
|
|
122
|
+
const res = await cmsApi('/ai/settings', {
|
|
123
|
+
method: 'PUT',
|
|
124
|
+
body: JSON.stringify({
|
|
125
|
+
defaultProviderId: form.defaultProviderId || null,
|
|
126
|
+
defaultModelId: form.defaultModelId || null,
|
|
127
|
+
features: form.features.map((f) => ({ key: f.key, enabled: f.enabled })),
|
|
128
|
+
budget: {
|
|
129
|
+
monthlyTokenLimit: form.monthlyTokenLimit === '' ? null : Number(form.monthlyTokenLimit),
|
|
130
|
+
},
|
|
131
|
+
}),
|
|
132
|
+
});
|
|
133
|
+
if (res.error)
|
|
134
|
+
throw new Error(res.error);
|
|
135
|
+
setBaseline(form);
|
|
136
|
+
setSaveState('success');
|
|
137
|
+
// Refresh usage so the budget meter reflects the new limit immediately.
|
|
138
|
+
const usageRes = await cmsApi('/ai/usage/summary');
|
|
139
|
+
if (!usageRes.error && usageRes.data)
|
|
140
|
+
setUsage(usageRes.data);
|
|
141
|
+
}
|
|
142
|
+
catch (err) {
|
|
143
|
+
setSaveState('error');
|
|
144
|
+
setSaveError(err instanceof Error ? err.message : 'Failed to save AI settings.');
|
|
145
|
+
}
|
|
146
|
+
}, [canEdit, budgetError, form]);
|
|
147
|
+
const reset = useCallback(() => {
|
|
148
|
+
setForm(baseline);
|
|
149
|
+
setSaveState('idle');
|
|
150
|
+
setSaveError(null);
|
|
151
|
+
}, [baseline]);
|
|
152
|
+
const refetch = useCallback(() => setReloadKey((k) => k + 1), []);
|
|
153
|
+
const reloadProviders = useCallback(async () => {
|
|
154
|
+
const res = await cmsApi('/ai/settings');
|
|
155
|
+
if (!res.error && res.data) {
|
|
156
|
+
setProviders(res.data.providers ?? []);
|
|
157
|
+
setDefaultModel(res.data.defaultModel ?? null);
|
|
158
|
+
}
|
|
159
|
+
}, []);
|
|
160
|
+
const createProvider = useCallback(async (input) => {
|
|
161
|
+
if (!canEdit)
|
|
162
|
+
return { ok: false, error: 'You do not have permission to do this.' };
|
|
163
|
+
setBusyProviderId('new');
|
|
164
|
+
try {
|
|
165
|
+
const res = await cmsApi('/ai/providers', {
|
|
166
|
+
method: 'POST',
|
|
167
|
+
body: JSON.stringify(input),
|
|
168
|
+
});
|
|
169
|
+
if (res.error)
|
|
170
|
+
return { ok: false, error: res.error };
|
|
171
|
+
await reloadProviders();
|
|
172
|
+
return { ok: true };
|
|
173
|
+
}
|
|
174
|
+
finally {
|
|
175
|
+
setBusyProviderId(null);
|
|
176
|
+
}
|
|
177
|
+
}, [canEdit, reloadProviders]);
|
|
178
|
+
const updateProvider = useCallback(async (id, patch) => {
|
|
179
|
+
if (!canEdit)
|
|
180
|
+
return { ok: false, error: 'You do not have permission to do this.' };
|
|
181
|
+
setBusyProviderId(id);
|
|
182
|
+
try {
|
|
183
|
+
const res = await cmsApi(`/ai/providers/${id}`, {
|
|
184
|
+
method: 'PUT',
|
|
185
|
+
body: JSON.stringify(patch),
|
|
186
|
+
});
|
|
187
|
+
if (res.error)
|
|
188
|
+
return { ok: false, error: res.error };
|
|
189
|
+
await reloadProviders();
|
|
190
|
+
return { ok: true };
|
|
191
|
+
}
|
|
192
|
+
finally {
|
|
193
|
+
setBusyProviderId(null);
|
|
194
|
+
}
|
|
195
|
+
}, [canEdit, reloadProviders]);
|
|
196
|
+
const deleteProvider = useCallback(async (id) => {
|
|
197
|
+
if (!canEdit)
|
|
198
|
+
return { ok: false, error: 'You do not have permission to do this.' };
|
|
199
|
+
setBusyProviderId(id);
|
|
200
|
+
try {
|
|
201
|
+
const res = await cmsApi(`/ai/providers/${id}`, { method: 'DELETE' });
|
|
202
|
+
if (res.error)
|
|
203
|
+
return { ok: false, error: res.error };
|
|
204
|
+
await reloadProviders();
|
|
205
|
+
await loadModels();
|
|
206
|
+
return { ok: true };
|
|
207
|
+
}
|
|
208
|
+
finally {
|
|
209
|
+
setBusyProviderId(null);
|
|
210
|
+
}
|
|
211
|
+
}, [canEdit, reloadProviders, loadModels]);
|
|
212
|
+
const testProvider = useCallback(async (id) => {
|
|
213
|
+
if (!canEdit)
|
|
214
|
+
return { ok: false, error: 'You do not have permission to do this.' };
|
|
215
|
+
setBusyProviderId(id);
|
|
216
|
+
try {
|
|
217
|
+
const res = await cmsApi(`/ai/providers/${id}/test`, { method: 'POST' });
|
|
218
|
+
if (res.error)
|
|
219
|
+
return { ok: false, error: res.error };
|
|
220
|
+
await reloadProviders();
|
|
221
|
+
return {
|
|
222
|
+
ok: res.data?.result.ok ?? false,
|
|
223
|
+
status: res.data?.result.status,
|
|
224
|
+
message: res.data?.result.message,
|
|
225
|
+
};
|
|
226
|
+
}
|
|
227
|
+
finally {
|
|
228
|
+
setBusyProviderId(null);
|
|
229
|
+
}
|
|
230
|
+
}, [canEdit, reloadProviders]);
|
|
231
|
+
const syncModels = useCallback(async (id) => {
|
|
232
|
+
if (!canEdit)
|
|
233
|
+
return { ok: false, error: 'You do not have permission to do this.' };
|
|
234
|
+
setBusyProviderId(id);
|
|
235
|
+
try {
|
|
236
|
+
const res = await cmsApi(`/ai/providers/${id}/sync-models`, { method: 'POST' });
|
|
237
|
+
if (res.error)
|
|
238
|
+
return { ok: false, error: res.error };
|
|
239
|
+
await loadModels();
|
|
240
|
+
await reloadProviders();
|
|
241
|
+
return {
|
|
242
|
+
ok: true,
|
|
243
|
+
fromFallback: res.data?.fromFallback,
|
|
244
|
+
warning: res.data?.warning ?? null,
|
|
245
|
+
};
|
|
246
|
+
}
|
|
247
|
+
finally {
|
|
248
|
+
setBusyProviderId(null);
|
|
249
|
+
}
|
|
250
|
+
}, [canEdit, loadModels, reloadProviders]);
|
|
251
|
+
return {
|
|
252
|
+
loading,
|
|
253
|
+
loadError,
|
|
254
|
+
providers,
|
|
255
|
+
models,
|
|
256
|
+
usage,
|
|
257
|
+
defaultModel,
|
|
258
|
+
form,
|
|
259
|
+
setDefaultProvider,
|
|
260
|
+
setDefaultModel: setDefaultModelId,
|
|
261
|
+
setMonthlyTokenLimit,
|
|
262
|
+
toggleFeature,
|
|
263
|
+
dirty,
|
|
264
|
+
budgetError,
|
|
265
|
+
saveState,
|
|
266
|
+
saveError,
|
|
267
|
+
save,
|
|
268
|
+
reset,
|
|
269
|
+
refetch,
|
|
270
|
+
createProvider,
|
|
271
|
+
updateProvider,
|
|
272
|
+
deleteProvider,
|
|
273
|
+
testProvider,
|
|
274
|
+
syncModels,
|
|
275
|
+
busyProviderId,
|
|
276
|
+
};
|
|
277
|
+
}
|
|
278
|
+
//# sourceMappingURL=useAiSettings.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"useAiSettings.js","sourceRoot":"","sources":["../../../src/views/settings/useAiSettings.ts"],"names":[],"mappings":"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"}
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import type { SettingsSourceMetadata, ValidationIssue } from './useGeneralSettings.js';
|
|
2
|
+
/**
|
|
3
|
+
* Settings → SEO ("Meta Defaults / Verification / Sitemap") form state.
|
|
4
|
+
*
|
|
5
|
+
* Single source of truth: every field reads from and writes back to the SAME
|
|
6
|
+
* shared SEO config service used by Settings → General and the /seo workspace.
|
|
7
|
+
* - meta defaults + verification → `/seo/config` (`site` overrides)
|
|
8
|
+
* - sitemap auto-generate / ping → `/seo/crawl-settings` (`module`)
|
|
9
|
+
* - sitemap status (read-only) → `/seo/sitemap/status`
|
|
10
|
+
*
|
|
11
|
+
* It deliberately does NOT own site URL or the document robots defaults — those
|
|
12
|
+
* are owned by Settings → General (also via `/seo/config`) and must not be
|
|
13
|
+
* duplicated here.
|
|
14
|
+
*/
|
|
15
|
+
export interface SeoSettingsForm {
|
|
16
|
+
/** Site-wide title template (`%page% — %site%`), applied when a page has none. */
|
|
17
|
+
titleTemplate: string;
|
|
18
|
+
/** Fallback meta description when a page/post defines none. */
|
|
19
|
+
defaultMetaDescription: string;
|
|
20
|
+
/**
|
|
21
|
+
* Fallback social (Open Graph / Twitter) image URL. Kept as a denormalized
|
|
22
|
+
* cache of the selected asset's public URL so the synchronous public renderer
|
|
23
|
+
* doesn't need a DB lookup. Empty = use the dynamic /og.png fallback.
|
|
24
|
+
*/
|
|
25
|
+
defaultOgImage: string;
|
|
26
|
+
/** Canonical reference to the selected media asset (source of truth). */
|
|
27
|
+
defaultSocialImageAssetId: string;
|
|
28
|
+
googleVerification: string;
|
|
29
|
+
bingVerification: string;
|
|
30
|
+
yandexVerification: string;
|
|
31
|
+
pinterestVerification: string;
|
|
32
|
+
facebookDomainVerification: string;
|
|
33
|
+
/** Rebuild the sitemap whenever content is published (`module`). */
|
|
34
|
+
autoGenerateSitemap: boolean;
|
|
35
|
+
/** Notify Google/Bing when the sitemap changes (`module`). */
|
|
36
|
+
pingOnPublish: boolean;
|
|
37
|
+
}
|
|
38
|
+
export interface SitemapSummary {
|
|
39
|
+
sitemapUrl: string;
|
|
40
|
+
lastGeneratedAt: string | null;
|
|
41
|
+
pagesIncluded: number;
|
|
42
|
+
pagesExcluded: number;
|
|
43
|
+
warnings: string[];
|
|
44
|
+
}
|
|
45
|
+
/** Recommended max length for the default meta description (SERP truncation). */
|
|
46
|
+
export declare const META_DESCRIPTION_MAX = 160;
|
|
47
|
+
/** Below this, the description is likely too thin to be useful. */
|
|
48
|
+
export declare const META_DESCRIPTION_MIN = 70;
|
|
49
|
+
export declare function validateTitleTemplateClient(template: string): {
|
|
50
|
+
valid: boolean;
|
|
51
|
+
unknownTokens: string[];
|
|
52
|
+
};
|
|
53
|
+
type SaveState = 'idle' | 'saving' | 'success' | 'error';
|
|
54
|
+
export interface UseSeoSettings {
|
|
55
|
+
loading: boolean;
|
|
56
|
+
loadError: string | null;
|
|
57
|
+
form: SeoSettingsForm;
|
|
58
|
+
setField: <K extends keyof SeoSettingsForm>(key: K, value: SeoSettingsForm[K]) => void;
|
|
59
|
+
dirty: boolean;
|
|
60
|
+
issues: ValidationIssue[];
|
|
61
|
+
hasErrors: boolean;
|
|
62
|
+
sources: Record<string, SettingsSourceMetadata>;
|
|
63
|
+
/** Effective site name (read-only here; edited in General/advanced) for preview. */
|
|
64
|
+
siteName: string;
|
|
65
|
+
/** Effective site URL for preview + warnings. */
|
|
66
|
+
siteUrl: string;
|
|
67
|
+
isProduction: boolean;
|
|
68
|
+
sitemap: SitemapSummary | null;
|
|
69
|
+
saveState: SaveState;
|
|
70
|
+
saveError: string | null;
|
|
71
|
+
save: () => Promise<void>;
|
|
72
|
+
reset: () => void;
|
|
73
|
+
refetch: () => void;
|
|
74
|
+
regenerateSitemap: () => Promise<void>;
|
|
75
|
+
regenerating: boolean;
|
|
76
|
+
}
|
|
77
|
+
export declare function useSeoSettings(canEdit: boolean): UseSeoSettings;
|
|
78
|
+
export {};
|
|
79
|
+
//# sourceMappingURL=useSeoSettings.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"useSeoSettings.d.ts","sourceRoot":"","sources":["../../../src/views/settings/useSeoSettings.ts"],"names":[],"mappings":"AAIA,OAAO,KAAK,EAAE,sBAAsB,EAAE,eAAe,EAAE,MAAM,yBAAyB,CAAA;AAEtF;;;;;;;;;;;;GAYG;AACH,MAAM,WAAW,eAAe;IAC9B,kFAAkF;IAClF,aAAa,EAAE,MAAM,CAAA;IACrB,+DAA+D;IAC/D,sBAAsB,EAAE,MAAM,CAAA;IAC9B;;;;OAIG;IACH,cAAc,EAAE,MAAM,CAAA;IACtB,yEAAyE;IACzE,yBAAyB,EAAE,MAAM,CAAA;IACjC,kBAAkB,EAAE,MAAM,CAAA;IAC1B,gBAAgB,EAAE,MAAM,CAAA;IACxB,kBAAkB,EAAE,MAAM,CAAA;IAC1B,qBAAqB,EAAE,MAAM,CAAA;IAC7B,0BAA0B,EAAE,MAAM,CAAA;IAClC,oEAAoE;IACpE,mBAAmB,EAAE,OAAO,CAAA;IAC5B,8DAA8D;IAC9D,aAAa,EAAE,OAAO,CAAA;CACvB;AAED,MAAM,WAAW,cAAc;IAC7B,UAAU,EAAE,MAAM,CAAA;IAClB,eAAe,EAAE,MAAM,GAAG,IAAI,CAAA;IAC9B,aAAa,EAAE,MAAM,CAAA;IACrB,aAAa,EAAE,MAAM,CAAA;IACrB,QAAQ,EAAE,MAAM,EAAE,CAAA;CACnB;AAgBD,iFAAiF;AACjF,eAAO,MAAM,oBAAoB,MAAM,CAAA;AACvC,mEAAmE;AACnE,eAAO,MAAM,oBAAoB,KAAK,CAAA;AAQtC,wBAAgB,2BAA2B,CAAC,QAAQ,EAAE,MAAM,GAAG;IAC7D,KAAK,EAAE,OAAO,CAAA;IACd,aAAa,EAAE,MAAM,EAAE,CAAA;CACxB,CAcA;AAwCD,KAAK,SAAS,GAAG,MAAM,GAAG,QAAQ,GAAG,SAAS,GAAG,OAAO,CAAA;AAExD,MAAM,WAAW,cAAc;IAC7B,OAAO,EAAE,OAAO,CAAA;IAChB,SAAS,EAAE,MAAM,GAAG,IAAI,CAAA;IACxB,IAAI,EAAE,eAAe,CAAA;IACrB,QAAQ,EAAE,CAAC,CAAC,SAAS,MAAM,eAAe,EAAE,GAAG,EAAE,CAAC,EAAE,KAAK,EAAE,eAAe,CAAC,CAAC,CAAC,KAAK,IAAI,CAAA;IACtF,KAAK,EAAE,OAAO,CAAA;IACd,MAAM,EAAE,eAAe,EAAE,CAAA;IACzB,SAAS,EAAE,OAAO,CAAA;IAClB,OAAO,EAAE,MAAM,CAAC,MAAM,EAAE,sBAAsB,CAAC,CAAA;IAC/C,oFAAoF;IACpF,QAAQ,EAAE,MAAM,CAAA;IAChB,iDAAiD;IACjD,OAAO,EAAE,MAAM,CAAA;IACf,YAAY,EAAE,OAAO,CAAA;IACrB,OAAO,EAAE,cAAc,GAAG,IAAI,CAAA;IAC9B,SAAS,EAAE,SAAS,CAAA;IACpB,SAAS,EAAE,MAAM,GAAG,IAAI,CAAA;IACxB,IAAI,EAAE,MAAM,OAAO,CAAC,IAAI,CAAC,CAAA;IACzB,KAAK,EAAE,MAAM,IAAI,CAAA;IACjB,OAAO,EAAE,MAAM,IAAI,CAAA;IACnB,iBAAiB,EAAE,MAAM,OAAO,CAAC,IAAI,CAAC,CAAA;IACtC,YAAY,EAAE,OAAO,CAAA;CACtB;AAED,wBAAgB,cAAc,CAAC,OAAO,EAAE,OAAO,GAAG,cAAc,CAsN/D"}
|
|
@@ -0,0 +1,278 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
import { useCallback, useEffect, useMemo, useState } from 'react';
|
|
3
|
+
import { cmsApi } from '../../lib/api.js';
|
|
4
|
+
const EMPTY_FORM = {
|
|
5
|
+
titleTemplate: '',
|
|
6
|
+
defaultMetaDescription: '',
|
|
7
|
+
defaultOgImage: '',
|
|
8
|
+
defaultSocialImageAssetId: '',
|
|
9
|
+
googleVerification: '',
|
|
10
|
+
bingVerification: '',
|
|
11
|
+
yandexVerification: '',
|
|
12
|
+
pinterestVerification: '',
|
|
13
|
+
facebookDomainVerification: '',
|
|
14
|
+
autoGenerateSitemap: true,
|
|
15
|
+
pingOnPublish: true,
|
|
16
|
+
};
|
|
17
|
+
/** Recommended max length for the default meta description (SERP truncation). */
|
|
18
|
+
export const META_DESCRIPTION_MAX = 160;
|
|
19
|
+
/** Below this, the description is likely too thin to be useful. */
|
|
20
|
+
export const META_DESCRIPTION_MIN = 70;
|
|
21
|
+
// Mirror of cms-core's `SITE_TITLE_TOKENS` (kept tiny + stable so the client can
|
|
22
|
+
// validate without importing the full cms-core module graph). The server
|
|
23
|
+
// re-validates on save via `validateTitleTemplate`, so this is a UX aid only.
|
|
24
|
+
const SITE_TITLE_TOKENS = ['page', 'site', 'type', 'separator', 'author', 'date', 'category', 'tag'];
|
|
25
|
+
const TOKEN_RE = /%(\w+)%/g;
|
|
26
|
+
export function validateTitleTemplateClient(template) {
|
|
27
|
+
if (!template || template.trim() === '')
|
|
28
|
+
return { valid: true, unknownTokens: [] };
|
|
29
|
+
const known = new Set(SITE_TITLE_TOKENS);
|
|
30
|
+
const unknown = [];
|
|
31
|
+
let hasKnown = false;
|
|
32
|
+
for (const m of template.matchAll(TOKEN_RE)) {
|
|
33
|
+
const t = m[1];
|
|
34
|
+
if (known.has(t))
|
|
35
|
+
hasKnown = true;
|
|
36
|
+
else if (!unknown.includes(t))
|
|
37
|
+
unknown.push(t);
|
|
38
|
+
}
|
|
39
|
+
const literal = template.replace(TOKEN_RE, '').trim();
|
|
40
|
+
if (unknown.length > 0)
|
|
41
|
+
return { valid: false, unknownTokens: unknown };
|
|
42
|
+
if (!hasKnown && literal === '')
|
|
43
|
+
return { valid: false, unknownTokens: [] };
|
|
44
|
+
return { valid: true, unknownTokens: [] };
|
|
45
|
+
}
|
|
46
|
+
function validate(form) {
|
|
47
|
+
const issues = [];
|
|
48
|
+
const tpl = validateTitleTemplateClient(form.titleTemplate);
|
|
49
|
+
if (!tpl.valid) {
|
|
50
|
+
issues.push({
|
|
51
|
+
field: 'titleTemplate',
|
|
52
|
+
message: tpl.unknownTokens.length > 0
|
|
53
|
+
? `Unsupported token${tpl.unknownTokens.length > 1 ? 's' : ''}: ${tpl.unknownTokens
|
|
54
|
+
.map((t) => `%${t}%`)
|
|
55
|
+
.join(', ')}`
|
|
56
|
+
: 'Template must include at least one token or some literal text.',
|
|
57
|
+
severity: 'error',
|
|
58
|
+
});
|
|
59
|
+
}
|
|
60
|
+
const desc = form.defaultMetaDescription.trim();
|
|
61
|
+
if (desc.length > META_DESCRIPTION_MAX) {
|
|
62
|
+
issues.push({
|
|
63
|
+
field: 'defaultMetaDescription',
|
|
64
|
+
message: `Default description is ${desc.length} characters — search engines truncate around ${META_DESCRIPTION_MAX}.`,
|
|
65
|
+
severity: 'warning',
|
|
66
|
+
});
|
|
67
|
+
}
|
|
68
|
+
else if (desc.length > 0 && desc.length < META_DESCRIPTION_MIN) {
|
|
69
|
+
issues.push({
|
|
70
|
+
field: 'defaultMetaDescription',
|
|
71
|
+
message: `Default description is short (${desc.length} characters). Aim for ${META_DESCRIPTION_MIN}–${META_DESCRIPTION_MAX}.`,
|
|
72
|
+
severity: 'warning',
|
|
73
|
+
});
|
|
74
|
+
}
|
|
75
|
+
return issues;
|
|
76
|
+
}
|
|
77
|
+
export function useSeoSettings(canEdit) {
|
|
78
|
+
const [loading, setLoading] = useState(true);
|
|
79
|
+
const [loadError, setLoadError] = useState(null);
|
|
80
|
+
const [baseline, setBaseline] = useState(EMPTY_FORM);
|
|
81
|
+
const [form, setForm] = useState(EMPTY_FORM);
|
|
82
|
+
const [overrideSite, setOverrideSite] = useState({});
|
|
83
|
+
const [sources, setSources] = useState({});
|
|
84
|
+
const [siteName, setSiteName] = useState('');
|
|
85
|
+
const [siteUrl, setSiteUrl] = useState('');
|
|
86
|
+
const [isProduction, setIsProduction] = useState(false);
|
|
87
|
+
const [sitemap, setSitemap] = useState(null);
|
|
88
|
+
const [saveState, setSaveState] = useState('idle');
|
|
89
|
+
const [saveError, setSaveError] = useState(null);
|
|
90
|
+
const [regenerating, setRegenerating] = useState(false);
|
|
91
|
+
const [reloadKey, setReloadKey] = useState(0);
|
|
92
|
+
useEffect(() => {
|
|
93
|
+
let cancelled = false;
|
|
94
|
+
setLoading(true);
|
|
95
|
+
setLoadError(null);
|
|
96
|
+
Promise.all([
|
|
97
|
+
cmsApi('/seo/config'),
|
|
98
|
+
cmsApi('/seo/crawl-settings'),
|
|
99
|
+
cmsApi('/seo/sitemap/status'),
|
|
100
|
+
cmsApi('/seo/index-status'),
|
|
101
|
+
])
|
|
102
|
+
.then(([seoRes, crawlRes, sitemapRes, statusRes]) => {
|
|
103
|
+
if (cancelled)
|
|
104
|
+
return;
|
|
105
|
+
if (seoRes.error && seoRes.status !== 404)
|
|
106
|
+
setLoadError(seoRes.error);
|
|
107
|
+
const seo = seoRes.data ?? {};
|
|
108
|
+
const effectiveSite = seo.effective?.site ?? {};
|
|
109
|
+
const staticSite = seo.static?.site ?? {};
|
|
110
|
+
const overSite = seo.overrides?.site ?? {};
|
|
111
|
+
const verification = effectiveSite.verification ?? {};
|
|
112
|
+
const crawl = crawlRes.data ?? {};
|
|
113
|
+
const loaded = {
|
|
114
|
+
titleTemplate: String(effectiveSite.titleTemplate ?? ''),
|
|
115
|
+
defaultMetaDescription: String(effectiveSite.defaultMetaDescription ?? ''),
|
|
116
|
+
defaultOgImage: String(effectiveSite.defaultOgImage ?? ''),
|
|
117
|
+
defaultSocialImageAssetId: String(effectiveSite.defaultSocialImageAssetId ?? ''),
|
|
118
|
+
googleVerification: String(verification.googleSearchConsole ?? ''),
|
|
119
|
+
bingVerification: String(verification.bingWebmasterTools ?? ''),
|
|
120
|
+
yandexVerification: String(verification.yandex ?? ''),
|
|
121
|
+
pinterestVerification: String(verification.pinterest ?? ''),
|
|
122
|
+
facebookDomainVerification: String(verification.facebookDomain ?? ''),
|
|
123
|
+
autoGenerateSitemap: crawl.autoRegenerateSitemapOnPublish !== false,
|
|
124
|
+
pingOnPublish: crawl.pingSearchEnginesOnPublish !== false,
|
|
125
|
+
};
|
|
126
|
+
// `editable` reflects the field's MANAGEMENT source (config vs database),
|
|
127
|
+
// not the user's role — role gates the inputs via `canEdit`. This keeps
|
|
128
|
+
// the source badge (and the field's accessible label) stable across
|
|
129
|
+
// roles, and only surfaces it when a value is locked by config.
|
|
130
|
+
const configManaged = (key) => staticSite[key]
|
|
131
|
+
? { source: 'config', editable: false, reason: 'Set in actuate.config.ts' }
|
|
132
|
+
: { source: 'database', editable: true };
|
|
133
|
+
setSources({
|
|
134
|
+
titleTemplate: configManaged('titleTemplate'),
|
|
135
|
+
defaultMetaDescription: configManaged('defaultMetaDescription'),
|
|
136
|
+
defaultOgImage: configManaged('defaultOgImage'),
|
|
137
|
+
verification: { source: 'database', editable: true },
|
|
138
|
+
sitemap: { source: 'database', editable: true },
|
|
139
|
+
});
|
|
140
|
+
setBaseline(loaded);
|
|
141
|
+
setForm(loaded);
|
|
142
|
+
setOverrideSite(overSite ?? {});
|
|
143
|
+
setSiteName(String(effectiveSite.siteName ?? ''));
|
|
144
|
+
setSiteUrl(String(effectiveSite.siteUrl ?? ''));
|
|
145
|
+
if (statusRes.data?.environment)
|
|
146
|
+
setIsProduction(statusRes.data.environment === 'production');
|
|
147
|
+
if (!sitemapRes.error && sitemapRes.data)
|
|
148
|
+
setSitemap(sitemapRes.data);
|
|
149
|
+
})
|
|
150
|
+
.catch(() => {
|
|
151
|
+
if (!cancelled)
|
|
152
|
+
setLoadError('Failed to load SEO settings.');
|
|
153
|
+
})
|
|
154
|
+
.finally(() => {
|
|
155
|
+
if (!cancelled)
|
|
156
|
+
setLoading(false);
|
|
157
|
+
});
|
|
158
|
+
return () => {
|
|
159
|
+
cancelled = true;
|
|
160
|
+
};
|
|
161
|
+
}, [reloadKey, canEdit]);
|
|
162
|
+
const setField = useCallback((key, value) => {
|
|
163
|
+
setForm((prev) => ({ ...prev, [key]: value }));
|
|
164
|
+
setSaveState('idle');
|
|
165
|
+
}, []);
|
|
166
|
+
const dirty = useMemo(() => Object.keys(form).some((k) => form[k] !== baseline[k]), [form, baseline]);
|
|
167
|
+
const issues = useMemo(() => validate(form), [form]);
|
|
168
|
+
const hasErrors = issues.some((i) => i.severity === 'error');
|
|
169
|
+
const refetchSitemap = useCallback(async () => {
|
|
170
|
+
const res = await cmsApi('/seo/sitemap/status');
|
|
171
|
+
if (!res.error && res.data)
|
|
172
|
+
setSitemap(res.data);
|
|
173
|
+
}, []);
|
|
174
|
+
const save = useCallback(async () => {
|
|
175
|
+
if (!canEdit || hasErrors)
|
|
176
|
+
return;
|
|
177
|
+
setSaveState('saving');
|
|
178
|
+
setSaveError(null);
|
|
179
|
+
const siteChanged = form.titleTemplate !== baseline.titleTemplate ||
|
|
180
|
+
form.defaultMetaDescription !== baseline.defaultMetaDescription ||
|
|
181
|
+
form.defaultOgImage !== baseline.defaultOgImage ||
|
|
182
|
+
form.defaultSocialImageAssetId !== baseline.defaultSocialImageAssetId ||
|
|
183
|
+
form.googleVerification !== baseline.googleVerification ||
|
|
184
|
+
form.bingVerification !== baseline.bingVerification ||
|
|
185
|
+
form.yandexVerification !== baseline.yandexVerification ||
|
|
186
|
+
form.pinterestVerification !== baseline.pinterestVerification ||
|
|
187
|
+
form.facebookDomainVerification !== baseline.facebookDomainVerification;
|
|
188
|
+
const sitemapChanged = form.autoGenerateSitemap !== baseline.autoGenerateSitemap ||
|
|
189
|
+
form.pingOnPublish !== baseline.pingOnPublish;
|
|
190
|
+
try {
|
|
191
|
+
// 1. Meta defaults + verification → shared /seo/config (merge over the
|
|
192
|
+
// current override site so General's siteUrl/robots aren't wiped).
|
|
193
|
+
if (siteChanged) {
|
|
194
|
+
const nextSite = {
|
|
195
|
+
...overrideSite,
|
|
196
|
+
titleTemplate: form.titleTemplate.trim() || undefined,
|
|
197
|
+
defaultMetaDescription: form.defaultMetaDescription.trim() || undefined,
|
|
198
|
+
defaultOgImage: form.defaultOgImage.trim() || undefined,
|
|
199
|
+
defaultSocialImageAssetId: form.defaultSocialImageAssetId.trim() || undefined,
|
|
200
|
+
verification: {
|
|
201
|
+
...(overrideSite.verification ?? {}),
|
|
202
|
+
googleSearchConsole: form.googleVerification.trim() || undefined,
|
|
203
|
+
bingWebmasterTools: form.bingVerification.trim() || undefined,
|
|
204
|
+
yandex: form.yandexVerification.trim() || undefined,
|
|
205
|
+
pinterest: form.pinterestVerification.trim() || undefined,
|
|
206
|
+
facebookDomain: form.facebookDomainVerification.trim() || undefined,
|
|
207
|
+
},
|
|
208
|
+
};
|
|
209
|
+
const res = await cmsApi('/seo/config', {
|
|
210
|
+
method: 'PUT',
|
|
211
|
+
body: JSON.stringify({ site: nextSite }),
|
|
212
|
+
});
|
|
213
|
+
if (res.error)
|
|
214
|
+
throw new Error(res.error);
|
|
215
|
+
setOverrideSite(nextSite);
|
|
216
|
+
}
|
|
217
|
+
// 2. Sitemap toggles → shared /seo/crawl-settings (`module`).
|
|
218
|
+
if (sitemapChanged) {
|
|
219
|
+
const res = await cmsApi('/seo/crawl-settings', {
|
|
220
|
+
method: 'PUT',
|
|
221
|
+
body: JSON.stringify({
|
|
222
|
+
autoRegenerateSitemapOnPublish: form.autoGenerateSitemap,
|
|
223
|
+
pingSearchEnginesOnPublish: form.pingOnPublish,
|
|
224
|
+
}),
|
|
225
|
+
});
|
|
226
|
+
if (res.error)
|
|
227
|
+
throw new Error(res.error);
|
|
228
|
+
}
|
|
229
|
+
setBaseline(form);
|
|
230
|
+
setSaveState('success');
|
|
231
|
+
if (sitemapChanged)
|
|
232
|
+
await refetchSitemap();
|
|
233
|
+
}
|
|
234
|
+
catch (err) {
|
|
235
|
+
setSaveState('error');
|
|
236
|
+
setSaveError(err instanceof Error ? err.message : 'Failed to save SEO settings.');
|
|
237
|
+
}
|
|
238
|
+
}, [canEdit, hasErrors, form, baseline, overrideSite, refetchSitemap]);
|
|
239
|
+
const reset = useCallback(() => {
|
|
240
|
+
setForm(baseline);
|
|
241
|
+
setSaveState('idle');
|
|
242
|
+
setSaveError(null);
|
|
243
|
+
}, [baseline]);
|
|
244
|
+
const refetch = useCallback(() => setReloadKey((k) => k + 1), []);
|
|
245
|
+
const regenerateSitemap = useCallback(async () => {
|
|
246
|
+
setRegenerating(true);
|
|
247
|
+
try {
|
|
248
|
+
const res = await cmsApi('/seo/sitemap/regenerate', { method: 'POST' });
|
|
249
|
+
if (!res.error && res.data)
|
|
250
|
+
setSitemap((prev) => ({ ...(prev ?? res.data), ...res.data }));
|
|
251
|
+
}
|
|
252
|
+
finally {
|
|
253
|
+
setRegenerating(false);
|
|
254
|
+
}
|
|
255
|
+
}, []);
|
|
256
|
+
return {
|
|
257
|
+
loading,
|
|
258
|
+
loadError,
|
|
259
|
+
form,
|
|
260
|
+
setField,
|
|
261
|
+
dirty,
|
|
262
|
+
issues,
|
|
263
|
+
hasErrors,
|
|
264
|
+
sources,
|
|
265
|
+
siteName,
|
|
266
|
+
siteUrl,
|
|
267
|
+
isProduction,
|
|
268
|
+
sitemap,
|
|
269
|
+
saveState,
|
|
270
|
+
saveError,
|
|
271
|
+
save,
|
|
272
|
+
reset,
|
|
273
|
+
refetch,
|
|
274
|
+
regenerateSitemap,
|
|
275
|
+
regenerating,
|
|
276
|
+
};
|
|
277
|
+
}
|
|
278
|
+
//# sourceMappingURL=useSeoSettings.js.map
|