@actuate-media/cms-admin 0.48.0 → 0.49.1
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 +22 -0
- package/dist/AdminRoot.d.ts +10 -0
- package/dist/AdminRoot.d.ts.map +1 -1
- package/dist/AdminRoot.js +25 -43
- package/dist/AdminRoot.js.map +1 -1
- package/dist/LoginRoot.d.ts +54 -0
- package/dist/LoginRoot.d.ts.map +1 -0
- package/dist/LoginRoot.js +59 -0
- package/dist/LoginRoot.js.map +1 -0
- package/dist/__tests__/components/modal.render.test.d.ts +2 -0
- package/dist/__tests__/components/modal.render.test.d.ts.map +1 -0
- package/dist/__tests__/components/modal.render.test.js +37 -0
- package/dist/__tests__/components/modal.render.test.js.map +1 -0
- package/dist/__tests__/views/login-root.render.test.d.ts +2 -0
- package/dist/__tests__/views/login-root.render.test.d.ts.map +1 -0
- package/dist/__tests__/views/login-root.render.test.js +79 -0
- package/dist/__tests__/views/login-root.render.test.js.map +1 -0
- package/dist/actuate-admin.css +1 -1
- package/dist/components/MediaPickerModal.d.ts +1 -1
- package/dist/components/MediaPickerModal.d.ts.map +1 -1
- package/dist/components/MediaPickerModal.js +12 -11
- package/dist/components/MediaPickerModal.js.map +1 -1
- package/dist/components/SchedulePublishDialog.d.ts +1 -1
- package/dist/components/SchedulePublishDialog.d.ts.map +1 -1
- package/dist/components/SchedulePublishDialog.js +6 -4
- package/dist/components/SchedulePublishDialog.js.map +1 -1
- package/dist/components/SharePreviewLinkDialog.d.ts +1 -1
- package/dist/components/SharePreviewLinkDialog.d.ts.map +1 -1
- package/dist/components/SharePreviewLinkDialog.js +10 -9
- package/dist/components/SharePreviewLinkDialog.js.map +1 -1
- package/dist/components/VersionHistory.d.ts +1 -1
- package/dist/components/VersionHistory.d.ts.map +1 -1
- package/dist/components/VersionHistory.js +14 -14
- package/dist/components/VersionHistory.js.map +1 -1
- package/dist/components/ui/Modal.d.ts +17 -1
- package/dist/components/ui/Modal.d.ts.map +1 -1
- package/dist/components/ui/Modal.js +16 -15
- package/dist/components/ui/Modal.js.map +1 -1
- package/dist/fields/BlockBuilderField.d.ts.map +1 -1
- package/dist/fields/BlockBuilderField.js +6 -4
- package/dist/fields/BlockBuilderField.js.map +1 -1
- package/dist/index.d.ts +2 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -0
- package/dist/index.js.map +1 -1
- package/dist/lib/root-chrome.d.ts +15 -0
- package/dist/lib/root-chrome.d.ts.map +1 -0
- package/dist/lib/root-chrome.js +41 -0
- package/dist/lib/root-chrome.js.map +1 -0
- package/dist/views/HiddenLoginNotFound.d.ts +8 -0
- package/dist/views/HiddenLoginNotFound.d.ts.map +1 -0
- package/dist/views/HiddenLoginNotFound.js +29 -0
- package/dist/views/HiddenLoginNotFound.js.map +1 -0
- package/dist/views/page-builder/AIGenerateDialog.d.ts +1 -1
- package/dist/views/page-builder/AIGenerateDialog.d.ts.map +1 -1
- package/dist/views/page-builder/AIGenerateDialog.js +23 -23
- package/dist/views/page-builder/AIGenerateDialog.js.map +1 -1
- package/package.json +2 -2
- package/src/AdminRoot.tsx +61 -59
- package/src/LoginRoot.tsx +166 -0
- package/src/__tests__/components/modal.render.test.tsx +57 -0
- package/src/__tests__/views/login-root.render.test.tsx +115 -0
- package/src/components/MediaPickerModal.tsx +143 -119
- package/src/components/SchedulePublishDialog.tsx +118 -93
- package/src/components/SharePreviewLinkDialog.tsx +115 -93
- package/src/components/VersionHistory.tsx +98 -82
- package/src/components/ui/Modal.tsx +48 -39
- package/src/fields/BlockBuilderField.tsx +26 -19
- package/src/index.ts +2 -0
- package/src/lib/root-chrome.ts +40 -0
- package/src/views/HiddenLoginNotFound.tsx +45 -0
- package/src/views/page-builder/AIGenerateDialog.tsx +347 -335
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import { useEffect } from 'react'
|
|
4
|
+
import { useAdminRouter } from './router/index.js'
|
|
5
|
+
import { ThemeProvider } from './components/ThemeProvider.js'
|
|
6
|
+
import { ISOLATION_STYLE, useRobotsNoIndex } from './lib/root-chrome.js'
|
|
7
|
+
import { Login, type CaptchaConfig } from './views/Login.js'
|
|
8
|
+
import { SetupWizard } from './views/SetupWizard.js'
|
|
9
|
+
import { ForgotPassword } from './views/ForgotPassword.js'
|
|
10
|
+
import { ResetPassword } from './views/ResetPassword.js'
|
|
11
|
+
|
|
12
|
+
export interface LoginRootProps {
|
|
13
|
+
config?: any
|
|
14
|
+
/** Current session, if any. Authenticated visitors are sent to `adminPath`. */
|
|
15
|
+
session?: any
|
|
16
|
+
/** Mount path of this login screen (default: '/securelogin'). */
|
|
17
|
+
basePath?: string
|
|
18
|
+
/**
|
|
19
|
+
* Where the admin app lives (default: '/admin'). Users are sent here after
|
|
20
|
+
* signing in — and immediately, if they arrive already authenticated.
|
|
21
|
+
*/
|
|
22
|
+
adminPath?: string
|
|
23
|
+
setupRequired?: boolean
|
|
24
|
+
onSetupComplete?: (data: {
|
|
25
|
+
name: string
|
|
26
|
+
email: string
|
|
27
|
+
password: string
|
|
28
|
+
}) => Promise<{ success: boolean; error?: string }>
|
|
29
|
+
onLogin: (
|
|
30
|
+
email: string,
|
|
31
|
+
password: string,
|
|
32
|
+
captchaToken?: string,
|
|
33
|
+
) => Promise<{
|
|
34
|
+
success: boolean
|
|
35
|
+
error?: string
|
|
36
|
+
requiresTOTP?: boolean
|
|
37
|
+
mfaPendingToken?: string
|
|
38
|
+
}>
|
|
39
|
+
/** Completes the TOTP second-factor step for accounts with MFA enabled. */
|
|
40
|
+
onVerifyTotp?: (
|
|
41
|
+
mfaPendingToken: string,
|
|
42
|
+
code: string,
|
|
43
|
+
) => Promise<{ success: boolean; error?: string }>
|
|
44
|
+
captchaConfig?: CaptchaConfig
|
|
45
|
+
/**
|
|
46
|
+
* Called once a session exists (after login, or on arrival while already
|
|
47
|
+
* signed in). Defaults to a full-page navigation to `adminPath` — the admin
|
|
48
|
+
* app is a separate route mount, so a client-side route push can't reach it.
|
|
49
|
+
*/
|
|
50
|
+
onAuthenticated?: (adminPath: string) => void
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Standalone mount for the unauthenticated auth screens on split-login
|
|
55
|
+
* installs: login (+ MFA), setup wizard, forgot/reset password. Pair it with
|
|
56
|
+
* `<AdminRoot loginPath>` so the admin mount itself stops rendering a login
|
|
57
|
+
* form for signed-out visitors.
|
|
58
|
+
*
|
|
59
|
+
* Routes under `basePath`:
|
|
60
|
+
* - `/` — login (or the setup wizard while `setupRequired`)
|
|
61
|
+
* - `/setup` — setup wizard (first-run; falls back to login once setup is done)
|
|
62
|
+
* - `/forgot-password`, `/reset-password?token=…` — recovery flows; emailed
|
|
63
|
+
* reset/invite links point here when `config.admin.loginPath` is set.
|
|
64
|
+
*/
|
|
65
|
+
export function LoginRoot({
|
|
66
|
+
config,
|
|
67
|
+
session,
|
|
68
|
+
basePath = '/securelogin',
|
|
69
|
+
adminPath = '/admin',
|
|
70
|
+
setupRequired,
|
|
71
|
+
onSetupComplete,
|
|
72
|
+
onLogin,
|
|
73
|
+
onVerifyTotp,
|
|
74
|
+
captchaConfig,
|
|
75
|
+
onAuthenticated,
|
|
76
|
+
}: LoginRootProps) {
|
|
77
|
+
const defaultDarkMode = config?.admin?.branding?.darkMode
|
|
78
|
+
|
|
79
|
+
useRobotsNoIndex()
|
|
80
|
+
|
|
81
|
+
return (
|
|
82
|
+
<div style={ISOLATION_STYLE} className="actuate-admin">
|
|
83
|
+
<ThemeProvider defaultDarkMode={defaultDarkMode}>
|
|
84
|
+
<LoginShell
|
|
85
|
+
config={config}
|
|
86
|
+
session={session}
|
|
87
|
+
basePath={basePath}
|
|
88
|
+
adminPath={adminPath}
|
|
89
|
+
setupRequired={setupRequired}
|
|
90
|
+
onSetupComplete={onSetupComplete}
|
|
91
|
+
onLogin={onLogin}
|
|
92
|
+
onVerifyTotp={onVerifyTotp}
|
|
93
|
+
captchaConfig={captchaConfig}
|
|
94
|
+
onAuthenticated={onAuthenticated}
|
|
95
|
+
/>
|
|
96
|
+
</ThemeProvider>
|
|
97
|
+
</div>
|
|
98
|
+
)
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function LoginShell({
|
|
102
|
+
config,
|
|
103
|
+
session,
|
|
104
|
+
basePath = '/securelogin',
|
|
105
|
+
adminPath = '/admin',
|
|
106
|
+
setupRequired,
|
|
107
|
+
onSetupComplete,
|
|
108
|
+
onLogin,
|
|
109
|
+
onVerifyTotp,
|
|
110
|
+
captchaConfig,
|
|
111
|
+
onAuthenticated,
|
|
112
|
+
}: LoginRootProps) {
|
|
113
|
+
const { navigate, matchRoute } = useAdminRouter(basePath, '/')
|
|
114
|
+
|
|
115
|
+
// Already signed in (or just signed in): hand off to the admin app. The
|
|
116
|
+
// admin is a separate route mount, so this must be a full-page navigation.
|
|
117
|
+
const authenticated = Boolean(session) && !setupRequired
|
|
118
|
+
useEffect(() => {
|
|
119
|
+
if (!authenticated) return
|
|
120
|
+
if (onAuthenticated) {
|
|
121
|
+
onAuthenticated(adminPath)
|
|
122
|
+
return
|
|
123
|
+
}
|
|
124
|
+
if (typeof window !== 'undefined') {
|
|
125
|
+
window.location.replace(adminPath)
|
|
126
|
+
}
|
|
127
|
+
}, [authenticated, adminPath, onAuthenticated])
|
|
128
|
+
|
|
129
|
+
if (authenticated) {
|
|
130
|
+
return (
|
|
131
|
+
<div className="bg-background flex min-h-screen items-center justify-center">
|
|
132
|
+
<p className="text-muted-foreground text-sm" role="status">
|
|
133
|
+
Signed in — taking you to the admin…
|
|
134
|
+
</p>
|
|
135
|
+
</div>
|
|
136
|
+
)
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
if (setupRequired && onSetupComplete) {
|
|
140
|
+
return (
|
|
141
|
+
<SetupWizard
|
|
142
|
+
onComplete={onSetupComplete}
|
|
143
|
+
siteName={config?.admin?.branding?.name ?? 'Actuate CMS'}
|
|
144
|
+
/>
|
|
145
|
+
)
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
if (matchRoute('/forgot-password')) {
|
|
149
|
+
return <ForgotPassword onNavigate={navigate} />
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
if (matchRoute('/reset-password')) {
|
|
153
|
+
const params = new URLSearchParams(typeof window !== 'undefined' ? window.location.search : '')
|
|
154
|
+
return <ResetPassword onNavigate={navigate} token={params.get('token')} />
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
return (
|
|
158
|
+
<Login
|
|
159
|
+
onLogin={onLogin}
|
|
160
|
+
onVerifyTotp={onVerifyTotp}
|
|
161
|
+
onNavigate={navigate}
|
|
162
|
+
captchaConfig={captchaConfig}
|
|
163
|
+
branding={config?.admin?.branding}
|
|
164
|
+
/>
|
|
165
|
+
)
|
|
166
|
+
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
// @vitest-environment happy-dom
|
|
2
|
+
import { describe, expect, it, vi } from 'vitest'
|
|
3
|
+
import { fireEvent, render, screen } from '@testing-library/react'
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* The shared admin Modal is built on Radix Dialog. These tests pin the
|
|
7
|
+
* accessibility contract that the migration introduced: the surface is exposed
|
|
8
|
+
* as a labelled dialog, the icon-only close control has an accessible name, and
|
|
9
|
+
* Escape / close both fire `onClose`. Hand-rolled `<div>` overlays gave us none
|
|
10
|
+
* of this.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
const { Modal } = await import('../../components/ui/Modal.js')
|
|
14
|
+
|
|
15
|
+
describe('Modal accessibility', () => {
|
|
16
|
+
it('does not render content while closed', () => {
|
|
17
|
+
render(
|
|
18
|
+
<Modal open={false} onClose={() => {}} title="Edit page">
|
|
19
|
+
<p>Body</p>
|
|
20
|
+
</Modal>,
|
|
21
|
+
)
|
|
22
|
+
expect(screen.queryByRole('dialog')).toBeNull()
|
|
23
|
+
})
|
|
24
|
+
|
|
25
|
+
it('exposes a labelled dialog with an accessible close button', () => {
|
|
26
|
+
render(
|
|
27
|
+
<Modal open onClose={() => {}} title="Edit page">
|
|
28
|
+
<p>Body</p>
|
|
29
|
+
</Modal>,
|
|
30
|
+
)
|
|
31
|
+
// The title becomes the dialog's accessible name via aria-labelledby.
|
|
32
|
+
expect(screen.getByRole('dialog', { name: 'Edit page' })).toBeTruthy()
|
|
33
|
+
expect(screen.getByRole('button', { name: 'Close dialog' })).toBeTruthy()
|
|
34
|
+
})
|
|
35
|
+
|
|
36
|
+
it('fires onClose when the close button is activated', () => {
|
|
37
|
+
const onClose = vi.fn()
|
|
38
|
+
render(
|
|
39
|
+
<Modal open onClose={onClose} title="Edit page">
|
|
40
|
+
<p>Body</p>
|
|
41
|
+
</Modal>,
|
|
42
|
+
)
|
|
43
|
+
fireEvent.click(screen.getByRole('button', { name: 'Close dialog' }))
|
|
44
|
+
expect(onClose).toHaveBeenCalledTimes(1)
|
|
45
|
+
})
|
|
46
|
+
|
|
47
|
+
it('fires onClose when Escape is pressed', () => {
|
|
48
|
+
const onClose = vi.fn()
|
|
49
|
+
render(
|
|
50
|
+
<Modal open onClose={onClose} title="Edit page">
|
|
51
|
+
<p>Body</p>
|
|
52
|
+
</Modal>,
|
|
53
|
+
)
|
|
54
|
+
fireEvent.keyDown(document.body, { key: 'Escape' })
|
|
55
|
+
expect(onClose).toHaveBeenCalled()
|
|
56
|
+
})
|
|
57
|
+
})
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
// @vitest-environment happy-dom
|
|
2
|
+
import { afterEach, describe, expect, it, vi } from 'vitest'
|
|
3
|
+
import { cleanup, render, screen, waitFor } from '@testing-library/react'
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Split-login mode (user request: login at a non-obvious path like
|
|
7
|
+
* /securelogin, admin app at /admin). Pins:
|
|
8
|
+
* - `LoginRoot` serves login / setup wizard / forgot-password on its own
|
|
9
|
+
* mount and hands off to the admin app once a session exists.
|
|
10
|
+
* - `<AdminRoot loginPath>` renders a 404 lookalike for signed-out visitors
|
|
11
|
+
* instead of a login form, so /admin doesn't advertise the CMS.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
vi.mock('../../lib/api.js', () => ({
|
|
15
|
+
cmsApi: vi.fn(async () => ({ data: undefined })),
|
|
16
|
+
ensureCsrfToken: vi.fn(async () => undefined),
|
|
17
|
+
setApiBase: vi.fn(),
|
|
18
|
+
}))
|
|
19
|
+
|
|
20
|
+
// This happy-dom environment doesn't expose a functional localStorage, which
|
|
21
|
+
// ThemeProvider/LocaleProvider read on mount.
|
|
22
|
+
const storage = new Map<string, string>()
|
|
23
|
+
vi.stubGlobal('localStorage', {
|
|
24
|
+
getItem: (k: string) => storage.get(k) ?? null,
|
|
25
|
+
setItem: (k: string, v: string) => void storage.set(k, String(v)),
|
|
26
|
+
removeItem: (k: string) => void storage.delete(k),
|
|
27
|
+
clear: () => storage.clear(),
|
|
28
|
+
})
|
|
29
|
+
|
|
30
|
+
const { LoginRoot } = await import('../../LoginRoot.js')
|
|
31
|
+
const { AdminRoot } = await import('../../AdminRoot.js')
|
|
32
|
+
|
|
33
|
+
afterEach(() => {
|
|
34
|
+
cleanup()
|
|
35
|
+
window.history.replaceState({}, '', '/')
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
const noopLogin = vi.fn(async () => ({ success: true }))
|
|
39
|
+
|
|
40
|
+
describe('LoginRoot', () => {
|
|
41
|
+
it('renders the login screen for signed-out visitors', () => {
|
|
42
|
+
window.history.replaceState({}, '', '/securelogin')
|
|
43
|
+
render(<LoginRoot config={{}} session={null} onLogin={noopLogin} />)
|
|
44
|
+
expect(screen.getByText('Sign in to your account')).toBeTruthy()
|
|
45
|
+
})
|
|
46
|
+
|
|
47
|
+
it('renders the setup wizard while setup is required (first-time install)', () => {
|
|
48
|
+
window.history.replaceState({}, '', '/securelogin/setup')
|
|
49
|
+
render(
|
|
50
|
+
<LoginRoot
|
|
51
|
+
config={{ admin: { branding: { name: 'Acme' } } }}
|
|
52
|
+
session={null}
|
|
53
|
+
setupRequired
|
|
54
|
+
onSetupComplete={vi.fn(async () => ({ success: true }))}
|
|
55
|
+
onLogin={noopLogin}
|
|
56
|
+
/>,
|
|
57
|
+
)
|
|
58
|
+
expect(screen.getByText('Welcome to Acme')).toBeTruthy()
|
|
59
|
+
})
|
|
60
|
+
|
|
61
|
+
it('serves the forgot-password flow under its own mount', () => {
|
|
62
|
+
window.history.replaceState({}, '', '/securelogin/forgot-password')
|
|
63
|
+
render(<LoginRoot config={{}} session={null} onLogin={noopLogin} />)
|
|
64
|
+
expect(screen.getByText('Reset Password')).toBeTruthy()
|
|
65
|
+
})
|
|
66
|
+
|
|
67
|
+
it('hands an authenticated visitor off to the admin app', async () => {
|
|
68
|
+
window.history.replaceState({}, '', '/securelogin')
|
|
69
|
+
const onAuthenticated = vi.fn()
|
|
70
|
+
render(
|
|
71
|
+
<LoginRoot
|
|
72
|
+
config={{}}
|
|
73
|
+
session={{ user: { id: 'u1' } }}
|
|
74
|
+
adminPath="/admin"
|
|
75
|
+
onLogin={noopLogin}
|
|
76
|
+
onAuthenticated={onAuthenticated}
|
|
77
|
+
/>,
|
|
78
|
+
)
|
|
79
|
+
expect(screen.getByRole('status').textContent).toContain('Signed in')
|
|
80
|
+
await waitFor(() => expect(onAuthenticated).toHaveBeenCalledWith('/admin'))
|
|
81
|
+
// No login form is offered to an already-authenticated visitor.
|
|
82
|
+
expect(screen.queryByText('Sign in to your account')).toBeNull()
|
|
83
|
+
})
|
|
84
|
+
})
|
|
85
|
+
|
|
86
|
+
describe('AdminRoot with loginPath (hidden login)', () => {
|
|
87
|
+
it('shows a 404 lookalike instead of a login form when signed out', () => {
|
|
88
|
+
window.history.replaceState({}, '', '/admin')
|
|
89
|
+
render(<AdminRoot config={{}} session={null} loginPath="/securelogin" onLogin={noopLogin} />)
|
|
90
|
+
expect(screen.getByText('This page could not be found.')).toBeTruthy()
|
|
91
|
+
expect(screen.queryByText('Sign in to your account')).toBeNull()
|
|
92
|
+
})
|
|
93
|
+
|
|
94
|
+
it('hides the setup wizard at the admin mount too (it lives on the login mount)', () => {
|
|
95
|
+
window.history.replaceState({}, '', '/admin')
|
|
96
|
+
render(
|
|
97
|
+
<AdminRoot
|
|
98
|
+
config={{}}
|
|
99
|
+
session={null}
|
|
100
|
+
loginPath="/securelogin"
|
|
101
|
+
setupRequired
|
|
102
|
+
onSetupComplete={vi.fn(async () => ({ success: true }))}
|
|
103
|
+
onLogin={noopLogin}
|
|
104
|
+
/>,
|
|
105
|
+
)
|
|
106
|
+
expect(screen.getByText('This page could not be found.')).toBeTruthy()
|
|
107
|
+
expect(screen.queryByText(/Welcome to/)).toBeNull()
|
|
108
|
+
})
|
|
109
|
+
|
|
110
|
+
it('keeps the classic inline login when loginPath is not set', () => {
|
|
111
|
+
window.history.replaceState({}, '', '/admin')
|
|
112
|
+
render(<AdminRoot config={{}} session={null} onLogin={noopLogin} />)
|
|
113
|
+
expect(screen.getByText('Sign in to your account')).toBeTruthy()
|
|
114
|
+
})
|
|
115
|
+
})
|
|
@@ -1,9 +1,11 @@
|
|
|
1
1
|
'use client'
|
|
2
2
|
|
|
3
|
-
import { useState, useRef } from 'react'
|
|
3
|
+
import { useId, useState, useRef } from 'react'
|
|
4
|
+
import * as Dialog from '@radix-ui/react-dialog'
|
|
4
5
|
import { X, Upload, Search, ImageIcon, Loader2 } from 'lucide-react'
|
|
5
6
|
import { toast } from 'sonner'
|
|
6
7
|
import { cmsApi } from '../lib/api.js'
|
|
8
|
+
import { adminPortalContainer } from '../lib/portal-container.js'
|
|
7
9
|
import { useApiData } from '../lib/useApiData.js'
|
|
8
10
|
|
|
9
11
|
interface MediaItem {
|
|
@@ -60,6 +62,7 @@ export function MediaPickerModal({
|
|
|
60
62
|
const [search, setSearch] = useState('')
|
|
61
63
|
const [uploading, setUploading] = useState(false)
|
|
62
64
|
const fileInputRef = useRef<HTMLInputElement>(null)
|
|
65
|
+
const searchId = useId()
|
|
63
66
|
|
|
64
67
|
const { data, loading, refetch } = useApiData<{ data: { items: MediaItem[] } }>(
|
|
65
68
|
open ? `/media?pageSize=50${search ? `&search=${encodeURIComponent(search)}` : ''}` : null,
|
|
@@ -119,132 +122,153 @@ export function MediaPickerModal({
|
|
|
119
122
|
onClose()
|
|
120
123
|
}
|
|
121
124
|
|
|
122
|
-
if (!open) return null
|
|
123
|
-
|
|
124
125
|
return (
|
|
125
|
-
<
|
|
126
|
-
<
|
|
127
|
-
|
|
128
|
-
<
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
: 'text-gray-500 hover:text-gray-700'
|
|
146
|
-
}`}
|
|
147
|
-
>
|
|
148
|
-
Media Library
|
|
149
|
-
</button>
|
|
150
|
-
<button
|
|
151
|
-
onClick={() => setTab('upload')}
|
|
152
|
-
className={`flex-1 px-4 py-2.5 text-sm font-medium transition-colors ${
|
|
153
|
-
tab === 'upload'
|
|
154
|
-
? 'border-b-2 border-blue-600 text-blue-600'
|
|
155
|
-
: 'text-gray-500 hover:text-gray-700'
|
|
156
|
-
}`}
|
|
157
|
-
>
|
|
158
|
-
Upload New
|
|
159
|
-
</button>
|
|
126
|
+
<Dialog.Root open={open} onOpenChange={(next) => !next && onClose()}>
|
|
127
|
+
<Dialog.Portal container={adminPortalContainer()}>
|
|
128
|
+
<Dialog.Overlay className="fixed inset-0 z-50 bg-black/40" />
|
|
129
|
+
<Dialog.Content
|
|
130
|
+
aria-describedby={undefined}
|
|
131
|
+
className="bg-card text-card-foreground border-border fixed top-1/2 left-1/2 z-50 mx-4 flex max-h-[80vh] w-[calc(100%-2rem)] max-w-2xl -translate-x-1/2 -translate-y-1/2 flex-col rounded-xl border shadow-2xl focus:outline-none"
|
|
132
|
+
>
|
|
133
|
+
<div className="border-border flex items-center justify-between border-b px-4 py-3">
|
|
134
|
+
<Dialog.Title className="text-foreground text-lg font-medium">
|
|
135
|
+
Insert Image
|
|
136
|
+
</Dialog.Title>
|
|
137
|
+
<Dialog.Close asChild>
|
|
138
|
+
<button
|
|
139
|
+
type="button"
|
|
140
|
+
aria-label="Close media picker"
|
|
141
|
+
className="text-muted-foreground hover:bg-accent hover:text-foreground focus-visible:ring-ring rounded-lg p-1.5 transition-colors focus-visible:ring-2 focus-visible:outline-none"
|
|
142
|
+
>
|
|
143
|
+
<X className="h-5 w-5" aria-hidden />
|
|
144
|
+
</button>
|
|
145
|
+
</Dialog.Close>
|
|
160
146
|
</div>
|
|
161
|
-
)}
|
|
162
147
|
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
148
|
+
{!libraryOnly && (
|
|
149
|
+
<div className="border-border flex border-b" role="tablist" aria-label="Image source">
|
|
150
|
+
<button
|
|
151
|
+
type="button"
|
|
152
|
+
role="tab"
|
|
153
|
+
aria-selected={tab === 'library'}
|
|
154
|
+
onClick={() => setTab('library')}
|
|
155
|
+
className={`flex-1 px-4 py-2.5 text-sm font-medium transition-colors ${
|
|
156
|
+
tab === 'library'
|
|
157
|
+
? 'border-primary text-primary border-b-2'
|
|
158
|
+
: 'text-muted-foreground hover:text-foreground'
|
|
159
|
+
}`}
|
|
160
|
+
>
|
|
161
|
+
Media Library
|
|
162
|
+
</button>
|
|
163
|
+
<button
|
|
164
|
+
type="button"
|
|
165
|
+
role="tab"
|
|
166
|
+
aria-selected={tab === 'upload'}
|
|
167
|
+
onClick={() => setTab('upload')}
|
|
168
|
+
className={`flex-1 px-4 py-2.5 text-sm font-medium transition-colors ${
|
|
169
|
+
tab === 'upload'
|
|
170
|
+
? 'border-primary text-primary border-b-2'
|
|
171
|
+
: 'text-muted-foreground hover:text-foreground'
|
|
172
|
+
}`}
|
|
173
|
+
>
|
|
174
|
+
Upload New
|
|
175
|
+
</button>
|
|
176
|
+
</div>
|
|
177
|
+
)}
|
|
176
178
|
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
179
|
+
<div className="flex-1 overflow-y-auto p-4">
|
|
180
|
+
{libraryOnly || tab === 'library' ? (
|
|
181
|
+
<>
|
|
182
|
+
<div className="relative mb-4">
|
|
183
|
+
<Search
|
|
184
|
+
className="text-muted-foreground absolute top-1/2 left-3 h-4 w-4 -translate-y-1/2"
|
|
185
|
+
aria-hidden
|
|
186
|
+
/>
|
|
187
|
+
<input
|
|
188
|
+
id={searchId}
|
|
189
|
+
type="text"
|
|
190
|
+
value={search}
|
|
191
|
+
onChange={(e) => setSearch(e.target.value)}
|
|
192
|
+
placeholder="Search images..."
|
|
193
|
+
aria-label="Search images"
|
|
194
|
+
className="border-input bg-background text-foreground focus:ring-ring w-full rounded-lg border py-2 pr-3 pl-9 text-sm focus:ring-2 focus:outline-none"
|
|
195
|
+
/>
|
|
180
196
|
</div>
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
197
|
+
|
|
198
|
+
{loading ? (
|
|
199
|
+
<div className="flex items-center justify-center py-12">
|
|
200
|
+
<Loader2 className="text-muted-foreground h-6 w-6 animate-spin" aria-hidden />
|
|
201
|
+
</div>
|
|
202
|
+
) : imageItems.length === 0 ? (
|
|
203
|
+
<div className="text-muted-foreground py-12 text-center text-sm">
|
|
204
|
+
No images found. Try uploading one.
|
|
205
|
+
</div>
|
|
206
|
+
) : (
|
|
207
|
+
<div className="grid grid-cols-3 gap-3 sm:grid-cols-4">
|
|
208
|
+
{imageItems.map((item: MediaItem) => {
|
|
209
|
+
const src = item.url || item.storageKey
|
|
210
|
+
return (
|
|
211
|
+
<button
|
|
212
|
+
key={item.id}
|
|
213
|
+
type="button"
|
|
214
|
+
onClick={() => handleSelectItem(item)}
|
|
215
|
+
title={item.filename}
|
|
216
|
+
className="group border-border bg-muted hover:border-primary relative aspect-square overflow-hidden rounded-lg border-2 transition-colors"
|
|
217
|
+
>
|
|
218
|
+
{src ? (
|
|
219
|
+
<img
|
|
220
|
+
src={src}
|
|
221
|
+
alt={item.filename}
|
|
222
|
+
loading="lazy"
|
|
223
|
+
className="h-full w-full object-contain"
|
|
224
|
+
/>
|
|
225
|
+
) : (
|
|
226
|
+
<div className="flex h-full w-full items-center justify-center">
|
|
227
|
+
<ImageIcon className="text-muted-foreground h-8 w-8" aria-hidden />
|
|
228
|
+
</div>
|
|
229
|
+
)}
|
|
230
|
+
<div className="absolute inset-x-0 bottom-0 bg-black/60 p-1.5 opacity-0 transition-opacity group-hover:opacity-100">
|
|
231
|
+
<p className="truncate text-xs text-white">{item.filename}</p>
|
|
206
232
|
</div>
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
233
|
+
</button>
|
|
234
|
+
)
|
|
235
|
+
})}
|
|
236
|
+
</div>
|
|
237
|
+
)}
|
|
238
|
+
</>
|
|
239
|
+
) : (
|
|
240
|
+
<div className="flex flex-col items-center justify-center py-12">
|
|
241
|
+
<input
|
|
242
|
+
ref={fileInputRef}
|
|
243
|
+
type="file"
|
|
244
|
+
accept={accept ?? 'image/*'}
|
|
245
|
+
className="hidden"
|
|
246
|
+
onChange={(e) => handleUpload(e.target.files)}
|
|
247
|
+
/>
|
|
248
|
+
<div className="bg-primary/10 mb-4 flex h-16 w-16 items-center justify-center rounded-full">
|
|
249
|
+
{uploading ? (
|
|
250
|
+
<Loader2 className="text-primary h-8 w-8 animate-spin" aria-hidden />
|
|
251
|
+
) : (
|
|
252
|
+
<Upload className="text-primary h-8 w-8" aria-hidden />
|
|
253
|
+
)}
|
|
214
254
|
</div>
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
<div className="mb-4 flex h-16 w-16 items-center justify-center rounded-full bg-blue-50">
|
|
227
|
-
{uploading ? (
|
|
228
|
-
<Loader2 className="h-8 w-8 animate-spin text-blue-600" />
|
|
229
|
-
) : (
|
|
230
|
-
<Upload className="h-8 w-8 text-blue-600" />
|
|
255
|
+
<p className="text-muted-foreground mb-4 text-sm">
|
|
256
|
+
{uploading ? 'Uploading...' : 'Select an image file to upload'}
|
|
257
|
+
</p>
|
|
258
|
+
{!uploading && (
|
|
259
|
+
<button
|
|
260
|
+
type="button"
|
|
261
|
+
onClick={() => fileInputRef.current?.click()}
|
|
262
|
+
className="bg-primary text-primary-foreground rounded-lg px-4 py-2 text-sm transition-opacity hover:opacity-90"
|
|
263
|
+
>
|
|
264
|
+
Choose File
|
|
265
|
+
</button>
|
|
231
266
|
)}
|
|
232
267
|
</div>
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
onClick={() => fileInputRef.current?.click()}
|
|
239
|
-
className="rounded-lg bg-blue-600 px-4 py-2 text-sm text-white transition-colors hover:bg-blue-700"
|
|
240
|
-
>
|
|
241
|
-
Choose File
|
|
242
|
-
</button>
|
|
243
|
-
)}
|
|
244
|
-
</div>
|
|
245
|
-
)}
|
|
246
|
-
</div>
|
|
247
|
-
</div>
|
|
248
|
-
</div>
|
|
268
|
+
)}
|
|
269
|
+
</div>
|
|
270
|
+
</Dialog.Content>
|
|
271
|
+
</Dialog.Portal>
|
|
272
|
+
</Dialog.Root>
|
|
249
273
|
)
|
|
250
274
|
}
|