betterstart-cli 0.0.17 → 0.0.18

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 (27) hide show
  1. package/dist/assets/adapters/next/plugins/blog/schemas/menus.json +1 -0
  2. package/dist/assets/adapters/next/plugins/blog/schemas/posts.json +132 -99
  3. package/dist/assets/adapters/next/plugins/portfolio/plugin.ts +17 -0
  4. package/dist/assets/adapters/next/plugins/portfolio/schemas/portfolio.json +128 -0
  5. package/dist/assets/adapters/next/templates/init/components/shared/dev-mode/dev-mode-types.ts +2 -2
  6. package/dist/assets/adapters/next/templates/init/components/shared/dev-mode/snippets-tab.tsx +1 -0
  7. package/dist/assets/adapters/next/templates/init/components/shared/dev-mode-integrate.tsx +1 -1
  8. package/dist/assets/adapters/next/templates/init/components/shared/media/media-delete-dialog.tsx +1 -1
  9. package/dist/assets/adapters/next/templates/init/components/shared/media/media-delete-drawer.tsx +1 -1
  10. package/dist/assets/adapters/next/templates/init/components/shared/media/media-grid-item.tsx +17 -7
  11. package/dist/assets/adapters/next/templates/init/data/navigation.ts +2 -1
  12. package/dist/assets/adapters/next/templates/init/hooks/content-editor/use-content-editor-source-mode.tsx +3 -3
  13. package/dist/assets/adapters/next/templates/init/hooks/use-media.ts +2 -1
  14. package/dist/assets/adapters/next/templates/init/pages/profile/profile-form.tsx +3 -3
  15. package/dist/assets/shared-assets/react-admin/custom/content-editor/media-gallery-block.tsx +2 -2
  16. package/dist/assets/shared-assets/react-admin/custom/content-editor/table-add-controls.tsx +2 -2
  17. package/dist/assets/shared-assets/react-admin/custom/gallery-field.tsx +219 -40
  18. package/dist/assets/shared-assets/react-admin/custom/media-field-empty-state.tsx +99 -0
  19. package/dist/assets/shared-assets/react-admin/custom/media-field.tsx +181 -0
  20. package/dist/assets/shared-assets/react-admin/custom/sortable-gallery-item.tsx +38 -38
  21. package/dist/assets/shared-assets/react-admin/schema.json +64 -2
  22. package/dist/assets/shared-assets/react-admin/ui/pagination.tsx +1 -1
  23. package/dist/cli.js +470 -143
  24. package/dist/cli.js.map +1 -1
  25. package/package.json +1 -1
  26. package/dist/assets/shared-assets/react-admin/custom/gallery-thumbnail.tsx +0 -39
  27. package/dist/assets/shared-assets/react-admin/custom/media-gallery-field.tsx +0 -182
@@ -1,20 +1,32 @@
1
1
  'use client'
2
2
 
3
3
  import { MediaGalleryDialog } from '@admin/components/shared/media/media-gallery-dialog'
4
+ import { Card, CardContent, CardFooter } from '@admin/components/ui/card'
5
+ import {
6
+ Carousel,
7
+ type CarouselApi,
8
+ CarouselContent,
9
+ CarouselItem
10
+ } from '@admin/components/ui/carousel'
11
+ import { Skeleton } from '@admin/components/ui/skeleton'
4
12
  import { useMediaByIds } from '@admin/hooks/use-media'
5
13
  import type { AdminMedia } from '@admin/types'
6
14
  import {
7
15
  closestCenter,
8
16
  DndContext,
9
17
  type DragEndEvent,
18
+ DragOverlay,
19
+ type DragStartEvent,
10
20
  KeyboardSensor,
11
21
  PointerSensor,
12
22
  useSensor,
13
23
  useSensors
14
24
  } from '@dnd-kit/core'
15
- import { arrayMove, rectSortingStrategy, SortableContext } from '@dnd-kit/sortable'
16
- import { ImageIcon, Plus } from 'lucide-react'
25
+ import { arrayMove, horizontalListSortingStrategy, SortableContext } from '@dnd-kit/sortable'
26
+ import { Plus } from 'lucide-react'
17
27
  import * as React from 'react'
28
+ import { MediaField } from './media-field'
29
+ import { MediaFieldEmptyState } from './media-field-empty-state'
18
30
  import { SortableGalleryItem } from './sortable-gallery-item'
19
31
 
20
32
  interface GalleryFieldProps {
@@ -33,14 +45,22 @@ export function GalleryField({
33
45
  maxItems
34
46
  }: GalleryFieldProps) {
35
47
  const [open, setOpen] = React.useState(false)
48
+ const [activeId, setActiveId] = React.useState<string | null>(null)
36
49
  const { data: mediaItems } = useMediaByIds(value)
50
+ const carouselRootRef = React.useRef<HTMLDivElement | null>(null)
51
+ const carouselApiRef = React.useRef<CarouselApi>(undefined)
37
52
 
38
53
  const sensors = useSensors(
39
54
  useSensor(PointerSensor, { activationConstraint: { distance: 5 } }),
40
55
  useSensor(KeyboardSensor)
41
56
  )
42
57
 
58
+ function handleDragStart(event: DragStartEvent) {
59
+ setActiveId(String(event.active.id))
60
+ }
61
+
43
62
  function handleDragEnd(event: DragEndEvent) {
63
+ setActiveId(null)
44
64
  const { active, over } = event
45
65
  if (!over || active.id === over.id) return
46
66
 
@@ -51,6 +71,10 @@ export function GalleryField({
51
71
  }
52
72
  }
53
73
 
74
+ function handleDragCancel() {
75
+ setActiveId(null)
76
+ }
77
+
54
78
  function handleSelect(selected: AdminMedia | AdminMedia[]) {
55
79
  const items = Array.isArray(selected) ? selected : [selected]
56
80
  const newIds = items.map((m) => m.id).filter((id) => !value.includes(id))
@@ -62,6 +86,68 @@ export function GalleryField({
62
86
  onChange(value.filter((v) => v !== id))
63
87
  }
64
88
 
89
+ function handleReplace(currentId: string, nextId: string) {
90
+ if (!nextId) {
91
+ handleRemove(currentId)
92
+ return
93
+ }
94
+
95
+ if (nextId === currentId) return
96
+
97
+ onChange(
98
+ value.flatMap((id) => {
99
+ if (id !== currentId) return [id]
100
+ return value.includes(nextId) ? [] : [nextId]
101
+ })
102
+ )
103
+ }
104
+
105
+ function handleCarouselApi(api: CarouselApi) {
106
+ carouselApiRef.current = api
107
+ }
108
+
109
+ const hasSelectedMedia = value.length > 0
110
+
111
+ React.useEffect(() => {
112
+ if (!hasSelectedMedia) return
113
+
114
+ const carouselRoot = carouselRootRef.current
115
+ if (!carouselRoot) return
116
+
117
+ function handleWheel(event: WheelEvent) {
118
+ const api = carouselApiRef.current
119
+ if (!api) return
120
+
121
+ const isHorizontalIntent = Math.abs(event.deltaX) >= Math.abs(event.deltaY)
122
+ const delta = isHorizontalIntent ? event.deltaX : event.deltaY
123
+ if (Math.abs(delta) < 1) return
124
+
125
+ const engine = api.internalEngine()
126
+ const target = engine.limit.constrain(engine.target.get() - delta)
127
+ const distance = target - engine.target.get()
128
+
129
+ if (Math.abs(distance) < 0.5) {
130
+ if (isHorizontalIntent) {
131
+ event.preventDefault()
132
+ }
133
+ return
134
+ }
135
+
136
+ event.preventDefault()
137
+ const scrollBody = engine.scrollBody
138
+ const setScrollFriction = scrollBody.useFriction
139
+ const setScrollDuration = scrollBody.useDuration
140
+ setScrollFriction(0)
141
+ setScrollDuration(0)
142
+ engine.scrollTo.distance(distance, false)
143
+ }
144
+
145
+ carouselRoot.addEventListener('wheel', handleWheel, { passive: false })
146
+ return () => {
147
+ carouselRoot.removeEventListener('wheel', handleWheel)
148
+ }
149
+ }, [hasSelectedMedia])
150
+
65
151
  const mediaMap = React.useMemo(() => {
66
152
  const map = new Map<string, AdminMedia>()
67
153
  if (mediaItems) {
@@ -74,51 +160,144 @@ export function GalleryField({
74
160
  }, [mediaItems, value])
75
161
 
76
162
  const canAdd = !maxItems || value.length < maxItems
163
+ const activeItem = activeId ? mediaMap.get(activeId) : undefined
77
164
 
78
165
  return (
79
166
  <div className={className}>
80
- <DndContext sensors={sensors} collisionDetection={closestCenter} onDragEnd={handleDragEnd}>
81
- <SortableContext items={value} strategy={rectSortingStrategy}>
82
- <div className="grid grid-cols-3 gap-3 sm:grid-cols-4 md:grid-cols-5">
83
- {value.map((id) => {
84
- const item = mediaMap.get(id)
85
- return (
86
- <SortableGalleryItem
87
- key={id}
88
- id={id}
89
- media={item}
90
- disabled={disabled}
91
- onRemove={() => handleRemove(id)}
92
- />
93
- )
94
- })}
95
-
96
- {canAdd && !disabled && (
97
- <button
98
- type="button"
99
- onClick={() => setOpen(true)}
100
- className="flex aspect-square items-center justify-center rounded-lg border border-dashed transition-colors hover:border-primary/50 hover:bg-muted/50"
101
- >
102
- <Plus className="size-6 text-foreground" />
103
- </button>
104
- )}
167
+ {hasSelectedMedia ? (
168
+ <DndContext
169
+ sensors={sensors}
170
+ collisionDetection={closestCenter}
171
+ onDragStart={handleDragStart}
172
+ onDragEnd={handleDragEnd}
173
+ onDragCancel={handleDragCancel}
174
+ >
175
+ <div ref={carouselRootRef} className="w-full overscroll-x-contain">
176
+ <Carousel
177
+ opts={{
178
+ align: 'start',
179
+ containScroll: 'trimSnaps',
180
+ duration: 0,
181
+ dragFree: true,
182
+ watchDrag: (_, event) => {
183
+ const target = event.target
184
+ return !(
185
+ target instanceof Element &&
186
+ target.closest(
187
+ '[data-gallery-drag-handle], [data-media-field-action], button, a'
188
+ )
189
+ )
190
+ }
191
+ }}
192
+ setApi={handleCarouselApi}
193
+ className="w-full"
194
+ >
195
+ <CarouselContent className="-ml-3">
196
+ {canAdd && !disabled ? (
197
+ <CarouselItem className="basis-[168px] pl-3 sm:basis-[184px]">
198
+ <Card className="h-full">
199
+ <CardContent className="p-2 flex flex-col cursor-pointer">
200
+ <div className="flex size-full bg-background border border-border rounded-sm aspect-square overflow-hidden text-muted-foreground hover:text-foreground transition-colors items-center flex-col justify-center gap-1">
201
+ <Plus className="size-6" strokeWidth={1.5} />
202
+ <span className="text-sm">Add Images</span>
203
+ </div>
204
+ </CardContent>
205
+ <CardFooter>
206
+ <Skeleton className="h-4 w-3/4" />
207
+ </CardFooter>
208
+ </Card>
209
+ </CarouselItem>
210
+ ) : null}
211
+
212
+ <SortableContext items={value} strategy={horizontalListSortingStrategy}>
213
+ {value.map((id) => {
214
+ const item = mediaMap.get(id)
215
+ return (
216
+ <CarouselItem key={id} className="basis-[168px] pl-3 sm:basis-[184px]">
217
+ <SortableGalleryItem id={id} disabled={disabled}>
218
+ {item ? (
219
+ <MediaField
220
+ value={id}
221
+ media={item}
222
+ onChange={(nextId) => handleReplace(id, nextId)}
223
+ onRemove={() => handleRemove(id)}
224
+ disabled={disabled}
225
+ isRemovable
226
+ className="**:data-media-field-preview:aspect-square! [&_[data-media-field-preview]_img]:object-cover!"
227
+ footerMode="filename"
228
+ />
229
+ ) : (
230
+ <Card>
231
+ <CardContent className="p-2 flex flex-col">
232
+ <div className="flex size-full bg-background border border-border rounded-sm aspect-square overflow-hidden">
233
+ <Skeleton className="size-full" />
234
+ </div>
235
+ </CardContent>
236
+ <CardFooter>
237
+ <Skeleton className="h-4 w-3/4" />
238
+ </CardFooter>
239
+ </Card>
240
+ )}
241
+ </SortableGalleryItem>
242
+ </CarouselItem>
243
+ )
244
+ })}
245
+ </SortableContext>
246
+ </CarouselContent>
247
+ </Carousel>
105
248
  </div>
106
- </SortableContext>
107
- </DndContext>
249
+ <DragOverlay>
250
+ {activeId ? (
251
+ <div className="w-[168px] scale-[0.98] cursor-grabbing opacity-95 sm:w-[184px]">
252
+ {activeItem ? (
253
+ <MediaField
254
+ value={activeId}
255
+ media={activeItem}
256
+ onChange={(nextId) => handleReplace(activeId, nextId)}
257
+ onRemove={() => handleRemove(activeId)}
258
+ disabled
259
+ isRemovable
260
+ className="**:data-media-field-preview:aspect-square! [&_[data-media-field-preview]_img]:object-cover!"
261
+ footerMode="filename"
262
+ />
263
+ ) : (
264
+ <Card>
265
+ <CardContent className="p-2 flex flex-col">
266
+ <div className="flex size-full bg-background border border-border rounded-sm aspect-square overflow-hidden">
267
+ <Skeleton className="size-full" />
268
+ </div>
269
+ </CardContent>
270
+ <CardFooter>
271
+ <Skeleton className="h-4 w-3/4" />
272
+ </CardFooter>
273
+ </Card>
274
+ )}
275
+ </div>
276
+ ) : null}
277
+ </DragOverlay>
278
+ </DndContext>
279
+ ) : null}
108
280
 
109
- {value.length === 0 && (
110
- <button
111
- type="button"
112
- onClick={() => !disabled && setOpen(true)}
281
+ {!hasSelectedMedia ? (
282
+ <MediaFieldEmptyState
283
+ addButtonLabel="Add Images"
113
284
  disabled={disabled}
114
- className="flex w-full flex-col items-center justify-center gap-2 rounded-lg border border-dashed p-8 transition-colors hover:border-primary/50 hover:bg-muted/50 disabled:cursor-not-allowed disabled:opacity-50"
115
- >
116
- <ImageIcon className="size-8 text-foreground" />
117
- <span className="text-sm text-muted-foreground">Click to select media</span>
118
- </button>
119
- )}
285
+ mode="multi"
286
+ open={open}
287
+ onOpenChange={setOpen}
288
+ onSelect={handleSelect}
289
+ showAddButton
290
+ />
291
+ ) : null}
120
292
 
121
- <MediaGalleryDialog open={open} onOpenChange={setOpen} onSelect={handleSelect} mode="multi" />
293
+ {hasSelectedMedia ? (
294
+ <MediaGalleryDialog
295
+ open={open}
296
+ onOpenChange={setOpen}
297
+ onSelect={handleSelect}
298
+ mode="multi"
299
+ />
300
+ ) : null}
122
301
  </div>
123
302
  )
124
303
  }
@@ -0,0 +1,99 @@
1
+ 'use client'
2
+
3
+ import { MediaGalleryDialog } from '@admin/components/shared/media/media-gallery-dialog'
4
+ import { Button } from '@admin/components/ui/button'
5
+ import { Card, CardContent } from '@admin/components/ui/card'
6
+ import type { AdminMedia } from '@admin/types'
7
+ import { cn } from '@admin/utils/shared/cn'
8
+ import { ImageIcon } from 'lucide-react'
9
+ import type * as React from 'react'
10
+
11
+ interface MediaFieldEmptyStateProps {
12
+ accept?: string
13
+ addButtonLabel?: string
14
+ className?: string
15
+ disabled?: boolean
16
+ isRemovable?: boolean
17
+ mode?: 'single' | 'multi'
18
+ open: boolean
19
+ onOpenChange: (open: boolean) => void
20
+ onRemove?: React.MouseEventHandler<HTMLButtonElement>
21
+ onSelect: (selected: AdminMedia | AdminMedia[]) => void
22
+ showAddButton?: boolean
23
+ }
24
+
25
+ export function MediaFieldEmptyState({
26
+ accept,
27
+ addButtonLabel = 'Add Image',
28
+ className,
29
+ disabled,
30
+ isRemovable = false,
31
+ mode = 'single',
32
+ open,
33
+ onOpenChange,
34
+ onRemove,
35
+ onSelect,
36
+ showAddButton = false
37
+ }: MediaFieldEmptyStateProps) {
38
+ const shouldShowAddButton = showAddButton || isRemovable
39
+ const shouldShowActions = !disabled && (shouldShowAddButton || onRemove)
40
+
41
+ function handleAddImage(e: React.MouseEvent) {
42
+ e.preventDefault()
43
+ e.stopPropagation()
44
+ onOpenChange(true)
45
+ }
46
+
47
+ return (
48
+ <div className={className}>
49
+ <Card
50
+ className={cn('group p-0 gap-0', {
51
+ 'cursor-not-allowed opacity-50 pointer-events-none': disabled
52
+ })}
53
+ onClick={() => !disabled && onOpenChange(true)}
54
+ >
55
+ <CardContent className="relative p-0 justify-center items-center flex flex-col h-40 hover:bg-muted/50 transition-colors duration-200">
56
+ <ImageIcon
57
+ className={cn('text-muted-foreground transition-opacity', {
58
+ 'group-hover:opacity-0 group-focus-within:opacity-0': shouldShowActions
59
+ })}
60
+ strokeWidth={1}
61
+ />
62
+ {shouldShowActions && (
63
+ <div className="absolute inset-0 flex items-center justify-center gap-1 opacity-0 transition-opacity duration-200 ease-out group-hover:opacity-100 group-focus-within:opacity-100">
64
+ {shouldShowAddButton ? (
65
+ <Button
66
+ type="button"
67
+ data-media-field-action="true"
68
+ variant="outline"
69
+ onClick={handleAddImage}
70
+ className="border-primary/10"
71
+ >
72
+ {addButtonLabel}
73
+ </Button>
74
+ ) : null}
75
+ {onRemove ? (
76
+ <Button
77
+ type="button"
78
+ data-media-field-action="true"
79
+ variant="default"
80
+ onClick={onRemove}
81
+ >
82
+ Remove
83
+ </Button>
84
+ ) : null}
85
+ </div>
86
+ )}
87
+ </CardContent>
88
+ </Card>
89
+
90
+ <MediaGalleryDialog
91
+ open={open}
92
+ onOpenChange={onOpenChange}
93
+ onSelect={onSelect}
94
+ mode={mode}
95
+ accept={accept}
96
+ />
97
+ </div>
98
+ )
99
+ }
@@ -0,0 +1,181 @@
1
+ 'use client'
2
+
3
+ import { MediaGalleryDialog } from '@admin/components/shared/media/media-gallery-dialog'
4
+ import { MediaPreview } from '@admin/components/shared/media/media-preview'
5
+ import { Button } from '@admin/components/ui/button'
6
+ import { Card, CardContent, CardFooter } from '@admin/components/ui/card'
7
+ import { useCopyToClipboard } from '@admin/hooks/use-copy-to-clipboard'
8
+ import { useMediaById } from '@admin/hooks/use-media'
9
+ import type { AdminMedia } from '@admin/types'
10
+ import { createAdminMediaFromUrl } from '@admin/utils/media/fallback'
11
+ import { cn } from '@admin/utils/shared/cn'
12
+ import { Check, Copy, Link2 } from 'lucide-react'
13
+ import Link from 'next/link'
14
+ import * as React from 'react'
15
+ import { MediaFieldEmptyState } from './media-field-empty-state'
16
+
17
+ type MediaFieldValueMode = 'id' | 'url'
18
+ type MediaFieldFooterMode = 'url' | 'filename' | 'none'
19
+
20
+ interface MediaFieldProps {
21
+ value?: string
22
+ onChange: (value: string) => void
23
+ onBlur?: () => void
24
+ onRemove?: () => void
25
+ media?: AdminMedia | null
26
+ accept?: string
27
+ disabled?: boolean
28
+ className?: string
29
+ isRemovable?: boolean
30
+ valueMode?: MediaFieldValueMode
31
+ footerMode?: MediaFieldFooterMode
32
+ }
33
+
34
+ export function MediaField({
35
+ value,
36
+ onChange,
37
+ onBlur,
38
+ onRemove,
39
+ media: mediaOverride,
40
+ accept,
41
+ disabled,
42
+ className,
43
+ isRemovable = false,
44
+ valueMode = 'id',
45
+ footerMode = 'url'
46
+ }: MediaFieldProps) {
47
+ const [open, setOpen] = React.useState(false)
48
+ const { data: media } = useMediaById(
49
+ valueMode === 'id' && mediaOverride === undefined ? value : undefined
50
+ )
51
+ const [copyMediaUrl, hasCopiedMediaUrl] = useCopyToClipboard()
52
+ const selectedMedia =
53
+ mediaOverride ??
54
+ media ??
55
+ (valueMode === 'url' && value ? createAdminMediaFromUrl(value, accept) : null)
56
+
57
+ function handleSelect(selected: AdminMedia | AdminMedia[]) {
58
+ const item = Array.isArray(selected) ? selected[0] : selected
59
+ if (item) {
60
+ onChange(valueMode === 'url' ? item.url : item.id)
61
+ }
62
+ }
63
+
64
+ function handleRemove(e: React.MouseEvent) {
65
+ e.preventDefault()
66
+ e.stopPropagation()
67
+ if (isRemovable && onRemove) {
68
+ onRemove()
69
+ return
70
+ }
71
+ onChange('')
72
+ onBlur?.()
73
+ }
74
+
75
+ function handleChangeImage(e: React.MouseEvent) {
76
+ e.preventDefault()
77
+ e.stopPropagation()
78
+ setOpen(true)
79
+ }
80
+
81
+ if (selectedMedia?.url) {
82
+ return (
83
+ <div className={cn('group relative', className)}>
84
+ <Card className="p-0">
85
+ <CardContent className="p-0! relative">
86
+ <div
87
+ data-media-field-preview
88
+ className="relative aspect-video rounded-lg bg-background overflow-hidden"
89
+ >
90
+ <MediaPreview media={selectedMedia} showMetadata={Boolean(media)} />
91
+ </div>
92
+
93
+ {!disabled && (
94
+ <>
95
+ <div
96
+ aria-hidden="true"
97
+ className="pointer-events-none absolute inset-0 bg-black/10 opacity-0 backdrop-blur-xs transition-opacity duration-300 ease-out group-hover:opacity-100 rounded-lg"
98
+ />
99
+ <div className="absolute inset-0 flex flex-col items-center justify-center gap-1 opacity-0 transition-opacity duration-200 ease-out group-hover:opacity-100">
100
+ <Button
101
+ type="button"
102
+ data-media-field-action="true"
103
+ variant="outline"
104
+ onClick={handleChangeImage}
105
+ className="border-primary/10 w-[100px]"
106
+ >
107
+ Change
108
+ </Button>
109
+ <Button
110
+ type="button"
111
+ data-media-field-action="true"
112
+ variant="default"
113
+ onClick={handleRemove}
114
+ className="w-[100px]"
115
+ >
116
+ Remove
117
+ </Button>
118
+ </div>
119
+ </>
120
+ )}
121
+ </CardContent>
122
+ {footerMode !== 'none' && (
123
+ <CardFooter className="grid w-full gap-0.5 px-4">
124
+ {footerMode === 'url' ? (
125
+ <div className="flex w-full min-w-0 items-center gap-x-2">
126
+ <div className="flex min-w-0 flex-1 items-center gap-x-2 text-muted-foreground">
127
+ <Link2 className="size-4 shrink-0" />
128
+ <Link
129
+ href={selectedMedia.url}
130
+ target="_blank"
131
+ rel="noopener noreferrer"
132
+ className="min-w-0 truncate text-foreground hover:underline underline-offset-4"
133
+ >
134
+ {selectedMedia.url}
135
+ </Link>
136
+ </div>
137
+ <Button
138
+ type="button"
139
+ data-media-field-action="true"
140
+ aria-label={hasCopiedMediaUrl ? 'Media URL copied' : 'Copy media URL'}
141
+ variant="ghost"
142
+ title={hasCopiedMediaUrl ? 'Copied' : 'Copy media URL'}
143
+ onClick={() => copyMediaUrl(selectedMedia.url)}
144
+ className="shrink-0 size-7"
145
+ >
146
+ {hasCopiedMediaUrl ? <Check className="text-foreground" /> : <Copy />}
147
+ </Button>
148
+ </div>
149
+ ) : (
150
+ <span className="w-full truncate text-sm text-muted-foreground">
151
+ {selectedMedia.filename}
152
+ </span>
153
+ )}
154
+ </CardFooter>
155
+ )}
156
+ </Card>
157
+
158
+ <MediaGalleryDialog
159
+ open={open}
160
+ onOpenChange={setOpen}
161
+ onSelect={handleSelect}
162
+ mode="single"
163
+ accept={accept}
164
+ />
165
+ </div>
166
+ )
167
+ }
168
+
169
+ return (
170
+ <MediaFieldEmptyState
171
+ accept={accept}
172
+ className={className}
173
+ disabled={disabled}
174
+ isRemovable={isRemovable}
175
+ open={open}
176
+ onOpenChange={setOpen}
177
+ onRemove={handleRemove}
178
+ onSelect={handleSelect}
179
+ />
180
+ )
181
+ }