@actuate-media/cms-admin 0.32.0 → 0.33.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (28) hide show
  1. package/dist/__tests__/views/seo-settings.render.test.d.ts +2 -0
  2. package/dist/__tests__/views/seo-settings.render.test.d.ts.map +1 -0
  3. package/dist/__tests__/views/seo-settings.render.test.js +152 -0
  4. package/dist/__tests__/views/seo-settings.render.test.js.map +1 -0
  5. package/dist/__tests__/views/settings-save-footer.render.test.js +16 -9
  6. package/dist/__tests__/views/settings-save-footer.render.test.js.map +1 -1
  7. package/dist/actuate-admin.css +1 -1
  8. package/dist/components/MediaPickerModal.d.ts +15 -1
  9. package/dist/components/MediaPickerModal.d.ts.map +1 -1
  10. package/dist/components/MediaPickerModal.js +17 -1
  11. package/dist/components/MediaPickerModal.js.map +1 -1
  12. package/dist/views/Settings.js +2 -2
  13. package/dist/views/Settings.js.map +1 -1
  14. package/dist/views/settings/SeoSettingsTab.d.ts +7 -0
  15. package/dist/views/settings/SeoSettingsTab.d.ts.map +1 -0
  16. package/dist/views/settings/SeoSettingsTab.js +126 -0
  17. package/dist/views/settings/SeoSettingsTab.js.map +1 -0
  18. package/dist/views/settings/useSeoSettings.d.ts +79 -0
  19. package/dist/views/settings/useSeoSettings.d.ts.map +1 -0
  20. package/dist/views/settings/useSeoSettings.js +278 -0
  21. package/dist/views/settings/useSeoSettings.js.map +1 -0
  22. package/package.json +2 -2
  23. package/src/__tests__/views/seo-settings.render.test.tsx +219 -0
  24. package/src/__tests__/views/settings-save-footer.render.test.tsx +16 -9
  25. package/src/components/MediaPickerModal.tsx +39 -2
  26. package/src/views/Settings.tsx +2 -2
  27. package/src/views/settings/SeoSettingsTab.tsx +658 -0
  28. 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
+ 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
@@ -0,0 +1 @@
1
+ {"version":3,"file":"useSeoSettings.js","sourceRoot":"","sources":["../../../src/views/settings/useSeoSettings.ts"],"names":[],"mappings":"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"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@actuate-media/cms-admin",
3
- "version": "0.32.0",
3
+ "version": "0.33.0",
4
4
  "repository": {
5
5
  "type": "git",
6
6
  "url": "https://github.com/actuate-media/actuatecms.git",
@@ -88,7 +88,7 @@
88
88
  "tailwindcss": "^4.0.0",
89
89
  "typescript": "^5.7.0",
90
90
  "vitest": "^4.1.0",
91
- "@actuate-media/cms-core": "0.41.0",
91
+ "@actuate-media/cms-core": "0.42.0",
92
92
  "@actuate-media/component-blocks": "0.2.0"
93
93
  },
94
94
  "scripts": {
@@ -0,0 +1,219 @@
1
+ // @vitest-environment happy-dom
2
+ import { beforeEach, describe, expect, it, vi } from 'vitest'
3
+ import { fireEvent, render, screen, waitFor, within } from '@testing-library/react'
4
+
5
+ // The SEO defaults tab reads four shared backends: /seo/config (meta defaults +
6
+ // verification), /seo/crawl-settings (sitemap toggles), /seo/sitemap/status
7
+ // (read-only summary), and /seo/index-status (environment).
8
+ const cmsApi = vi.fn(async (endpoint: string, options?: { method?: string }) => {
9
+ const method = options?.method ?? 'GET'
10
+ if (endpoint === '/seo/config' && method === 'GET') {
11
+ return {
12
+ data: {
13
+ static: { site: null },
14
+ overrides: { site: {} },
15
+ effective: {
16
+ site: {
17
+ siteName: 'Acme',
18
+ siteUrl: 'https://acme.test',
19
+ titleTemplate: '%page% — %site%',
20
+ defaultMetaDescription: 'Seed description.',
21
+ verification: { googleSearchConsole: '', bingWebmasterTools: '' },
22
+ },
23
+ },
24
+ collections: [],
25
+ },
26
+ status: 200,
27
+ }
28
+ }
29
+ if (endpoint === '/seo/crawl-settings' && method === 'GET') {
30
+ return {
31
+ data: {
32
+ autoRegenerateSitemapOnPublish: true,
33
+ pingSearchEnginesOnPublish: true,
34
+ sitemapEnabled: true,
35
+ },
36
+ status: 200,
37
+ }
38
+ }
39
+ if (endpoint === '/seo/sitemap/status' && method === 'GET') {
40
+ return {
41
+ data: {
42
+ sitemapUrl: 'https://acme.test/api/cms/sitemap.xml',
43
+ lastGeneratedAt: '2026-01-02T03:04:05.000Z',
44
+ pagesIncluded: 12,
45
+ pagesExcluded: 1,
46
+ warnings: [],
47
+ },
48
+ status: 200,
49
+ }
50
+ }
51
+ if (endpoint === '/seo/index-status' && method === 'GET') {
52
+ return { data: { environment: 'preview' }, status: 200 }
53
+ }
54
+ return { data: {}, status: 200 }
55
+ })
56
+
57
+ vi.mock('../../lib/api.js', () => ({
58
+ cmsApi: (e: string, o?: unknown) => cmsApi(e, o as any),
59
+ ensureCsrfToken: vi.fn(async () => {}),
60
+ }))
61
+
62
+ // Stub the Media Center picker: when open, expose a button that selects a known
63
+ // asset (id + url), exercising the asset-id storage path without the real
64
+ // MediaBrowser/network stack.
65
+ vi.mock('../../components/MediaPickerModal.js', () => ({
66
+ MediaPickerModal: ({
67
+ open,
68
+ onSelect,
69
+ onSelectItem,
70
+ }: {
71
+ open: boolean
72
+ onSelect: (url: string) => void
73
+ onSelectItem?: (item: { id: string }) => void
74
+ }) =>
75
+ open ? (
76
+ <button
77
+ type="button"
78
+ onClick={() => {
79
+ onSelect('https://cdn.test/og.png')
80
+ onSelectItem?.({ id: 'asset-123' })
81
+ }}
82
+ >
83
+ pick-asset
84
+ </button>
85
+ ) : null,
86
+ }))
87
+
88
+ const { SeoSettingsTab } = await import('../../views/settings/SeoSettingsTab.js')
89
+
90
+ beforeEach(() => {
91
+ cmsApi.mockClear()
92
+ })
93
+
94
+ describe('SeoSettingsTab', () => {
95
+ it('renders the workspace callout and loads meta defaults from /seo/config', async () => {
96
+ render(<SeoSettingsTab role="ADMIN" />)
97
+
98
+ expect(await screen.findByText('Looking for the full SEO workspace?')).toBeTruthy()
99
+ expect((screen.getByLabelText('Title template') as HTMLInputElement).value).toBe(
100
+ '%page% — %site%',
101
+ )
102
+ expect((screen.getByLabelText('Default meta description') as HTMLTextAreaElement).value).toBe(
103
+ 'Seed description.',
104
+ )
105
+ // Clean form → Save disabled.
106
+ expect(
107
+ (screen.getByRole('button', { name: 'Save Changes' }) as HTMLButtonElement).disabled,
108
+ ).toBe(true)
109
+ })
110
+
111
+ it('routes "Open SEO" to /seo', async () => {
112
+ const onNavigate = vi.fn()
113
+ render(<SeoSettingsTab role="ADMIN" onNavigate={onNavigate} />)
114
+ await screen.findByText('Looking for the full SEO workspace?')
115
+ fireEvent.click(screen.getByRole('button', { name: /Open SEO/ }))
116
+ expect(onNavigate).toHaveBeenCalledWith('/seo')
117
+ })
118
+
119
+ it('rejects an unsupported title-template token and blocks save', async () => {
120
+ render(<SeoSettingsTab role="ADMIN" />)
121
+ const tpl = (await screen.findByLabelText('Title template')) as HTMLInputElement
122
+ fireEvent.change(tpl, { target: { value: '%page% %bogus%' } })
123
+
124
+ expect((await screen.findAllByText(/Unsupported token/i)).length).toBeGreaterThan(0)
125
+ expect(
126
+ (screen.getByRole('button', { name: 'Save Changes' }) as HTMLButtonElement).disabled,
127
+ ).toBe(true)
128
+ })
129
+
130
+ it('shows a character count and warns when the description is too long', async () => {
131
+ render(<SeoSettingsTab role="ADMIN" />)
132
+ const desc = (await screen.findByLabelText('Default meta description')) as HTMLTextAreaElement
133
+ fireEvent.change(desc, { target: { value: 'x'.repeat(200) } })
134
+
135
+ expect(screen.getByText('200/160')).toBeTruthy()
136
+ expect((await screen.findAllByText(/truncate/i)).length).toBeGreaterThan(0)
137
+ })
138
+
139
+ it('persists meta defaults + verification to the shared /seo/config', async () => {
140
+ render(<SeoSettingsTab role="ADMIN" />)
141
+ const google = (await screen.findByLabelText('Google Search Console')) as HTMLInputElement
142
+ fireEvent.change(google, { target: { value: 'goog-token' } })
143
+
144
+ const save = screen.getByRole('button', { name: 'Save Changes' }) as HTMLButtonElement
145
+ expect(save.disabled).toBe(false)
146
+ fireEvent.click(save)
147
+
148
+ await waitFor(() =>
149
+ expect(cmsApi).toHaveBeenCalledWith(
150
+ '/seo/config',
151
+ expect.objectContaining({ method: 'PUT' }),
152
+ ),
153
+ )
154
+ const putCall = cmsApi.mock.calls.find(
155
+ ([e, o]) => e === '/seo/config' && (o as { method?: string })?.method === 'PUT',
156
+ )
157
+ const body = JSON.parse((putCall![1] as { body: string }).body)
158
+ expect(body.site.verification.googleSearchConsole).toBe('goog-token')
159
+ })
160
+
161
+ it('stores the selected social image asset id from the Media Center picker', async () => {
162
+ render(<SeoSettingsTab role="ADMIN" />)
163
+ await screen.findByLabelText('Title template')
164
+
165
+ fireEvent.click(screen.getByRole('button', { name: 'Choose image' }))
166
+ fireEvent.click(screen.getByRole('button', { name: 'pick-asset' }))
167
+
168
+ const save = screen.getByRole('button', { name: 'Save Changes' }) as HTMLButtonElement
169
+ expect(save.disabled).toBe(false)
170
+ fireEvent.click(save)
171
+
172
+ await waitFor(() =>
173
+ expect(cmsApi).toHaveBeenCalledWith(
174
+ '/seo/config',
175
+ expect.objectContaining({ method: 'PUT' }),
176
+ ),
177
+ )
178
+ const putCall = cmsApi.mock.calls.find(
179
+ ([e, o]) => e === '/seo/config' && (o as { method?: string })?.method === 'PUT',
180
+ )
181
+ const body = JSON.parse((putCall![1] as { body: string }).body)
182
+ expect(body.site.defaultSocialImageAssetId).toBe('asset-123')
183
+ expect(body.site.defaultOgImage).toBe('https://cdn.test/og.png')
184
+ })
185
+
186
+ it('persists sitemap toggles to the shared /seo/crawl-settings', async () => {
187
+ render(<SeoSettingsTab role="ADMIN" />)
188
+ await screen.findByLabelText('Title template')
189
+ fireEvent.click(screen.getByRole('switch', { name: 'Ping search engines on publish' }))
190
+
191
+ fireEvent.click(screen.getByRole('button', { name: 'Save Changes' }))
192
+
193
+ await waitFor(() =>
194
+ expect(cmsApi).toHaveBeenCalledWith(
195
+ '/seo/crawl-settings',
196
+ expect.objectContaining({ method: 'PUT' }),
197
+ ),
198
+ )
199
+ })
200
+
201
+ it('shows the read-only sitemap summary', async () => {
202
+ render(<SeoSettingsTab role="ADMIN" />)
203
+ await screen.findByLabelText('Title template')
204
+ expect(screen.getByText('Included URLs')).toBeTruthy()
205
+ expect(screen.getByText('12')).toBeTruthy()
206
+ expect(screen.getByRole('link', { name: /View sitemap/ })).toBeTruthy()
207
+ })
208
+
209
+ it('is read-only for non-admins', async () => {
210
+ render(<SeoSettingsTab role="EDITOR" />)
211
+ expect(await screen.findByText(/read-only access to SEO defaults/i)).toBeTruthy()
212
+ // Scope to the Meta Defaults region: the advanced SEOConfigPanel disclosure
213
+ // also mounts and can briefly produce overlapping label matches.
214
+ const metaCard = screen.getByRole('region', { name: 'Meta Defaults' })
215
+ expect((within(metaCard).getByLabelText('Title template') as HTMLInputElement).disabled).toBe(
216
+ true,
217
+ )
218
+ })
219
+ })
@@ -3,11 +3,16 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'
3
3
  import { render, screen, waitFor } from '@testing-library/react'
4
4
 
5
5
  /**
6
- * Regression: the shared "Save Changes" footer must only render on tabs that
7
- * actually persist through `handleSave` (currently just Layout, which writes
8
- * `defaultLayout` into the settings global). On self-managed tabs like SEO it
9
- * was rendering a footer whose click toasted "Settings saved successfully!"
10
- * while persisting none of the tab's edits — misleading, no-op UI.
6
+ * Regression: the shared no-op "Save Changes" footer (driven by the settings
7
+ * global `handleSave`) must only render on tabs that actually persist through
8
+ * it (currently just Layout). On self-managed tabs like SEO it used to render a
9
+ * SECOND footer whose click toasted "Settings saved successfully!" while
10
+ * persisting none of the tab's edits — misleading, no-op UI.
11
+ *
12
+ * The SEO tab now owns a real `SettingsSaveBar` (also labelled "Save Changes")
13
+ * that persists through `/seo/config`, so the contract is: exactly ONE
14
+ * "Save Changes" button on the SEO tab — the tab's own — never the global one
15
+ * stacked on top of it.
11
16
  */
12
17
  const SEO_CONFIG = {
13
18
  static: { site: null },
@@ -44,15 +49,17 @@ beforeEach(() => {
44
49
  })
45
50
 
46
51
  describe('Settings shared save footer', () => {
47
- it('does NOT render the global "Save Changes" footer on the self-managed SEO tab', async () => {
52
+ it("renders only the SEO tab's own save bar, not the global footer on top", async () => {
48
53
  render(<Settings config={LAYOUT_CONFIG} session={SESSION} initialTab="seo" />)
49
54
 
50
- // The SEO panel owns its own persistence ("Save SEO defaults").
55
+ // The Advanced disclosure still mounts the per-collection SEO panel, which
56
+ // owns its own persistence ("Save SEO defaults").
51
57
  expect(
52
58
  (await screen.findAllByRole('button', { name: 'Save SEO defaults' })).length,
53
59
  ).toBeGreaterThan(0)
54
- // The shared footer ("Save Changes") must be absent here.
55
- expect(screen.queryByRole('button', { name: 'Save Changes' })).toBeNull()
60
+ // The tab's own SettingsSaveBar is the ONLY "Save Changes" button the
61
+ // no-op global footer must not be stacked on top of it.
62
+ expect(screen.getAllByRole('button', { name: 'Save Changes' })).toHaveLength(1)
56
63
  })
57
64
 
58
65
  it('renders the global "Save Changes" footer on the Layout tab', async () => {
@@ -16,14 +16,35 @@ interface MediaItem {
16
16
  height?: number
17
17
  }
18
18
 
19
+ /** Minimal selected-asset shape exposed to callers that need the asset id. */
20
+ export interface SelectedMediaItem {
21
+ id: string
22
+ storageKey: string
23
+ filename: string
24
+ mimeType: string
25
+ width?: number
26
+ height?: number
27
+ }
28
+
19
29
  export interface MediaPickerModalProps {
20
30
  open: boolean
21
31
  onClose: () => void
22
32
  onSelect: (url: string, alt?: string) => void
33
+ /**
34
+ * Optional richer callback fired alongside `onSelect` with the full media
35
+ * item, so callers that need to store the asset id (not just the URL) can.
36
+ */
37
+ onSelectItem?: (item: SelectedMediaItem) => void
23
38
  accept?: string
24
39
  }
25
40
 
26
- export function MediaPickerModal({ open, onClose, onSelect, accept }: MediaPickerModalProps) {
41
+ export function MediaPickerModal({
42
+ open,
43
+ onClose,
44
+ onSelect,
45
+ onSelectItem,
46
+ accept,
47
+ }: MediaPickerModalProps) {
27
48
  const [tab, setTab] = useState<'library' | 'upload'>('library')
28
49
  const [search, setSearch] = useState('')
29
50
  const [uploading, setUploading] = useState(false)
@@ -52,9 +73,17 @@ export function MediaPickerModal({ open, onClose, onSelect, accept }: MediaPicke
52
73
  if (res.error) {
53
74
  toast.error(res.error)
54
75
  } else if (res.data) {
55
- const mediaItem = res.data as any
76
+ const mediaItem = res.data as MediaItem
56
77
  const url = mediaItem.storageKey ?? ''
57
78
  toast.success(`Uploaded ${file.name}`)
79
+ onSelectItem?.({
80
+ id: mediaItem.id,
81
+ storageKey: mediaItem.storageKey,
82
+ filename: mediaItem.filename ?? file.name,
83
+ mimeType: mediaItem.mimeType ?? file.type,
84
+ width: mediaItem.width,
85
+ height: mediaItem.height,
86
+ })
58
87
  onSelect(url, file.name)
59
88
  onClose()
60
89
  }
@@ -64,6 +93,14 @@ export function MediaPickerModal({ open, onClose, onSelect, accept }: MediaPicke
64
93
  }
65
94
 
66
95
  function handleSelectItem(item: MediaItem) {
96
+ onSelectItem?.({
97
+ id: item.id,
98
+ storageKey: item.storageKey,
99
+ filename: item.filename,
100
+ mimeType: item.mimeType,
101
+ width: item.width,
102
+ height: item.height,
103
+ })
67
104
  onSelect(item.storageKey, item.filename)
68
105
  onClose()
69
106
  }
@@ -22,8 +22,8 @@ import { toast } from 'sonner'
22
22
  import { useApiData } from '../lib/useApiData.js'
23
23
  import { cmsApi } from '../lib/api.js'
24
24
  import { RelationshipField } from '../fields/RelationshipField.js'
25
- import { SEOConfigPanel } from '../components/SEOConfigPanel.js'
26
25
  import { GeneralSettingsTab } from './settings/GeneralSettingsTab.js'
26
+ import { SeoSettingsTab } from './settings/SeoSettingsTab.js'
27
27
  import { AppearanceTab } from './settings/AppearanceTab.js'
28
28
  import { SecurityTab } from './settings/SecurityTab.js'
29
29
  import { Users as UsersView } from './Users.js'
@@ -314,7 +314,7 @@ export function Settings({
314
314
  </Tabs.Content>
315
315
 
316
316
  <Tabs.Content value="seo" className="space-y-4">
317
- <SEOConfigPanel />
317
+ <SeoSettingsTab onNavigate={onNavigate} role={session?.user?.role} />
318
318
  </Tabs.Content>
319
319
 
320
320
  <Tabs.Content value="ai" className="space-y-4">