@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.
Files changed (72) hide show
  1. package/CHANGELOG.md +22 -0
  2. package/dist/AdminRoot.d.ts +10 -0
  3. package/dist/AdminRoot.d.ts.map +1 -1
  4. package/dist/AdminRoot.js +25 -43
  5. package/dist/AdminRoot.js.map +1 -1
  6. package/dist/LoginRoot.d.ts +54 -0
  7. package/dist/LoginRoot.d.ts.map +1 -0
  8. package/dist/LoginRoot.js +59 -0
  9. package/dist/LoginRoot.js.map +1 -0
  10. package/dist/__tests__/components/modal.render.test.d.ts +2 -0
  11. package/dist/__tests__/components/modal.render.test.d.ts.map +1 -0
  12. package/dist/__tests__/components/modal.render.test.js +37 -0
  13. package/dist/__tests__/components/modal.render.test.js.map +1 -0
  14. package/dist/__tests__/views/login-root.render.test.d.ts +2 -0
  15. package/dist/__tests__/views/login-root.render.test.d.ts.map +1 -0
  16. package/dist/__tests__/views/login-root.render.test.js +79 -0
  17. package/dist/__tests__/views/login-root.render.test.js.map +1 -0
  18. package/dist/actuate-admin.css +1 -1
  19. package/dist/components/MediaPickerModal.d.ts +1 -1
  20. package/dist/components/MediaPickerModal.d.ts.map +1 -1
  21. package/dist/components/MediaPickerModal.js +12 -11
  22. package/dist/components/MediaPickerModal.js.map +1 -1
  23. package/dist/components/SchedulePublishDialog.d.ts +1 -1
  24. package/dist/components/SchedulePublishDialog.d.ts.map +1 -1
  25. package/dist/components/SchedulePublishDialog.js +6 -4
  26. package/dist/components/SchedulePublishDialog.js.map +1 -1
  27. package/dist/components/SharePreviewLinkDialog.d.ts +1 -1
  28. package/dist/components/SharePreviewLinkDialog.d.ts.map +1 -1
  29. package/dist/components/SharePreviewLinkDialog.js +10 -9
  30. package/dist/components/SharePreviewLinkDialog.js.map +1 -1
  31. package/dist/components/VersionHistory.d.ts +1 -1
  32. package/dist/components/VersionHistory.d.ts.map +1 -1
  33. package/dist/components/VersionHistory.js +14 -14
  34. package/dist/components/VersionHistory.js.map +1 -1
  35. package/dist/components/ui/Modal.d.ts +17 -1
  36. package/dist/components/ui/Modal.d.ts.map +1 -1
  37. package/dist/components/ui/Modal.js +16 -15
  38. package/dist/components/ui/Modal.js.map +1 -1
  39. package/dist/fields/BlockBuilderField.d.ts.map +1 -1
  40. package/dist/fields/BlockBuilderField.js +6 -4
  41. package/dist/fields/BlockBuilderField.js.map +1 -1
  42. package/dist/index.d.ts +2 -0
  43. package/dist/index.d.ts.map +1 -1
  44. package/dist/index.js +1 -0
  45. package/dist/index.js.map +1 -1
  46. package/dist/lib/root-chrome.d.ts +15 -0
  47. package/dist/lib/root-chrome.d.ts.map +1 -0
  48. package/dist/lib/root-chrome.js +41 -0
  49. package/dist/lib/root-chrome.js.map +1 -0
  50. package/dist/views/HiddenLoginNotFound.d.ts +8 -0
  51. package/dist/views/HiddenLoginNotFound.d.ts.map +1 -0
  52. package/dist/views/HiddenLoginNotFound.js +29 -0
  53. package/dist/views/HiddenLoginNotFound.js.map +1 -0
  54. package/dist/views/page-builder/AIGenerateDialog.d.ts +1 -1
  55. package/dist/views/page-builder/AIGenerateDialog.d.ts.map +1 -1
  56. package/dist/views/page-builder/AIGenerateDialog.js +23 -23
  57. package/dist/views/page-builder/AIGenerateDialog.js.map +1 -1
  58. package/package.json +2 -2
  59. package/src/AdminRoot.tsx +61 -59
  60. package/src/LoginRoot.tsx +166 -0
  61. package/src/__tests__/components/modal.render.test.tsx +57 -0
  62. package/src/__tests__/views/login-root.render.test.tsx +115 -0
  63. package/src/components/MediaPickerModal.tsx +143 -119
  64. package/src/components/SchedulePublishDialog.tsx +118 -93
  65. package/src/components/SharePreviewLinkDialog.tsx +115 -93
  66. package/src/components/VersionHistory.tsx +98 -82
  67. package/src/components/ui/Modal.tsx +48 -39
  68. package/src/fields/BlockBuilderField.tsx +26 -19
  69. package/src/index.ts +2 -0
  70. package/src/lib/root-chrome.ts +40 -0
  71. package/src/views/HiddenLoginNotFound.tsx +45 -0
  72. 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
- <div className="fixed inset-0 z-50 flex items-center justify-center">
126
- <div className="fixed inset-0 bg-black/40" onClick={onClose} />
127
- <div className="relative mx-4 flex max-h-[80vh] w-full max-w-2xl flex-col rounded-xl bg-white shadow-2xl">
128
- <div className="flex items-center justify-between border-b border-gray-200 px-4 py-3">
129
- <h2 className="text-lg font-semibold text-gray-900">Insert Image</h2>
130
- <button
131
- onClick={onClose}
132
- className="rounded-lg p-1.5 transition-colors hover:bg-gray-100"
133
- >
134
- <X className="h-5 w-5 text-gray-500" />
135
- </button>
136
- </div>
137
-
138
- {!libraryOnly && (
139
- <div className="flex border-b border-gray-200">
140
- <button
141
- onClick={() => setTab('library')}
142
- className={`flex-1 px-4 py-2.5 text-sm font-medium transition-colors ${
143
- tab === 'library'
144
- ? 'border-b-2 border-blue-600 text-blue-600'
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
- <div className="flex-1 overflow-y-auto p-4">
164
- {libraryOnly || tab === 'library' ? (
165
- <>
166
- <div className="relative mb-4">
167
- <Search className="absolute top-1/2 left-3 h-4 w-4 -translate-y-1/2 text-gray-400" />
168
- <input
169
- type="text"
170
- value={search}
171
- onChange={(e) => setSearch(e.target.value)}
172
- placeholder="Search images..."
173
- className="w-full rounded-lg border border-gray-300 py-2 pr-3 pl-9 text-sm focus:ring-2 focus:ring-blue-500 focus:outline-none"
174
- />
175
- </div>
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
- {loading ? (
178
- <div className="flex items-center justify-center py-12">
179
- <Loader2 className="h-6 w-6 animate-spin text-gray-400" />
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
- ) : imageItems.length === 0 ? (
182
- <div className="py-12 text-center text-sm text-gray-500">
183
- No images found. Try uploading one.
184
- </div>
185
- ) : (
186
- <div className="grid grid-cols-3 gap-3 sm:grid-cols-4">
187
- {imageItems.map((item: MediaItem) => {
188
- const src = item.url || item.storageKey
189
- return (
190
- <button
191
- key={item.id}
192
- onClick={() => handleSelectItem(item)}
193
- title={item.filename}
194
- className="group relative aspect-square overflow-hidden rounded-lg border-2 border-gray-200 bg-gray-100 transition-colors hover:border-blue-500"
195
- >
196
- {src ? (
197
- <img
198
- src={src}
199
- alt={item.filename}
200
- loading="lazy"
201
- className="h-full w-full object-contain"
202
- />
203
- ) : (
204
- <div className="flex h-full w-full items-center justify-center">
205
- <ImageIcon className="h-8 w-8 text-gray-300" />
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
- <div className="absolute inset-x-0 bottom-0 bg-black/60 p-1.5 opacity-0 transition-opacity group-hover:opacity-100">
209
- <p className="truncate text-xs text-white">{item.filename}</p>
210
- </div>
211
- </button>
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
- <div className="flex flex-col items-center justify-center py-12">
219
- <input
220
- ref={fileInputRef}
221
- type="file"
222
- accept={accept ?? 'image/*'}
223
- className="hidden"
224
- onChange={(e) => handleUpload(e.target.files)}
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
- <p className="mb-4 text-sm text-gray-600">
234
- {uploading ? 'Uploading...' : 'Select an image file to upload'}
235
- </p>
236
- {!uploading && (
237
- <button
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
  }