@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, 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
  }
@@ -1,9 +1,11 @@
1
1
  'use client'
2
2
 
3
- import { useEffect, useState } from 'react'
3
+ import { useEffect, useId, useState } from 'react'
4
+ import * as Dialog from '@radix-ui/react-dialog'
4
5
  import { Calendar, Clock, Loader2, 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 SchedulePublishDialogProps {
9
11
  collectionSlug: string
@@ -56,6 +58,8 @@ export function SchedulePublishDialog({
56
58
  const [includeUnpublish, setIncludeUnpublish] = useState(false)
57
59
  const [saving, setSaving] = useState(false)
58
60
  const [cancelling, setCancelling] = useState(false)
61
+ const publishAtId = useId()
62
+ const unpublishAtId = useId()
59
63
 
60
64
  const hasExistingSchedule = Boolean(scheduledAt || scheduledUnpublishAt)
61
65
 
@@ -133,109 +137,130 @@ export function SchedulePublishDialog({
133
137
  onClose()
134
138
  }
135
139
 
136
- if (!open) return null
137
-
138
140
  return (
139
- <div className="fixed inset-0 z-50 flex items-center justify-center p-4">
140
- <div className="fixed inset-0 bg-black/40" onClick={onClose} />
141
- <div className="relative w-full max-w-md rounded-lg bg-white shadow-xl">
142
- <div className="flex items-center justify-between border-b border-gray-200 px-4 py-3">
143
- <div className="flex items-center gap-2">
144
- <Calendar className="h-5 w-5 text-gray-600" />
145
- <h2 className="text-lg font-semibold text-gray-900">Schedule publishing</h2>
146
- </div>
147
- <button
148
- onClick={onClose}
149
- className="rounded-lg p-1.5 transition-colors hover:bg-gray-100"
150
- >
151
- <X className="h-5 w-5 text-gray-500" />
152
- </button>
153
- </div>
154
-
155
- <div className="space-y-4 px-4 py-4">
156
- <div className="space-y-1.5">
157
- <label className="text-sm font-medium text-gray-700">Publish at</label>
158
- <div className="relative">
159
- <Clock className="pointer-events-none absolute top-2.5 left-2.5 h-4 w-4 text-gray-400" />
160
- <input
161
- type="datetime-local"
162
- value={publishAt}
163
- onChange={(e) => setPublishAt(e.target.value)}
164
- min={toLocalDateTimeInput(new Date(Date.now() + 60 * 1000))}
165
- className="w-full rounded-md border border-gray-300 bg-white py-2 pr-3 pl-9 text-sm focus:border-blue-500 focus:ring-1 focus:ring-blue-500 focus:outline-none"
166
- />
141
+ <Dialog.Root open={open} onOpenChange={(next) => !next && onClose()}>
142
+ <Dialog.Portal container={adminPortalContainer()}>
143
+ <Dialog.Overlay className="fixed inset-0 z-50 bg-black/40" />
144
+ <Dialog.Content className="bg-card text-card-foreground border-border fixed top-1/2 left-1/2 z-50 w-full max-w-md -translate-x-1/2 -translate-y-1/2 rounded-lg border shadow-xl focus:outline-none">
145
+ <div className="border-border flex items-center justify-between border-b px-4 py-3">
146
+ <div className="flex items-center gap-2">
147
+ <Calendar className="text-muted-foreground h-5 w-5" aria-hidden />
148
+ <Dialog.Title className="text-foreground text-lg font-medium">
149
+ Schedule publishing
150
+ </Dialog.Title>
167
151
  </div>
168
- <p className="text-xs text-gray-500">
169
- The document will move from DRAFT to PUBLISHED at this time (your local timezone).
170
- </p>
152
+ <Dialog.Close asChild>
153
+ <button
154
+ type="button"
155
+ aria-label="Close dialog"
156
+ 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"
157
+ >
158
+ <X className="h-5 w-5" aria-hidden />
159
+ </button>
160
+ </Dialog.Close>
171
161
  </div>
172
162
 
173
- <label className="flex items-center gap-2 text-sm text-gray-700">
174
- <input
175
- type="checkbox"
176
- checked={includeUnpublish}
177
- onChange={(e) => setIncludeUnpublish(e.target.checked)}
178
- className="h-4 w-4 rounded border-gray-300 text-blue-600 focus:ring-blue-500"
179
- />
180
- Also schedule an unpublish
181
- </label>
182
-
183
- {includeUnpublish && (
163
+ <div className="space-y-4 px-4 py-4">
184
164
  <div className="space-y-1.5">
185
- <label className="text-sm font-medium text-gray-700">Unpublish at</label>
165
+ <label htmlFor={publishAtId} className="text-foreground text-sm font-medium">
166
+ Publish at
167
+ </label>
186
168
  <div className="relative">
187
- <Clock className="pointer-events-none absolute top-2.5 left-2.5 h-4 w-4 text-gray-400" />
169
+ <Clock
170
+ className="text-muted-foreground pointer-events-none absolute top-2.5 left-2.5 h-4 w-4"
171
+ aria-hidden
172
+ />
188
173
  <input
174
+ id={publishAtId}
189
175
  type="datetime-local"
190
- value={unpublishAt}
191
- onChange={(e) => setUnpublishAt(e.target.value)}
192
- min={publishAt || toLocalDateTimeInput(new Date(Date.now() + 60 * 1000))}
193
- className="w-full rounded-md border border-gray-300 bg-white py-2 pr-3 pl-9 text-sm focus:border-blue-500 focus:ring-1 focus:ring-blue-500 focus:outline-none"
176
+ value={publishAt}
177
+ onChange={(e) => setPublishAt(e.target.value)}
178
+ min={toLocalDateTimeInput(new Date(Date.now() + 60 * 1000))}
179
+ className="border-input bg-background text-foreground focus:border-ring focus:ring-ring w-full rounded-md border py-2 pr-3 pl-9 text-sm focus:ring-1 focus:outline-none"
194
180
  />
195
181
  </div>
182
+ <p className="text-muted-foreground text-xs">
183
+ The document will move from DRAFT to PUBLISHED at this time (your local timezone).
184
+ </p>
185
+ </div>
186
+
187
+ <label className="text-foreground flex items-center gap-2 text-sm">
188
+ <input
189
+ type="checkbox"
190
+ checked={includeUnpublish}
191
+ onChange={(e) => setIncludeUnpublish(e.target.checked)}
192
+ className="border-input text-primary focus:ring-ring h-4 w-4 rounded"
193
+ />
194
+ Also schedule an unpublish
195
+ </label>
196
+
197
+ {includeUnpublish && (
198
+ <div className="space-y-1.5">
199
+ <label htmlFor={unpublishAtId} className="text-foreground text-sm font-medium">
200
+ Unpublish at
201
+ </label>
202
+ <div className="relative">
203
+ <Clock
204
+ className="text-muted-foreground pointer-events-none absolute top-2.5 left-2.5 h-4 w-4"
205
+ aria-hidden
206
+ />
207
+ <input
208
+ id={unpublishAtId}
209
+ type="datetime-local"
210
+ value={unpublishAt}
211
+ onChange={(e) => setUnpublishAt(e.target.value)}
212
+ min={publishAt || toLocalDateTimeInput(new Date(Date.now() + 60 * 1000))}
213
+ className="border-input bg-background text-foreground focus:border-ring focus:ring-ring w-full rounded-md border py-2 pr-3 pl-9 text-sm focus:ring-1 focus:outline-none"
214
+ />
215
+ </div>
216
+ </div>
217
+ )}
218
+ </div>
219
+
220
+ <div className="border-border flex items-center justify-between gap-2 border-t px-4 py-3">
221
+ {hasExistingSchedule ? (
222
+ <button
223
+ type="button"
224
+ onClick={handleCancelSchedule}
225
+ disabled={cancelling || saving}
226
+ className="text-destructive hover:bg-destructive/10 inline-flex items-center gap-1.5 rounded-md px-3 py-1.5 text-sm font-medium disabled:opacity-50"
227
+ >
228
+ {cancelling ? (
229
+ <Loader2 className="h-4 w-4 animate-spin" aria-hidden />
230
+ ) : (
231
+ <X className="h-4 w-4" aria-hidden />
232
+ )}
233
+ Cancel schedule
234
+ </button>
235
+ ) : (
236
+ <span />
237
+ )}
238
+ <div className="flex items-center gap-2">
239
+ <Dialog.Close asChild>
240
+ <button
241
+ type="button"
242
+ className="text-foreground hover:bg-accent rounded-md px-3 py-1.5 text-sm"
243
+ >
244
+ Close
245
+ </button>
246
+ </Dialog.Close>
247
+ <button
248
+ type="button"
249
+ onClick={handleSave}
250
+ disabled={saving || cancelling}
251
+ 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"
252
+ >
253
+ {saving ? (
254
+ <Loader2 className="h-4 w-4 animate-spin" aria-hidden />
255
+ ) : (
256
+ <Calendar className="h-4 w-4" aria-hidden />
257
+ )}
258
+ {hasExistingSchedule ? 'Reschedule' : 'Schedule'}
259
+ </button>
196
260
  </div>
197
- )}
198
- </div>
199
-
200
- <div className="flex items-center justify-between gap-2 border-t border-gray-200 px-4 py-3">
201
- {hasExistingSchedule ? (
202
- <button
203
- onClick={handleCancelSchedule}
204
- disabled={cancelling || saving}
205
- className="inline-flex items-center gap-1.5 rounded-md px-3 py-1.5 text-sm font-medium text-red-700 hover:bg-red-50 disabled:opacity-50"
206
- >
207
- {cancelling ? (
208
- <Loader2 className="h-4 w-4 animate-spin" />
209
- ) : (
210
- <X className="h-4 w-4" />
211
- )}
212
- Cancel schedule
213
- </button>
214
- ) : (
215
- <span />
216
- )}
217
- <div className="flex items-center gap-2">
218
- <button
219
- onClick={onClose}
220
- className="rounded-md px-3 py-1.5 text-sm text-gray-700 hover:bg-gray-100"
221
- >
222
- Close
223
- </button>
224
- <button
225
- onClick={handleSave}
226
- disabled={saving || cancelling}
227
- 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"
228
- >
229
- {saving ? (
230
- <Loader2 className="h-4 w-4 animate-spin" />
231
- ) : (
232
- <Calendar className="h-4 w-4" />
233
- )}
234
- {hasExistingSchedule ? 'Reschedule' : 'Schedule'}
235
- </button>
236
261
  </div>
237
- </div>
238
- </div>
239
- </div>
262
+ </Dialog.Content>
263
+ </Dialog.Portal>
264
+ </Dialog.Root>
240
265
  )
241
266
  }