@actuate-media/cms-admin 0.49.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 (46) hide show
  1. package/CHANGELOG.md +13 -0
  2. package/dist/AdminRoot.d.ts.map +1 -1
  3. package/dist/AdminRoot.js +10 -11
  4. package/dist/AdminRoot.js.map +1 -1
  5. package/dist/__tests__/components/modal.render.test.d.ts +2 -0
  6. package/dist/__tests__/components/modal.render.test.d.ts.map +1 -0
  7. package/dist/__tests__/components/modal.render.test.js +37 -0
  8. package/dist/__tests__/components/modal.render.test.js.map +1 -0
  9. package/dist/actuate-admin.css +1 -1
  10. package/dist/components/MediaPickerModal.d.ts +1 -1
  11. package/dist/components/MediaPickerModal.d.ts.map +1 -1
  12. package/dist/components/MediaPickerModal.js +12 -11
  13. package/dist/components/MediaPickerModal.js.map +1 -1
  14. package/dist/components/SchedulePublishDialog.d.ts +1 -1
  15. package/dist/components/SchedulePublishDialog.d.ts.map +1 -1
  16. package/dist/components/SchedulePublishDialog.js +6 -4
  17. package/dist/components/SchedulePublishDialog.js.map +1 -1
  18. package/dist/components/SharePreviewLinkDialog.d.ts +1 -1
  19. package/dist/components/SharePreviewLinkDialog.d.ts.map +1 -1
  20. package/dist/components/SharePreviewLinkDialog.js +10 -9
  21. package/dist/components/SharePreviewLinkDialog.js.map +1 -1
  22. package/dist/components/VersionHistory.d.ts +1 -1
  23. package/dist/components/VersionHistory.d.ts.map +1 -1
  24. package/dist/components/VersionHistory.js +14 -14
  25. package/dist/components/VersionHistory.js.map +1 -1
  26. package/dist/components/ui/Modal.d.ts +17 -1
  27. package/dist/components/ui/Modal.d.ts.map +1 -1
  28. package/dist/components/ui/Modal.js +16 -15
  29. package/dist/components/ui/Modal.js.map +1 -1
  30. package/dist/fields/BlockBuilderField.d.ts.map +1 -1
  31. package/dist/fields/BlockBuilderField.js +6 -4
  32. package/dist/fields/BlockBuilderField.js.map +1 -1
  33. package/dist/views/page-builder/AIGenerateDialog.d.ts +1 -1
  34. package/dist/views/page-builder/AIGenerateDialog.d.ts.map +1 -1
  35. package/dist/views/page-builder/AIGenerateDialog.js +23 -23
  36. package/dist/views/page-builder/AIGenerateDialog.js.map +1 -1
  37. package/package.json +1 -1
  38. package/src/AdminRoot.tsx +35 -30
  39. package/src/__tests__/components/modal.render.test.tsx +57 -0
  40. package/src/components/MediaPickerModal.tsx +143 -119
  41. package/src/components/SchedulePublishDialog.tsx +118 -93
  42. package/src/components/SharePreviewLinkDialog.tsx +115 -93
  43. package/src/components/VersionHistory.tsx +98 -82
  44. package/src/components/ui/Modal.tsx +48 -39
  45. package/src/fields/BlockBuilderField.tsx +26 -19
  46. package/src/views/page-builder/AIGenerateDialog.tsx +347 -335
@@ -1,9 +1,11 @@
1
1
  'use client'
2
2
 
3
- import { useState } from 'react'
3
+ import { useId, useState } from 'react'
4
+ import * as Dialog from '@radix-ui/react-dialog'
4
5
  import { Check, Copy, Link2, Loader2, Share2, X } 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
 
8
10
  export interface SharePreviewLinkDialogProps {
9
11
  collectionSlug: string
@@ -75,6 +77,7 @@ export function SharePreviewLinkDialog({
75
77
  const [expiresAt, setExpiresAt] = useState<string>('')
76
78
  const [generating, setGenerating] = useState(false)
77
79
  const [copied, setCopied] = useState(false)
80
+ const previewUrlId = useId()
78
81
 
79
82
  async function generate() {
80
83
  setGenerating(true)
@@ -121,107 +124,126 @@ export function SharePreviewLinkDialog({
121
124
  }
122
125
  }
123
126
 
124
- if (!open) return null
125
-
126
127
  return (
127
- <div className="fixed inset-0 z-50 flex items-center justify-center p-4">
128
- <div className="fixed inset-0 bg-black/40" onClick={onClose} />
129
- <div className="relative w-full max-w-lg rounded-lg bg-white shadow-xl">
130
- <div className="flex items-center justify-between border-b border-gray-200 px-4 py-3">
131
- <div className="flex items-center gap-2">
132
- <Share2 className="h-5 w-5 text-gray-600" />
133
- <h2 className="text-lg font-semibold text-gray-900">Share preview link</h2>
128
+ <Dialog.Root open={open} onOpenChange={(next) => !next && onClose()}>
129
+ <Dialog.Portal container={adminPortalContainer()}>
130
+ <Dialog.Overlay className="fixed inset-0 z-50 bg-black/40" />
131
+ <Dialog.Content className="bg-card text-card-foreground border-border fixed top-1/2 left-1/2 z-50 w-full max-w-lg -translate-x-1/2 -translate-y-1/2 rounded-lg border shadow-xl focus:outline-none">
132
+ <div className="border-border flex items-center justify-between border-b px-4 py-3">
133
+ <div className="flex items-center gap-2">
134
+ <Share2 className="text-muted-foreground h-5 w-5" aria-hidden />
135
+ <Dialog.Title className="text-foreground text-lg font-medium">
136
+ Share preview link
137
+ </Dialog.Title>
138
+ </div>
139
+ <Dialog.Close asChild>
140
+ <button
141
+ type="button"
142
+ aria-label="Close dialog"
143
+ 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"
144
+ >
145
+ <X className="h-5 w-5" aria-hidden />
146
+ </button>
147
+ </Dialog.Close>
134
148
  </div>
135
- <button
136
- onClick={onClose}
137
- className="rounded-lg p-1.5 transition-colors hover:bg-gray-100"
138
- >
139
- <X className="h-5 w-5 text-gray-500" />
140
- </button>
141
- </div>
142
149
 
143
- <div className="space-y-4 px-4 py-4">
144
- <p className="text-sm text-gray-600">
145
- Generate a signed URL that lets anyone with the link view the current draft of this
146
- document — even if it's never been published.
147
- </p>
150
+ <div className="space-y-4 px-4 py-4">
151
+ <Dialog.Description className="text-muted-foreground text-sm">
152
+ Generate a signed URL that lets anyone with the link view the current draft of this
153
+ document — even if it's never been published.
154
+ </Dialog.Description>
148
155
 
149
- <div className="space-y-2">
150
- <label className="text-sm font-medium text-gray-700">Link lifetime</label>
151
- <div className="grid grid-cols-2 gap-2">
152
- {TTL_CHOICES.map((choice) => {
153
- const selected = ttl === choice.seconds
154
- return (
156
+ <div className="space-y-2">
157
+ <span className="text-foreground text-sm font-medium">Link lifetime</span>
158
+ <div className="grid grid-cols-2 gap-2">
159
+ {TTL_CHOICES.map((choice) => {
160
+ const selected = ttl === choice.seconds
161
+ return (
162
+ <button
163
+ key={choice.seconds}
164
+ type="button"
165
+ aria-pressed={selected}
166
+ onClick={() => setTtl(choice.seconds)}
167
+ className={`rounded-md border px-3 py-2 text-left text-sm transition-colors ${
168
+ selected
169
+ ? 'border-primary bg-primary/10 text-foreground'
170
+ : 'border-border hover:border-input hover:bg-accent'
171
+ }`}
172
+ >
173
+ <div className="font-medium">{choice.label}</div>
174
+ <div className="text-muted-foreground text-xs">{choice.hint}</div>
175
+ </button>
176
+ )
177
+ })}
178
+ </div>
179
+ <p className="text-muted-foreground text-xs">
180
+ Preview tokens can't be revoked individually — pick the shortest TTL that fits.
181
+ </p>
182
+ </div>
183
+
184
+ {previewUrl ? (
185
+ <div className="border-border bg-muted space-y-2 rounded-lg border p-3">
186
+ <label
187
+ htmlFor={previewUrlId}
188
+ className="text-muted-foreground text-xs font-medium tracking-wide uppercase"
189
+ >
190
+ Preview URL
191
+ </label>
192
+ <div className="flex items-stretch gap-2">
193
+ <input
194
+ id={previewUrlId}
195
+ readOnly
196
+ value={previewUrl}
197
+ onFocus={(e) => e.currentTarget.select()}
198
+ className="border-input bg-background text-foreground flex-1 rounded-md border px-2 py-1.5 font-mono text-xs"
199
+ />
155
200
  <button
156
- key={choice.seconds}
157
- onClick={() => setTtl(choice.seconds)}
158
- className={`rounded-md border px-3 py-2 text-left text-sm transition-colors ${
159
- selected
160
- ? 'border-blue-500 bg-blue-50 text-blue-900'
161
- : 'border-gray-200 hover:border-gray-300 hover:bg-gray-50'
162
- }`}
201
+ type="button"
202
+ onClick={copyToClipboard}
203
+ className="bg-primary text-primary-foreground inline-flex items-center gap-1.5 rounded-md px-3 py-1.5 text-xs font-medium hover:opacity-90"
163
204
  >
164
- <div className="font-medium">{choice.label}</div>
165
- <div className="text-xs text-gray-500">{choice.hint}</div>
205
+ {copied ? (
206
+ <Check className="h-3.5 w-3.5" aria-hidden />
207
+ ) : (
208
+ <Copy className="h-3.5 w-3.5" aria-hidden />
209
+ )}
210
+ {copied ? 'Copied' : 'Copy'}
166
211
  </button>
167
- )
168
- })}
169
- </div>
170
- <p className="text-xs text-gray-500">
171
- Preview tokens can't be revoked individually — pick the shortest TTL that fits.
172
- </p>
212
+ </div>
213
+ {expiresAt && (
214
+ <p className="text-muted-foreground text-xs">
215
+ Expires {new Date(expiresAt).toLocaleString()}
216
+ </p>
217
+ )}
218
+ </div>
219
+ ) : null}
173
220
  </div>
174
221
 
175
- {previewUrl ? (
176
- <div className="space-y-2 rounded-lg border border-gray-200 bg-gray-50 p-3">
177
- <label className="text-xs font-medium tracking-wide text-gray-500 uppercase">
178
- Preview URL
179
- </label>
180
- <div className="flex items-stretch gap-2">
181
- <input
182
- readOnly
183
- value={previewUrl}
184
- onFocus={(e) => e.currentTarget.select()}
185
- className="flex-1 rounded-md border border-gray-300 bg-white px-2 py-1.5 font-mono text-xs"
186
- />
187
- <button
188
- onClick={copyToClipboard}
189
- className="inline-flex items-center gap-1.5 rounded-md bg-gray-900 px-3 py-1.5 text-xs font-medium text-white hover:bg-gray-700"
190
- >
191
- {copied ? <Check className="h-3.5 w-3.5" /> : <Copy className="h-3.5 w-3.5" />}
192
- {copied ? 'Copied' : 'Copy'}
193
- </button>
194
- </div>
195
- {expiresAt && (
196
- <p className="text-xs text-gray-500">
197
- Expires {new Date(expiresAt).toLocaleString()}
198
- </p>
222
+ <div className="border-border flex items-center justify-end gap-2 border-t px-4 py-3">
223
+ <Dialog.Close asChild>
224
+ <button
225
+ type="button"
226
+ className="text-foreground hover:bg-accent rounded-md px-3 py-1.5 text-sm"
227
+ >
228
+ Close
229
+ </button>
230
+ </Dialog.Close>
231
+ <button
232
+ type="button"
233
+ onClick={generate}
234
+ disabled={generating}
235
+ className="bg-primary text-primary-foreground inline-flex items-center gap-1.5 rounded-md px-3 py-1.5 text-sm font-medium hover:opacity-90 disabled:opacity-50"
236
+ >
237
+ {generating ? (
238
+ <Loader2 className="h-4 w-4 animate-spin" aria-hidden />
239
+ ) : (
240
+ <Link2 className="h-4 w-4" aria-hidden />
199
241
  )}
200
- </div>
201
- ) : null}
202
- </div>
203
-
204
- <div className="flex items-center justify-end gap-2 border-t border-gray-200 px-4 py-3">
205
- <button
206
- onClick={onClose}
207
- className="rounded-md px-3 py-1.5 text-sm text-gray-700 hover:bg-gray-100"
208
- >
209
- Close
210
- </button>
211
- <button
212
- onClick={generate}
213
- disabled={generating}
214
- className="inline-flex items-center gap-1.5 rounded-md bg-blue-600 px-3 py-1.5 text-sm font-medium text-white hover:bg-blue-700 disabled:opacity-50"
215
- >
216
- {generating ? (
217
- <Loader2 className="h-4 w-4 animate-spin" />
218
- ) : (
219
- <Link2 className="h-4 w-4" />
220
- )}
221
- {previewUrl ? 'Generate new link' : 'Generate link'}
222
- </button>
223
- </div>
224
- </div>
225
- </div>
242
+ {previewUrl ? 'Generate new link' : 'Generate link'}
243
+ </button>
244
+ </div>
245
+ </Dialog.Content>
246
+ </Dialog.Portal>
247
+ </Dialog.Root>
226
248
  )
227
249
  }
@@ -1,9 +1,11 @@
1
1
  'use client'
2
2
 
3
3
  import { useState, useEffect } from 'react'
4
+ import * as Dialog from '@radix-ui/react-dialog'
4
5
  import { X, RotateCcw, Clock, User, 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
 
8
10
  interface Version {
9
11
  id: string
@@ -38,10 +40,10 @@ function timeAgo(dateStr: string): string {
38
40
  }
39
41
 
40
42
  const CHANGE_TYPE_LABELS: Record<string, { label: string; color: string }> = {
41
- CREATE: { label: 'Created', color: 'bg-green-100 text-green-800' },
42
- UPDATE: { label: 'Updated', color: 'bg-blue-100 text-blue-800' },
43
- DELETE: { label: 'Deleted', color: 'bg-red-100 text-red-800' },
44
- RESTORE: { label: 'Restored', color: 'bg-purple-100 text-purple-800' },
43
+ CREATE: { label: 'Created', color: 'bg-success/15 text-success' },
44
+ UPDATE: { label: 'Updated', color: 'bg-primary/15 text-primary' },
45
+ DELETE: { label: 'Deleted', color: 'bg-destructive/15 text-destructive' },
46
+ RESTORE: { label: 'Restored', color: 'bg-accent text-accent-foreground' },
45
47
  }
46
48
 
47
49
  export function VersionHistory({
@@ -88,91 +90,105 @@ export function VersionHistory({
88
90
  setRestoring(null)
89
91
  }
90
92
 
91
- if (!open) return null
92
-
93
93
  return (
94
- <div className="fixed inset-0 z-50 flex justify-end">
95
- <div className="fixed inset-0 bg-black/30" onClick={onClose} />
96
- <div className="animate-in slide-in-from-right relative flex w-full max-w-md flex-col bg-white shadow-xl">
97
- <div className="flex items-center justify-between border-b border-gray-200 px-4 py-3">
98
- <div className="flex items-center gap-2">
99
- <Clock className="h-5 w-5 text-gray-600" />
100
- <h2 className="text-lg font-semibold text-gray-900">Version History</h2>
94
+ <Dialog.Root open={open} onOpenChange={(next) => !next && onClose()}>
95
+ <Dialog.Portal container={adminPortalContainer()}>
96
+ <Dialog.Overlay className="fixed inset-0 z-50 bg-black/30" />
97
+ <Dialog.Content
98
+ aria-describedby={undefined}
99
+ className="bg-card text-card-foreground border-border animate-in slide-in-from-right fixed inset-y-0 right-0 z-50 flex w-full max-w-md flex-col border-l shadow-xl focus:outline-none"
100
+ >
101
+ <div className="border-border flex items-center justify-between border-b px-4 py-3">
102
+ <div className="flex items-center gap-2">
103
+ <Clock className="text-muted-foreground h-5 w-5" aria-hidden />
104
+ <Dialog.Title className="text-foreground text-lg font-medium">
105
+ Version History
106
+ </Dialog.Title>
107
+ </div>
108
+ <Dialog.Close asChild>
109
+ <button
110
+ type="button"
111
+ aria-label="Close version history"
112
+ 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"
113
+ >
114
+ <X className="h-5 w-5" aria-hidden />
115
+ </button>
116
+ </Dialog.Close>
101
117
  </div>
102
- <button
103
- onClick={onClose}
104
- className="rounded-lg p-1.5 transition-colors hover:bg-gray-100"
105
- >
106
- <X className="h-5 w-5 text-gray-500" />
107
- </button>
108
- </div>
109
118
 
110
- <div className="flex-1 overflow-y-auto">
111
- {loading ? (
112
- <div className="flex items-center justify-center py-16">
113
- <Loader2 className="h-6 w-6 animate-spin text-gray-400" />
114
- </div>
115
- ) : versions.length === 0 ? (
116
- <div className="py-16 text-center text-sm text-gray-500">
117
- No version history available
118
- </div>
119
- ) : (
120
- <div className="divide-y divide-gray-100">
121
- {versions.map((version, index) => {
122
- const typeInfo = CHANGE_TYPE_LABELS[version.changeType] ?? {
123
- label: version.changeType,
124
- color: 'bg-gray-100 text-gray-800',
125
- }
126
- const isLatest = index === 0
119
+ <div className="flex-1 overflow-y-auto">
120
+ {loading ? (
121
+ <div className="flex items-center justify-center py-16">
122
+ <Loader2 className="text-muted-foreground h-6 w-6 animate-spin" aria-hidden />
123
+ </div>
124
+ ) : versions.length === 0 ? (
125
+ <div className="text-muted-foreground py-16 text-center text-sm">
126
+ No version history available
127
+ </div>
128
+ ) : (
129
+ <div className="divide-border divide-y">
130
+ {versions.map((version, index) => {
131
+ const typeInfo = CHANGE_TYPE_LABELS[version.changeType] ?? {
132
+ label: version.changeType,
133
+ color: 'bg-muted text-muted-foreground',
134
+ }
135
+ const isLatest = index === 0
127
136
 
128
- return (
129
- <div key={version.id} className="px-4 py-3 transition-colors hover:bg-gray-50">
130
- <div className="flex items-start justify-between gap-3">
131
- <div className="min-w-0 flex-1">
132
- <div className="mb-1 flex items-center gap-2">
133
- <span
134
- className={`inline-flex rounded-full px-2 py-0.5 text-xs font-medium ${typeInfo.color}`}
135
- >
136
- {typeInfo.label}
137
- </span>
138
- {isLatest && (
139
- <span className="text-xs font-medium text-gray-400">Current</span>
140
- )}
141
- </div>
142
- <div className="flex items-center gap-3 text-xs text-gray-500">
143
- {version.changedBy && (
144
- <span className="flex items-center gap-1">
145
- <User className="h-3 w-3" />
146
- {version.changedBy.name ?? version.changedBy.email ?? 'Unknown'}
137
+ return (
138
+ <div
139
+ key={version.id}
140
+ className="hover:bg-accent/50 px-4 py-3 transition-colors"
141
+ >
142
+ <div className="flex items-start justify-between gap-3">
143
+ <div className="min-w-0 flex-1">
144
+ <div className="mb-1 flex items-center gap-2">
145
+ <span
146
+ className={`inline-flex rounded-full px-2 py-0.5 text-xs font-medium ${typeInfo.color}`}
147
+ >
148
+ {typeInfo.label}
149
+ </span>
150
+ {isLatest && (
151
+ <span className="text-muted-foreground text-xs font-medium">
152
+ Current
153
+ </span>
154
+ )}
155
+ </div>
156
+ <div className="text-muted-foreground flex items-center gap-3 text-xs">
157
+ {version.changedBy && (
158
+ <span className="flex items-center gap-1">
159
+ <User className="h-3 w-3" aria-hidden />
160
+ {version.changedBy.name ?? version.changedBy.email ?? 'Unknown'}
161
+ </span>
162
+ )}
163
+ <span title={new Date(version.createdAt).toLocaleString()}>
164
+ {timeAgo(version.createdAt)}
147
165
  </span>
148
- )}
149
- <span title={new Date(version.createdAt).toLocaleString()}>
150
- {timeAgo(version.createdAt)}
151
- </span>
166
+ </div>
152
167
  </div>
168
+ {!isLatest && (
169
+ <button
170
+ type="button"
171
+ onClick={() => handleRestore(version)}
172
+ disabled={restoring === version.id}
173
+ className="bg-primary/10 text-primary hover:bg-primary/20 flex items-center gap-1.5 rounded-lg px-2.5 py-1.5 text-xs font-medium transition-colors disabled:opacity-50"
174
+ >
175
+ {restoring === version.id ? (
176
+ <Loader2 className="h-3.5 w-3.5 animate-spin" aria-hidden />
177
+ ) : (
178
+ <RotateCcw className="h-3.5 w-3.5" aria-hidden />
179
+ )}
180
+ Restore
181
+ </button>
182
+ )}
153
183
  </div>
154
- {!isLatest && (
155
- <button
156
- onClick={() => handleRestore(version)}
157
- disabled={restoring === version.id}
158
- className="flex items-center gap-1.5 rounded-lg bg-blue-50 px-2.5 py-1.5 text-xs font-medium text-blue-700 transition-colors hover:bg-blue-100 disabled:opacity-50"
159
- >
160
- {restoring === version.id ? (
161
- <Loader2 className="h-3.5 w-3.5 animate-spin" />
162
- ) : (
163
- <RotateCcw className="h-3.5 w-3.5" />
164
- )}
165
- Restore
166
- </button>
167
- )}
168
184
  </div>
169
- </div>
170
- )
171
- })}
172
- </div>
173
- )}
174
- </div>
175
- </div>
176
- </div>
185
+ )
186
+ })}
187
+ </div>
188
+ )}
189
+ </div>
190
+ </Dialog.Content>
191
+ </Dialog.Portal>
192
+ </Dialog.Root>
177
193
  )
178
194
  }
@@ -1,6 +1,9 @@
1
1
  'use client'
2
2
 
3
- import { useEffect, type ReactNode } from 'react'
3
+ import * as Dialog from '@radix-ui/react-dialog'
4
+ import { X } from 'lucide-react'
5
+ import { type ReactNode } from 'react'
6
+ import { adminPortalContainer } from '../../lib/portal-container.js'
4
7
 
5
8
  export interface ModalProps {
6
9
  open: boolean
@@ -8,47 +11,53 @@ export interface ModalProps {
8
11
  title: string
9
12
  children: ReactNode
10
13
  actions?: ReactNode
14
+ /**
15
+ * Accessible description for the dialog, announced after the title. Optional —
16
+ * omit when the body content is self-explanatory.
17
+ */
18
+ description?: string
11
19
  }
12
20
 
13
- export function Modal({ open, onClose, title, children, actions }: ModalProps) {
14
- useEffect(() => {
15
- if (!open) return
16
-
17
- function handleKey(e: KeyboardEvent) {
18
- if (e.key === 'Escape') onClose()
19
- }
20
-
21
- document.addEventListener('keydown', handleKey)
22
- return () => document.removeEventListener('keydown', handleKey)
23
- }, [open, onClose])
24
-
25
- if (!open) return null
26
-
21
+ /**
22
+ * The canonical admin modal. Built on Radix `Dialog`, which provides the
23
+ * accessibility contract for free: focus is trapped while open and restored to
24
+ * the trigger on close, Escape and overlay-click dismiss, the surface is
25
+ * exposed as `role="dialog"` + `aria-modal`, and the title/description are wired
26
+ * via `aria-labelledby`/`aria-describedby`.
27
+ *
28
+ * The portal mounts inside `.actuate-admin` (see `adminPortalContainer`) so the
29
+ * dialog inherits theme tokens and dark mode instead of rendering unstyled on
30
+ * `document.body`.
31
+ */
32
+ export function Modal({ open, onClose, title, children, actions, description }: ModalProps) {
27
33
  return (
28
- <div className="fixed inset-0 z-[60] flex items-center justify-center">
29
- <div className="fixed inset-0 bg-black/50" onClick={onClose} role="presentation" />
30
- <div className="relative z-10 w-full max-w-lg rounded-lg border border-[var(--border)] bg-[var(--card)] shadow-xl">
31
- <div className="flex items-center justify-between border-b border-[var(--border)] px-6 py-4">
32
- <h2 className="text-lg font-semibold">{title}</h2>
33
- <button onClick={onClose} className="rounded-md p-1 hover:bg-[var(--accent)]">
34
- <svg
35
- className="h-4 w-4"
36
- fill="none"
37
- viewBox="0 0 24 24"
38
- stroke="currentColor"
39
- strokeWidth={2}
40
- >
41
- <path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
42
- </svg>
43
- </button>
44
- </div>
45
- <div className="px-6 py-4">{children}</div>
46
- {actions && (
47
- <div className="flex justify-end gap-2 border-t border-[var(--border)] px-6 py-4">
48
- {actions}
34
+ <Dialog.Root open={open} onOpenChange={(next) => !next && onClose()}>
35
+ <Dialog.Portal container={adminPortalContainer()}>
36
+ <Dialog.Overlay className="fixed inset-0 z-60 bg-black/50" />
37
+ <Dialog.Content className="bg-card text-card-foreground border-border fixed top-1/2 left-1/2 z-60 w-full max-w-lg -translate-x-1/2 -translate-y-1/2 rounded-lg border shadow-xl focus:outline-none">
38
+ <div className="border-border flex items-center justify-between border-b px-6 py-4">
39
+ <Dialog.Title className="text-foreground text-lg font-medium">{title}</Dialog.Title>
40
+ <Dialog.Close asChild>
41
+ <button
42
+ type="button"
43
+ aria-label="Close dialog"
44
+ className="text-muted-foreground hover:bg-accent hover:text-foreground focus-visible:ring-ring rounded-md p-1 transition-colors focus-visible:ring-2 focus-visible:outline-none"
45
+ >
46
+ <X className="h-4 w-4" aria-hidden />
47
+ </button>
48
+ </Dialog.Close>
49
49
  </div>
50
- )}
51
- </div>
52
- </div>
50
+ {description && (
51
+ <Dialog.Description className="text-muted-foreground px-6 pt-4 text-sm">
52
+ {description}
53
+ </Dialog.Description>
54
+ )}
55
+ <div className="px-6 py-4">{children}</div>
56
+ {actions && (
57
+ <div className="border-border flex justify-end gap-2 border-t px-6 py-4">{actions}</div>
58
+ )}
59
+ </Dialog.Content>
60
+ </Dialog.Portal>
61
+ </Dialog.Root>
53
62
  )
54
63
  }
@@ -18,6 +18,7 @@ import {
18
18
  arrayMove,
19
19
  } from '@dnd-kit/sortable'
20
20
  import { CSS } from '@dnd-kit/utilities'
21
+ import * as Dialog from '@radix-ui/react-dialog'
21
22
  import {
22
23
  Plus,
23
24
  GripVertical,
@@ -33,6 +34,7 @@ import {
33
34
  X,
34
35
  } from 'lucide-react'
35
36
  import { PRESET_BLOCKS, type BlockTypeDefinition } from './block-types.js'
37
+ import { adminPortalContainer } from '../lib/portal-container.js'
36
38
 
37
39
  const BLOCK_ICONS: Record<string, React.ComponentType<{ className?: string }>> = {
38
40
  layout: Layout,
@@ -359,19 +361,24 @@ export function BlockBuilderField({
359
361
  </button>
360
362
  </div>
361
363
 
362
- {pickerOpen && (
363
- <div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50">
364
- <div className="w-full max-w-2xl rounded-lg border border-[var(--border)] bg-[var(--card)] shadow-xl">
365
- <div className="flex items-center justify-between border-b border-[var(--border)] px-5 py-4">
366
- <h3 className="text-base font-semibold">Add Block</h3>
367
- <button
368
- type="button"
369
- onClick={() => setPickerOpen(false)}
370
- className="text-[var(--muted-foreground)] hover:text-[var(--foreground)]"
371
- aria-label="Close"
372
- >
373
- <X className="h-5 w-5" />
374
- </button>
364
+ <Dialog.Root open={pickerOpen} onOpenChange={setPickerOpen}>
365
+ <Dialog.Portal container={adminPortalContainer()}>
366
+ <Dialog.Overlay className="fixed inset-0 z-50 bg-black/50" />
367
+ <Dialog.Content
368
+ aria-describedby={undefined}
369
+ className="bg-card text-card-foreground border-border fixed top-1/2 left-1/2 z-50 w-[calc(100%-2rem)] max-w-2xl -translate-x-1/2 -translate-y-1/2 rounded-lg border shadow-xl focus:outline-none"
370
+ >
371
+ <div className="border-border flex items-center justify-between border-b px-5 py-4">
372
+ <Dialog.Title className="text-base font-medium">Add Block</Dialog.Title>
373
+ <Dialog.Close asChild>
374
+ <button
375
+ type="button"
376
+ className="text-muted-foreground hover:text-foreground focus-visible:ring-ring rounded-md p-1 focus-visible:ring-2 focus-visible:outline-none"
377
+ aria-label="Close block picker"
378
+ >
379
+ <X className="h-5 w-5" aria-hidden />
380
+ </button>
381
+ </Dialog.Close>
375
382
  </div>
376
383
  <div className="grid grid-cols-2 gap-3 p-5 sm:grid-cols-3">
377
384
  {allBlockTypes.map((bt) => {
@@ -381,20 +388,20 @@ export function BlockBuilderField({
381
388
  key={bt.type}
382
389
  type="button"
383
390
  onClick={() => addBlock(bt.type)}
384
- className="flex flex-col items-start gap-1.5 rounded-md border border-[var(--border)] p-3 text-left transition-colors hover:border-[var(--primary)] hover:bg-[var(--accent)]"
391
+ className="border-border hover:border-primary hover:bg-accent flex flex-col items-start gap-1.5 rounded-md border p-3 text-left transition-colors"
385
392
  >
386
- {Icon && <Icon className="h-5 w-5 text-[var(--primary)]" />}
393
+ {Icon && <Icon className="text-primary h-5 w-5" aria-hidden />}
387
394
  <span className="text-sm font-medium">{bt.label}</span>
388
- <span className="text-xs leading-snug text-[var(--muted-foreground)]">
395
+ <span className="text-muted-foreground text-xs leading-snug">
389
396
  {bt.description}
390
397
  </span>
391
398
  </button>
392
399
  )
393
400
  })}
394
401
  </div>
395
- </div>
396
- </div>
397
- )}
402
+ </Dialog.Content>
403
+ </Dialog.Portal>
404
+ </Dialog.Root>
398
405
 
399
406
  {helpText && <p className="mt-1 text-xs text-[var(--muted-foreground)]">{helpText}</p>}
400
407
  </div>