@actuate-media/cms-admin 0.31.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.
- 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/__tests__/views/user-detail-drawer.render.test.js +31 -0
- package/dist/__tests__/views/user-detail-drawer.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.js +2 -2
- package/dist/views/Settings.js.map +1 -1
- 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/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/dist/views/users/UserDetailDrawer.d.ts.map +1 -1
- package/dist/views/users/UserDetailDrawer.js +45 -5
- package/dist/views/users/UserDetailDrawer.js.map +1 -1
- package/package.json +2 -2
- 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/__tests__/views/user-detail-drawer.render.test.tsx +43 -0
- package/src/components/MediaPickerModal.tsx +39 -2
- package/src/views/Settings.tsx +2 -2
- package/src/views/settings/SeoSettingsTab.tsx +658 -0
- package/src/views/settings/useSeoSettings.ts +374 -0
- package/src/views/users/UserDetailDrawer.tsx +169 -24
|
@@ -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
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
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(
|
|
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
|
|
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
|
|
55
|
-
|
|
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 () => {
|
|
@@ -62,11 +62,32 @@ const PERMISSIONS = {
|
|
|
62
62
|
const { cmsApi } = vi.hoisted(() => ({ cmsApi: vi.fn() }))
|
|
63
63
|
vi.mock('../../lib/api.js', () => ({ cmsApi }))
|
|
64
64
|
|
|
65
|
+
const DIRECTORY = [
|
|
66
|
+
{
|
|
67
|
+
id: 'u1',
|
|
68
|
+
name: 'Priya Nair',
|
|
69
|
+
email: 'priya@actuatemedia.com',
|
|
70
|
+
status: 'active',
|
|
71
|
+
isCurrentUser: false,
|
|
72
|
+
},
|
|
73
|
+
{
|
|
74
|
+
id: 'u2',
|
|
75
|
+
name: 'Dana Chen',
|
|
76
|
+
email: 'dana@actuatemedia.com',
|
|
77
|
+
status: 'active',
|
|
78
|
+
isCurrentUser: false,
|
|
79
|
+
},
|
|
80
|
+
]
|
|
81
|
+
|
|
65
82
|
cmsApi.mockImplementation(async (endpoint?: string) => {
|
|
66
83
|
if (!endpoint) return { data: null, status: 200 }
|
|
84
|
+
if (endpoint.includes('/transfer-content'))
|
|
85
|
+
return { data: { documents: 4, media: 0, templates: 0, total: 4 }, status: 200 }
|
|
67
86
|
if (endpoint.endsWith('/sessions')) return { data: SESSIONS, status: 200 }
|
|
68
87
|
if (endpoint.endsWith('/permissions')) return { data: PERMISSIONS, status: 200 }
|
|
69
88
|
if (endpoint.endsWith('/activity')) return { data: [], status: 200 }
|
|
89
|
+
if (endpoint === '/users' || endpoint.startsWith('/users?'))
|
|
90
|
+
return { data: DIRECTORY, status: 200 }
|
|
70
91
|
if (endpoint.startsWith('/users/u1')) return { data: DETAIL, status: 200 }
|
|
71
92
|
return { data: null, status: 200 }
|
|
72
93
|
})
|
|
@@ -116,4 +137,26 @@ describe('UserDetailDrawer', () => {
|
|
|
116
137
|
await waitFor(() => expect(screen.getByText('content.publish')).toBeTruthy())
|
|
117
138
|
expect(screen.getByText('dashboard.view')).toBeTruthy()
|
|
118
139
|
})
|
|
140
|
+
|
|
141
|
+
it('transfers content ownership to a chosen active recipient', async () => {
|
|
142
|
+
render(<UserDetailDrawer userId="u1" open onOpenChange={() => {}} />)
|
|
143
|
+
await screen.findByText('Priya Nair')
|
|
144
|
+
|
|
145
|
+
fireEvent.click(screen.getByRole('button', { name: 'Transfer content' }))
|
|
146
|
+
|
|
147
|
+
// Modal opens and loads the directory, excluding the target (u1).
|
|
148
|
+
expect(await screen.findByText('Transfer content ownership')).toBeTruthy()
|
|
149
|
+
const select = (await screen.findByLabelText('Transfer to')) as HTMLSelectElement
|
|
150
|
+
fireEvent.change(select, { target: { value: 'u2' } })
|
|
151
|
+
|
|
152
|
+
fireEvent.click(screen.getByRole('button', { name: 'Transfer' }))
|
|
153
|
+
|
|
154
|
+
await waitFor(() =>
|
|
155
|
+
expect(
|
|
156
|
+
cmsApi.mock.calls.some(([endpoint]) =>
|
|
157
|
+
String(endpoint).includes('/users/u1/transfer-content'),
|
|
158
|
+
),
|
|
159
|
+
).toBe(true),
|
|
160
|
+
)
|
|
161
|
+
})
|
|
119
162
|
})
|
|
@@ -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({
|
|
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
|
|
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
|
}
|
package/src/views/Settings.tsx
CHANGED
|
@@ -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
|
-
<
|
|
317
|
+
<SeoSettingsTab onNavigate={onNavigate} role={session?.user?.role} />
|
|
318
318
|
</Tabs.Content>
|
|
319
319
|
|
|
320
320
|
<Tabs.Content value="ai" className="space-y-4">
|