betterstart-cli 0.0.17 → 0.0.19
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/assets/adapters/next/plugins/blog/schemas/menus.json +1 -0
- package/dist/assets/adapters/next/plugins/blog/schemas/posts.json +1 -0
- package/dist/assets/adapters/next/plugins/portfolio/plugin.ts +17 -0
- package/dist/assets/adapters/next/plugins/portfolio/schemas/portfolio.json +128 -0
- package/dist/assets/adapters/next/templates/init/components/shared/dev-mode/dev-mode-types.ts +2 -2
- package/dist/assets/adapters/next/templates/init/components/shared/dev-mode/snippets-tab.tsx +1 -0
- package/dist/assets/adapters/next/templates/init/components/shared/dev-mode-integrate.tsx +1 -1
- package/dist/assets/adapters/next/templates/init/components/shared/media/media-delete-dialog.tsx +1 -1
- package/dist/assets/adapters/next/templates/init/components/shared/media/media-delete-drawer.tsx +1 -1
- package/dist/assets/adapters/next/templates/init/components/shared/media/media-grid-item.tsx +17 -7
- package/dist/assets/adapters/next/templates/init/components/shared/media/media-preview.tsx +1 -0
- package/dist/assets/adapters/next/templates/init/data/navigation.ts +2 -1
- package/dist/assets/adapters/next/templates/init/hooks/content-editor/use-content-editor-source-mode.tsx +3 -3
- package/dist/assets/adapters/next/templates/init/hooks/use-media.ts +2 -1
- package/dist/assets/adapters/next/templates/init/pages/profile/profile-form.tsx +3 -3
- package/dist/assets/shared-assets/react-admin/custom/content-editor/media-gallery-block.tsx +2 -2
- package/dist/assets/shared-assets/react-admin/custom/content-editor/table-add-controls.tsx +2 -2
- package/dist/assets/shared-assets/react-admin/custom/gallery-field.tsx +219 -40
- package/dist/assets/shared-assets/react-admin/custom/media-field-empty-state.tsx +99 -0
- package/dist/assets/shared-assets/react-admin/custom/media-field.tsx +181 -0
- package/dist/assets/shared-assets/react-admin/custom/sortable-gallery-item.tsx +38 -38
- package/dist/assets/shared-assets/react-admin/schema.json +64 -2
- package/dist/assets/shared-assets/react-admin/ui/pagination.tsx +1 -1
- package/dist/cli.js +474 -143
- package/dist/cli.js.map +1 -1
- package/package.json +1 -1
- package/dist/assets/shared-assets/react-admin/custom/gallery-thumbnail.tsx +0 -39
- 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,
|
|
16
|
-
import {
|
|
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
|
-
|
|
81
|
-
<
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
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
|
-
|
|
107
|
-
|
|
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
|
-
{
|
|
110
|
-
<
|
|
111
|
-
|
|
112
|
-
onClick={() => !disabled && setOpen(true)}
|
|
281
|
+
{!hasSelectedMedia ? (
|
|
282
|
+
<MediaFieldEmptyState
|
|
283
|
+
addButtonLabel="Add Images"
|
|
113
284
|
disabled={disabled}
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
285
|
+
mode="multi"
|
|
286
|
+
open={open}
|
|
287
|
+
onOpenChange={setOpen}
|
|
288
|
+
onSelect={handleSelect}
|
|
289
|
+
showAddButton
|
|
290
|
+
/>
|
|
291
|
+
) : null}
|
|
120
292
|
|
|
121
|
-
|
|
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
|
+
}
|