@spree/dashboard-core 6.0.3 → 6.0.4
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/vite/index.js +81 -5
- package/package.json +19 -19
- package/src/api-client.ts +7 -1
- package/src/client.ts +2 -0
- package/src/components/address-form-dialog.tsx +75 -28
- package/src/components/app-shell.tsx +5 -4
- package/src/components/app-sidebar.tsx +2 -1
- package/src/components/bulk-action-bar.tsx +6 -5
- package/src/components/country-state-fields.tsx +3 -0
- package/src/components/currency-select.tsx +6 -4
- package/src/components/developer-inspector-drawer.tsx +322 -0
- package/src/components/file-upload-field.tsx +64 -37
- package/src/components/image-upload-field.tsx +4 -0
- package/src/components/import-wizard-dialog.tsx +14 -12
- package/src/components/invitation-acceptance.tsx +15 -11
- package/src/components/media-editor-dialog.tsx +131 -0
- package/src/components/media-picker-dialog.tsx +866 -0
- package/src/components/media-picker-sheet.tsx +13 -300
- package/src/components/mobile-top-bar.tsx +1 -1
- package/src/components/nav-main.tsx +13 -2
- package/src/components/page-header.tsx +57 -13
- package/src/components/page-tabs.tsx +1 -1
- package/src/components/product-stock-summary.tsx +1 -1
- package/src/components/read-row.tsx +38 -0
- package/src/components/resource-combobox.tsx +1 -1
- package/src/components/resource-image-field.tsx +150 -0
- package/src/components/resource-picker-sheet.tsx +2 -2
- package/src/components/resource-table.tsx +161 -40
- package/src/components/section-heading.tsx +2 -2
- package/src/components/settings-container.tsx +19 -0
- package/src/components/settings-fields.tsx +165 -0
- package/src/components/settings-section.tsx +46 -0
- package/src/components/settings-sidebar.tsx +35 -18
- package/src/components/sheet-top-bar.tsx +7 -8
- package/src/components/sidebar-search.tsx +11 -12
- package/src/components/sidebar-user.tsx +39 -44
- package/src/components/skip-link.tsx +1 -1
- package/src/components/store-switcher.tsx +1 -1
- package/src/components/table-toolbar.tsx +189 -323
- package/src/components/timezone-combobox.tsx +119 -0
- package/src/components/top-bar-breadcrumbs.tsx +1 -1
- package/src/hooks/use-stock-locations.ts +6 -2
- package/src/hooks/use-table-density.ts +50 -0
- package/src/index.ts +19 -2
- package/src/lib/curl.test.ts +18 -0
- package/src/lib/curl.ts +28 -0
- package/src/lib/developer-inspector.test.ts +169 -0
- package/src/lib/developer-inspector.ts +134 -0
- package/src/lib/docs.ts +3 -2
- package/src/lib/fulfillment-items.test.ts +51 -0
- package/src/lib/fulfillment-items.ts +53 -0
- package/src/lib/request-snippets.ts +41 -0
- package/src/lib/table-registry.ts +13 -6
- package/src/lib/timestamp-columns.tsx +1 -1
- package/src/locales/ar.json +120 -14
- package/src/locales/de.json +120 -14
- package/src/locales/en.json +120 -14
- package/src/locales/es.json +120 -14
- package/src/locales/fr.json +120 -14
- package/src/locales/ja.json +120 -14
- package/src/locales/pl.json +120 -14
- package/src/locales/vi.json +120 -14
- package/src/locales/zh-CN.json +120 -14
- package/src/pages/settings-index.tsx +79 -48
- package/src/pages/stock-locations.tsx +14 -6
- package/src/products/digital-asset-edit-sheet.tsx +1 -1
- package/src/products/digital-asset-provider-sheet.tsx +1 -1
- package/src/products/digital-assets-card.tsx +7 -7
- package/src/products/inventory-section.tsx +2 -2
- package/src/products/media-edit-sheet.tsx +136 -133
- package/src/products/product-form-cards.tsx +201 -70
- package/src/products/product-form-mapping.ts +1 -0
- package/src/products/product-schema.ts +8 -0
- package/src/products/product-types.ts +2 -0
- package/src/products/use-product-form-data.ts +30 -3
- package/src/products/variant-edit-sheet.tsx +3 -3
- package/src/products/variants-options-builder.tsx +13 -8
- package/src/products/variants-section.tsx +196 -4
- package/src/providers/auth-provider.tsx +2 -0
- package/src/providers/developer-inspector-provider.tsx +145 -0
- package/src/providers/json-link-resolver-provider.tsx +27 -0
- package/src/sellers/seller-schema.ts +107 -0
- package/src/tables/package-types.tsx +3 -3
- package/src/tables/stock-locations.tsx +15 -14
- package/src/virtual.d.ts +4 -0
- package/src/vite/index.ts +103 -5
|
@@ -0,0 +1,866 @@
|
|
|
1
|
+
import {
|
|
2
|
+
Button,
|
|
3
|
+
Checkbox,
|
|
4
|
+
cn,
|
|
5
|
+
Dialog,
|
|
6
|
+
DialogContent,
|
|
7
|
+
DialogDescription,
|
|
8
|
+
DialogFooter,
|
|
9
|
+
DialogHeader,
|
|
10
|
+
DialogTitle,
|
|
11
|
+
DropdownMenu,
|
|
12
|
+
DropdownMenuContent,
|
|
13
|
+
DropdownMenuRadioGroup,
|
|
14
|
+
DropdownMenuRadioItem,
|
|
15
|
+
DropdownMenuTrigger,
|
|
16
|
+
SearchInput,
|
|
17
|
+
} from '@spree/dashboard-ui'
|
|
18
|
+
import {
|
|
19
|
+
ChevronsUpDownIcon,
|
|
20
|
+
ImageIcon,
|
|
21
|
+
LayoutGridIcon,
|
|
22
|
+
ListIcon,
|
|
23
|
+
Loader2Icon,
|
|
24
|
+
PlayIcon,
|
|
25
|
+
UploadIcon,
|
|
26
|
+
} from '@spree/dashboard-ui/icons'
|
|
27
|
+
import { useQuery } from '@tanstack/react-query'
|
|
28
|
+
import {
|
|
29
|
+
type DragEvent,
|
|
30
|
+
type ReactNode,
|
|
31
|
+
useCallback,
|
|
32
|
+
useDeferredValue,
|
|
33
|
+
useRef,
|
|
34
|
+
useState,
|
|
35
|
+
} from 'react'
|
|
36
|
+
import { useTranslation } from 'react-i18next'
|
|
37
|
+
import { useTenantId } from '../providers/tenant-provider'
|
|
38
|
+
import { SortMenu } from './table-toolbar'
|
|
39
|
+
|
|
40
|
+
/** The shape the picker needs; a full Media record satisfies it. */
|
|
41
|
+
export interface MediaPickerOption {
|
|
42
|
+
id: string
|
|
43
|
+
alt?: string | null
|
|
44
|
+
filename?: string | null
|
|
45
|
+
media_type?: string
|
|
46
|
+
small_url?: string | null
|
|
47
|
+
poster_url?: string | null
|
|
48
|
+
original_url?: string | null
|
|
49
|
+
/** Blob signed id, for handing a picked file to a plain attachment field. */
|
|
50
|
+
signed_id?: string | null
|
|
51
|
+
created_at?: string | null
|
|
52
|
+
byte_size?: number | null
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** The picker's own controls, handed to `search` alongside the query. */
|
|
56
|
+
export interface MediaPickerSearchOptions {
|
|
57
|
+
sort: { field: 'created_at' | 'filename' | 'byte_size'; direction: 'asc' | 'desc' }
|
|
58
|
+
mediaType: 'all' | 'image' | 'video'
|
|
59
|
+
usage: 'all' | 'used' | 'unused'
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Turns the picker's query and controls into media list params, for callers
|
|
64
|
+
* that search the Admin API's media endpoint:
|
|
65
|
+
* `search={(query, options) => adminClient.media.list({ limit: 48, ...mediaPickerListParams(query, options) })}`.
|
|
66
|
+
*/
|
|
67
|
+
export function mediaPickerListParams(query: string, options?: MediaPickerSearchOptions) {
|
|
68
|
+
return {
|
|
69
|
+
...(query ? { filename_cont: query } : {}),
|
|
70
|
+
sort: options
|
|
71
|
+
? (`${options.sort.direction === 'desc' ? '-' : ''}${options.sort.field}` as const)
|
|
72
|
+
: ('-created_at' as const),
|
|
73
|
+
...(options?.mediaType === 'image' ? { media_type_eq: 'image' as const } : {}),
|
|
74
|
+
...(options?.mediaType === 'video'
|
|
75
|
+
? { media_type_in: ['video' as const, 'external_video' as const] }
|
|
76
|
+
: {}),
|
|
77
|
+
...(options?.usage === 'used' ? { attached: true } : {}),
|
|
78
|
+
...(options?.usage === 'unused' ? { unattached: true } : {}),
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export interface MediaPickerDialogProps<T extends MediaPickerOption> {
|
|
83
|
+
open: boolean
|
|
84
|
+
onOpenChange: (open: boolean) => void
|
|
85
|
+
/**
|
|
86
|
+
* Paginated search over the library, called with the trimmed query and the
|
|
87
|
+
* picker's sort and filters. `mediaPickerListParams` maps both to the media
|
|
88
|
+
* endpoint's params.
|
|
89
|
+
*/
|
|
90
|
+
search: (query: string, options: MediaPickerSearchOptions) => Promise<{ data: T[] }>
|
|
91
|
+
/** Called with the picked files when the user confirms. */
|
|
92
|
+
onConfirm: (media: T[]) => void | Promise<void>
|
|
93
|
+
/** Uploads a file and returns the created library row. */
|
|
94
|
+
onUpload?: (file: File) => Promise<T>
|
|
95
|
+
/** One file only — the picker confirms as soon as a file is clicked. */
|
|
96
|
+
multiple?: boolean
|
|
97
|
+
/** Cache-isolation key, one per picker instance. */
|
|
98
|
+
queryKey: string
|
|
99
|
+
/** File types the upload accepts. A list naming video also offers the Type filter. */
|
|
100
|
+
accept?: string
|
|
101
|
+
title?: string
|
|
102
|
+
description?: string
|
|
103
|
+
confirmLabel?: string
|
|
104
|
+
onConfirmError?: (error: unknown) => void
|
|
105
|
+
/**
|
|
106
|
+
* Files ticked when the picker opens — what the host already has. Records,
|
|
107
|
+
* not ids, so one outside the loaded page or the current filter still
|
|
108
|
+
* counts and survives Done.
|
|
109
|
+
*/
|
|
110
|
+
initialSelection?: T[]
|
|
111
|
+
/**
|
|
112
|
+
* Lets Done confirm with nothing ticked, for a host where the picked set is
|
|
113
|
+
* the whole answer (un-ticking every file clears it).
|
|
114
|
+
*/
|
|
115
|
+
allowEmptyConfirm?: boolean
|
|
116
|
+
/**
|
|
117
|
+
* Sort and the library filters. Off for a short, local list — a product's
|
|
118
|
+
* own images — where they would narrow nothing; search and view stay.
|
|
119
|
+
*/
|
|
120
|
+
browseControls?: boolean
|
|
121
|
+
/**
|
|
122
|
+
* Labels the first ticked file in list order — what a host derives from
|
|
123
|
+
* that order, such as a variant's thumbnail — and moves as files are ticked.
|
|
124
|
+
*/
|
|
125
|
+
firstSelectedBadge?: string
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
export type MediaView = 'grid' | 'list'
|
|
129
|
+
const VIEW_STORAGE_KEY = 'spree-media-view'
|
|
130
|
+
|
|
131
|
+
function readView(): MediaView {
|
|
132
|
+
try {
|
|
133
|
+
return localStorage.getItem(VIEW_STORAGE_KEY) === 'list' ? 'list' : 'grid'
|
|
134
|
+
} catch {
|
|
135
|
+
return 'grid'
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* Grid or list for browsing media, remembered per browser and shared by the
|
|
141
|
+
* media library page and the picker, so one choice holds in both.
|
|
142
|
+
*/
|
|
143
|
+
export function useMediaView(): [MediaView, (view: MediaView) => void] {
|
|
144
|
+
const [view, setView] = useState<MediaView>(readView)
|
|
145
|
+
const change = useCallback((next: MediaView) => {
|
|
146
|
+
setView(next)
|
|
147
|
+
try {
|
|
148
|
+
localStorage.setItem(VIEW_STORAGE_KEY, next)
|
|
149
|
+
} catch {
|
|
150
|
+
// A blocked store only means the choice is not remembered.
|
|
151
|
+
}
|
|
152
|
+
}, [])
|
|
153
|
+
return [view, change]
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* The grid/list switch, sized and styled as the sort button beside it — the
|
|
158
|
+
* resource table toolbar's controls — rather than a segmented control.
|
|
159
|
+
*/
|
|
160
|
+
export function MediaViewMenu({
|
|
161
|
+
view,
|
|
162
|
+
onViewChange,
|
|
163
|
+
}: {
|
|
164
|
+
view: MediaView
|
|
165
|
+
onViewChange: (view: MediaView) => void
|
|
166
|
+
}) {
|
|
167
|
+
const { t } = useTranslation()
|
|
168
|
+
return (
|
|
169
|
+
<DropdownMenu>
|
|
170
|
+
<DropdownMenuTrigger asChild>
|
|
171
|
+
<Button
|
|
172
|
+
type="button"
|
|
173
|
+
variant="outline"
|
|
174
|
+
size="sm"
|
|
175
|
+
className="h-11 lg:h-[2.125rem]"
|
|
176
|
+
aria-label={t('admin.media_picker.view')}
|
|
177
|
+
>
|
|
178
|
+
{view === 'grid' ? (
|
|
179
|
+
<LayoutGridIcon className="size-4" />
|
|
180
|
+
) : (
|
|
181
|
+
<ListIcon className="size-4" />
|
|
182
|
+
)}
|
|
183
|
+
<ChevronsUpDownIcon className="size-3.5 text-muted-foreground" />
|
|
184
|
+
</Button>
|
|
185
|
+
</DropdownMenuTrigger>
|
|
186
|
+
<DropdownMenuContent align="end" className="min-w-40">
|
|
187
|
+
{/* A two-way switch, so a pick closes the menu — unlike the sort
|
|
188
|
+
menu, where a field and a direction are often picked together. */}
|
|
189
|
+
<DropdownMenuRadioGroup
|
|
190
|
+
value={view}
|
|
191
|
+
onValueChange={(next) => onViewChange(next as MediaView)}
|
|
192
|
+
>
|
|
193
|
+
<DropdownMenuRadioItem value="grid" closeOnClick>
|
|
194
|
+
<LayoutGridIcon className="size-4" />
|
|
195
|
+
{t('admin.media_picker.view_grid')}
|
|
196
|
+
</DropdownMenuRadioItem>
|
|
197
|
+
<DropdownMenuRadioItem value="list" closeOnClick>
|
|
198
|
+
<ListIcon className="size-4" />
|
|
199
|
+
{t('admin.media_picker.view_list')}
|
|
200
|
+
</DropdownMenuRadioItem>
|
|
201
|
+
</DropdownMenuRadioGroup>
|
|
202
|
+
</DropdownMenuContent>
|
|
203
|
+
</DropdownMenu>
|
|
204
|
+
)
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/** "18.1 kB", "2.4 MB" — in the admin's own number format. */
|
|
208
|
+
export function formatMediaBytes(bytes: number | null | undefined, language: string): string {
|
|
209
|
+
if (bytes == null) return ''
|
|
210
|
+
const [value, unit] =
|
|
211
|
+
bytes >= 1_000_000 ? [bytes / 1_000_000, 'megabyte'] : [bytes / 1_000, 'kilobyte']
|
|
212
|
+
return new Intl.NumberFormat(language, {
|
|
213
|
+
style: 'unit',
|
|
214
|
+
unit,
|
|
215
|
+
unitDisplay: 'short',
|
|
216
|
+
maximumFractionDigits: 1,
|
|
217
|
+
}).format(value)
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/** "JPG", "MP4" — from the file's extension, falling back to its media type. */
|
|
221
|
+
export function mediaFileTypeLabel(media: MediaPickerOption): string {
|
|
222
|
+
const extension = media.filename?.split('.').pop()
|
|
223
|
+
if (extension && extension !== media.filename) return extension.toUpperCase()
|
|
224
|
+
if (media.media_type === 'external_video') return 'URL'
|
|
225
|
+
return media.media_type?.toUpperCase() ?? ''
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
function acceptsFile(accept: string, file: File): boolean {
|
|
229
|
+
return accept.split(',').some((rule) => {
|
|
230
|
+
const type = rule.trim()
|
|
231
|
+
return type.endsWith('/*') ? file.type.startsWith(type.slice(0, -1)) : file.type === type
|
|
232
|
+
})
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
/**
|
|
236
|
+
* Browse the store's media library and pick files from it.
|
|
237
|
+
*
|
|
238
|
+
* A dialog rather than a side sheet: choosing a picture means looking at it,
|
|
239
|
+
* so the library gets the width for a real grid, with search, sort, filters, a
|
|
240
|
+
* list view for finding a file by name, and a drop zone for adding new ones.
|
|
241
|
+
*
|
|
242
|
+
* Headless — the caller supplies `search` and `onUpload`, so the same dialog
|
|
243
|
+
* serves the product media card (which places files on a product) and plain
|
|
244
|
+
* image fields (which take the picked file's blob).
|
|
245
|
+
*/
|
|
246
|
+
export function MediaPickerDialog<T extends MediaPickerOption>({
|
|
247
|
+
open,
|
|
248
|
+
onOpenChange,
|
|
249
|
+
search,
|
|
250
|
+
onConfirm,
|
|
251
|
+
onUpload,
|
|
252
|
+
multiple = true,
|
|
253
|
+
queryKey,
|
|
254
|
+
accept = 'image/png,image/jpeg,image/webp,image/gif',
|
|
255
|
+
title,
|
|
256
|
+
description,
|
|
257
|
+
confirmLabel,
|
|
258
|
+
onConfirmError,
|
|
259
|
+
initialSelection,
|
|
260
|
+
allowEmptyConfirm = false,
|
|
261
|
+
browseControls = true,
|
|
262
|
+
firstSelectedBadge,
|
|
263
|
+
}: MediaPickerDialogProps<T>) {
|
|
264
|
+
const { t } = useTranslation()
|
|
265
|
+
const tenantId = useTenantId()
|
|
266
|
+
|
|
267
|
+
const [input, setInput] = useState('')
|
|
268
|
+
const deferredInput = useDeferredValue(input)
|
|
269
|
+
const trimmedQuery = deferredInput.trim()
|
|
270
|
+
const [sort, setSort] = useState<MediaPickerSearchOptions['sort']>({
|
|
271
|
+
field: 'created_at',
|
|
272
|
+
direction: 'desc',
|
|
273
|
+
})
|
|
274
|
+
const [mediaType, setMediaType] = useState<MediaPickerSearchOptions['mediaType']>('all')
|
|
275
|
+
const [usage, setUsage] = useState<MediaPickerSearchOptions['usage']>('all')
|
|
276
|
+
const [view, changeView] = useMediaView()
|
|
277
|
+
const [staged, setStaged] = useState<Map<string, T>>(new Map())
|
|
278
|
+
const [submitting, setSubmitting] = useState(false)
|
|
279
|
+
const [uploading, setUploading] = useState(false)
|
|
280
|
+
const [dragging, setDragging] = useState(false)
|
|
281
|
+
const fileInputRef = useRef<HTMLInputElement>(null)
|
|
282
|
+
// Bumped whenever the dialog closes. An upload started in an earlier session
|
|
283
|
+
// must not confirm into a host that has already moved on — closing mid-upload
|
|
284
|
+
// would otherwise still set the field the merchant cancelled.
|
|
285
|
+
const sessionRef = useRef(0)
|
|
286
|
+
|
|
287
|
+
const offersVideo = accept.includes('video')
|
|
288
|
+
const options: MediaPickerSearchOptions = { sort, mediaType, usage }
|
|
289
|
+
|
|
290
|
+
// The tenant id is part of the key: a library is one tenant's, and without
|
|
291
|
+
// it reopening the picker after a store or seller switch serves the previous
|
|
292
|
+
// tenant's files from cache — whose ids the new tenant's API would reject.
|
|
293
|
+
const { data, isFetching, refetch } = useQuery({
|
|
294
|
+
queryKey: [
|
|
295
|
+
queryKey,
|
|
296
|
+
'media-picker',
|
|
297
|
+
tenantId,
|
|
298
|
+
trimmedQuery,
|
|
299
|
+
sort.field,
|
|
300
|
+
sort.direction,
|
|
301
|
+
mediaType,
|
|
302
|
+
usage,
|
|
303
|
+
],
|
|
304
|
+
queryFn: () => search(trimmedQuery, options),
|
|
305
|
+
enabled: open,
|
|
306
|
+
staleTime: 30_000,
|
|
307
|
+
})
|
|
308
|
+
|
|
309
|
+
const results = data?.data ?? []
|
|
310
|
+
// Tick what the host already has, once per opening — a later render must
|
|
311
|
+
// not undo what the merchant changed since.
|
|
312
|
+
const [seeded, setSeeded] = useState(false)
|
|
313
|
+
if (!open && seeded) setSeeded(false)
|
|
314
|
+
if (open && !seeded && initialSelection?.length) {
|
|
315
|
+
setSeeded(true)
|
|
316
|
+
setStaged(new Map(initialSelection.map((media) => [media.id, media])))
|
|
317
|
+
}
|
|
318
|
+
const filtered = !!trimmedQuery || mediaType !== 'all' || usage !== 'all'
|
|
319
|
+
// Only over the whole list: a search or filter hides files, and the first
|
|
320
|
+
// ticked one left showing need not be the first in the host's order.
|
|
321
|
+
const firstSelectedId =
|
|
322
|
+
firstSelectedBadge && !filtered ? results.find((media) => staged.has(media.id))?.id : undefined
|
|
323
|
+
|
|
324
|
+
async function pick(option: T) {
|
|
325
|
+
if (!multiple) {
|
|
326
|
+
await submit([option])
|
|
327
|
+
return
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
setStaged((prev) => {
|
|
331
|
+
const next = new Map(prev)
|
|
332
|
+
if (next.has(option.id)) next.delete(option.id)
|
|
333
|
+
else next.set(option.id, option)
|
|
334
|
+
return next
|
|
335
|
+
})
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
async function submit(media: T[]) {
|
|
339
|
+
if (media.length === 0 && !allowEmptyConfirm) return
|
|
340
|
+
setSubmitting(true)
|
|
341
|
+
try {
|
|
342
|
+
await onConfirm(media)
|
|
343
|
+
reset()
|
|
344
|
+
onOpenChange(false)
|
|
345
|
+
} catch (error) {
|
|
346
|
+
// The mutation shows its own error; keep the dialog open with the
|
|
347
|
+
// selection intact so the merchant can retry.
|
|
348
|
+
onConfirmError?.(error)
|
|
349
|
+
} finally {
|
|
350
|
+
setSubmitting(false)
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
async function handleUpload(files: File[]) {
|
|
355
|
+
if (!files.length || !onUpload) return
|
|
356
|
+
|
|
357
|
+
const session = sessionRef.current
|
|
358
|
+
setUploading(true)
|
|
359
|
+
try {
|
|
360
|
+
const uploaded: T[] = []
|
|
361
|
+
for (const file of files) {
|
|
362
|
+
uploaded.push(await onUpload(file))
|
|
363
|
+
}
|
|
364
|
+
// The file is in the library either way — only the confirmation is
|
|
365
|
+
// abandoned, so nothing the merchant uploaded is lost.
|
|
366
|
+
if (session !== sessionRef.current) return
|
|
367
|
+
|
|
368
|
+
await refetch()
|
|
369
|
+
|
|
370
|
+
if (multiple) {
|
|
371
|
+
setStaged((prev) => {
|
|
372
|
+
const next = new Map(prev)
|
|
373
|
+
for (const media of uploaded) next.set(media.id, media)
|
|
374
|
+
return next
|
|
375
|
+
})
|
|
376
|
+
} else {
|
|
377
|
+
await submit(uploaded.slice(0, 1))
|
|
378
|
+
}
|
|
379
|
+
} catch (error) {
|
|
380
|
+
onConfirmError?.(error)
|
|
381
|
+
} finally {
|
|
382
|
+
setUploading(false)
|
|
383
|
+
if (fileInputRef.current) fileInputRef.current.value = ''
|
|
384
|
+
}
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
function handleDrop(event: DragEvent) {
|
|
388
|
+
event.preventDefault()
|
|
389
|
+
setDragging(false)
|
|
390
|
+
const files = Array.from(event.dataTransfer.files).filter((file) => acceptsFile(accept, file))
|
|
391
|
+
void handleUpload(multiple ? files : files.slice(0, 1))
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
function reset() {
|
|
395
|
+
setStaged(new Map())
|
|
396
|
+
setInput('')
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
function handleOpenChange(next: boolean) {
|
|
400
|
+
if (!next) {
|
|
401
|
+
sessionRef.current += 1
|
|
402
|
+
reset()
|
|
403
|
+
}
|
|
404
|
+
onOpenChange(next)
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
return (
|
|
408
|
+
<Dialog open={open} onOpenChange={handleOpenChange}>
|
|
409
|
+
<DialogContent className="h-[min(860px,calc(100dvh-2rem))] gap-0 p-0 sm:max-w-5xl">
|
|
410
|
+
<DialogHeader>
|
|
411
|
+
<DialogTitle>{title ?? t('admin.media_picker.title')}</DialogTitle>
|
|
412
|
+
<DialogDescription>
|
|
413
|
+
{description ?? t('admin.media_picker.description')}
|
|
414
|
+
</DialogDescription>
|
|
415
|
+
</DialogHeader>
|
|
416
|
+
|
|
417
|
+
<div className="flex min-h-0 flex-1 flex-col gap-4 overflow-y-auto p-4">
|
|
418
|
+
<div className="flex flex-col gap-3">
|
|
419
|
+
<div className="flex items-center gap-2">
|
|
420
|
+
<SearchInput
|
|
421
|
+
value={input}
|
|
422
|
+
onValueChange={setInput}
|
|
423
|
+
// Enter has no action here; swallow it so it can't submit a host form.
|
|
424
|
+
onKeyDown={(e) => e.key === 'Enter' && e.preventDefault()}
|
|
425
|
+
placeholder={t('admin.media_picker.search_placeholder')}
|
|
426
|
+
aria-label={t('admin.media_picker.search_placeholder')}
|
|
427
|
+
clearLabel={t('admin.common.clear')}
|
|
428
|
+
className="h-11 max-w-xl flex-1 lg:h-[2.125rem]"
|
|
429
|
+
/>
|
|
430
|
+
<div className="ms-auto flex items-center gap-2">
|
|
431
|
+
{browseControls && (
|
|
432
|
+
<SortMenu
|
|
433
|
+
columns={[
|
|
434
|
+
{ key: 'created_at', label: t('admin.media_picker.sort_date_added') },
|
|
435
|
+
{ key: 'filename', label: t('admin.media_picker.sort_file_name') },
|
|
436
|
+
{ key: 'byte_size', label: t('admin.media_picker.sort_file_size') },
|
|
437
|
+
]}
|
|
438
|
+
sort={sort}
|
|
439
|
+
onSortChange={(next) => setSort(next as MediaPickerSearchOptions['sort'])}
|
|
440
|
+
/>
|
|
441
|
+
)}
|
|
442
|
+
<MediaViewMenu view={view} onViewChange={changeView} />
|
|
443
|
+
</div>
|
|
444
|
+
</div>
|
|
445
|
+
|
|
446
|
+
{browseControls && (
|
|
447
|
+
<div className="flex flex-wrap items-center gap-2">
|
|
448
|
+
{offersVideo && (
|
|
449
|
+
<MediaFilterChip
|
|
450
|
+
label={t('admin.media_picker.filter_type')}
|
|
451
|
+
value={mediaType}
|
|
452
|
+
onChange={(next) => setMediaType(next as MediaPickerSearchOptions['mediaType'])}
|
|
453
|
+
options={[
|
|
454
|
+
{ value: 'all', label: t('admin.media_picker.any') },
|
|
455
|
+
{ value: 'image', label: t('admin.media_picker.type_image') },
|
|
456
|
+
{ value: 'video', label: t('admin.media_picker.type_video') },
|
|
457
|
+
]}
|
|
458
|
+
/>
|
|
459
|
+
)}
|
|
460
|
+
<MediaFilterChip
|
|
461
|
+
label={t('admin.media_picker.filter_usage')}
|
|
462
|
+
value={usage}
|
|
463
|
+
onChange={(next) => setUsage(next as MediaPickerSearchOptions['usage'])}
|
|
464
|
+
options={[
|
|
465
|
+
{ value: 'all', label: t('admin.media_picker.any') },
|
|
466
|
+
{ value: 'used', label: t('admin.media_picker.usage_used') },
|
|
467
|
+
{ value: 'unused', label: t('admin.media_picker.usage_unused') },
|
|
468
|
+
]}
|
|
469
|
+
/>
|
|
470
|
+
</div>
|
|
471
|
+
)}
|
|
472
|
+
</div>
|
|
473
|
+
|
|
474
|
+
{onUpload && (
|
|
475
|
+
// biome-ignore lint/a11y/noStaticElementInteractions: a drop target; the button inside is the keyboard way in
|
|
476
|
+
<div
|
|
477
|
+
onDragOver={(event) => {
|
|
478
|
+
event.preventDefault()
|
|
479
|
+
setDragging(true)
|
|
480
|
+
}}
|
|
481
|
+
onDragLeave={() => setDragging(false)}
|
|
482
|
+
onDrop={handleDrop}
|
|
483
|
+
className={cn(
|
|
484
|
+
'flex flex-col items-center justify-center gap-2 rounded-card border border-dashed border-border px-4 py-6 text-center transition-colors',
|
|
485
|
+
dragging && 'border-blue-500 bg-blue-50/70 dark:bg-blue-500/10',
|
|
486
|
+
)}
|
|
487
|
+
>
|
|
488
|
+
<input
|
|
489
|
+
ref={fileInputRef}
|
|
490
|
+
type="file"
|
|
491
|
+
accept={accept}
|
|
492
|
+
multiple={multiple}
|
|
493
|
+
className="hidden"
|
|
494
|
+
onChange={(e) => handleUpload(Array.from(e.target.files ?? []))}
|
|
495
|
+
/>
|
|
496
|
+
<Button
|
|
497
|
+
type="button"
|
|
498
|
+
variant="outline"
|
|
499
|
+
disabled={uploading}
|
|
500
|
+
onClick={() => fileInputRef.current?.click()}
|
|
501
|
+
>
|
|
502
|
+
{uploading ? (
|
|
503
|
+
<Loader2Icon className="size-4 animate-spin" />
|
|
504
|
+
) : (
|
|
505
|
+
<UploadIcon className="size-4" />
|
|
506
|
+
)}
|
|
507
|
+
<span>{t('admin.media_picker.upload')}</span>
|
|
508
|
+
</Button>
|
|
509
|
+
<p className="text-muted-foreground text-base">
|
|
510
|
+
{offersVideo
|
|
511
|
+
? t('admin.media_picker.drop_hint_media')
|
|
512
|
+
: t('admin.media_picker.drop_hint_images')}
|
|
513
|
+
</p>
|
|
514
|
+
</div>
|
|
515
|
+
)}
|
|
516
|
+
|
|
517
|
+
{isFetching && results.length === 0 ? (
|
|
518
|
+
<div className="flex items-center justify-center py-10 text-muted-foreground">
|
|
519
|
+
<Loader2Icon className="size-5 animate-spin" />
|
|
520
|
+
</div>
|
|
521
|
+
) : results.length === 0 ? (
|
|
522
|
+
<p className="p-6 text-center text-muted-foreground text-base">
|
|
523
|
+
{filtered ? t('admin.media_picker.no_results') : t('admin.media_picker.empty')}
|
|
524
|
+
</p>
|
|
525
|
+
) : view === 'grid' ? (
|
|
526
|
+
<ul className="grid grid-cols-2 gap-2 sm:grid-cols-4 lg:grid-cols-6">
|
|
527
|
+
{results.map((media) => (
|
|
528
|
+
<li key={media.id}>
|
|
529
|
+
<GridTile
|
|
530
|
+
media={media}
|
|
531
|
+
checked={staged.has(media.id)}
|
|
532
|
+
onPick={() => pick(media)}
|
|
533
|
+
badge={media.id === firstSelectedId ? firstSelectedBadge : undefined}
|
|
534
|
+
/>
|
|
535
|
+
</li>
|
|
536
|
+
))}
|
|
537
|
+
</ul>
|
|
538
|
+
) : (
|
|
539
|
+
<div>
|
|
540
|
+
<MediaListHeader withCheckbox />
|
|
541
|
+
<ul className={mediaListClassName}>
|
|
542
|
+
{results.map((media) => (
|
|
543
|
+
<li key={media.id}>
|
|
544
|
+
<ListRow
|
|
545
|
+
media={media}
|
|
546
|
+
checked={staged.has(media.id)}
|
|
547
|
+
onPick={() => pick(media)}
|
|
548
|
+
badge={media.id === firstSelectedId ? firstSelectedBadge : undefined}
|
|
549
|
+
/>
|
|
550
|
+
</li>
|
|
551
|
+
))}
|
|
552
|
+
</ul>
|
|
553
|
+
</div>
|
|
554
|
+
)}
|
|
555
|
+
</div>
|
|
556
|
+
|
|
557
|
+
{multiple && (
|
|
558
|
+
<DialogFooter className="items-center sm:justify-between">
|
|
559
|
+
<span className="text-muted-foreground text-base">
|
|
560
|
+
{t('admin.media_picker.selected_count', { count: staged.size })}
|
|
561
|
+
</span>
|
|
562
|
+
<div className="flex gap-2">
|
|
563
|
+
<Button type="button" variant="outline" onClick={() => handleOpenChange(false)}>
|
|
564
|
+
{t('admin.actions.cancel')}
|
|
565
|
+
</Button>
|
|
566
|
+
<Button
|
|
567
|
+
type="button"
|
|
568
|
+
disabled={(staged.size === 0 && !allowEmptyConfirm) || submitting}
|
|
569
|
+
onClick={() => submit(Array.from(staged.values()))}
|
|
570
|
+
>
|
|
571
|
+
{submitting && <Loader2Icon className="size-4 animate-spin" />}
|
|
572
|
+
<span>{confirmLabel ?? t('admin.media_picker.confirm')}</span>
|
|
573
|
+
</Button>
|
|
574
|
+
</div>
|
|
575
|
+
</DialogFooter>
|
|
576
|
+
)}
|
|
577
|
+
</DialogContent>
|
|
578
|
+
</Dialog>
|
|
579
|
+
)
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
function Thumbnail({ media, className }: { media: MediaPickerOption; className?: string }) {
|
|
583
|
+
const src = media.small_url ?? media.poster_url ?? media.original_url
|
|
584
|
+
const label = media.alt || media.filename || ''
|
|
585
|
+
return (
|
|
586
|
+
<span
|
|
587
|
+
className={cn(
|
|
588
|
+
'relative flex items-center justify-center overflow-hidden bg-muted',
|
|
589
|
+
className,
|
|
590
|
+
)}
|
|
591
|
+
>
|
|
592
|
+
{src ? (
|
|
593
|
+
<img src={src} alt={label} loading="lazy" className="size-full object-cover" />
|
|
594
|
+
) : (
|
|
595
|
+
<ImageIcon className="size-6 text-muted-foreground" />
|
|
596
|
+
)}
|
|
597
|
+
{!!media.media_type && media.media_type !== 'image' && (
|
|
598
|
+
<span className="absolute right-1.5 bottom-1.5 rounded-full bg-black/60 p-1 text-white">
|
|
599
|
+
<PlayIcon className="size-3" />
|
|
600
|
+
</span>
|
|
601
|
+
)}
|
|
602
|
+
</span>
|
|
603
|
+
)
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
/** The button classes of a media grid tile; `selected` gives it the grey fill. */
|
|
607
|
+
export function mediaTileClassName(selected = false) {
|
|
608
|
+
return cn(
|
|
609
|
+
'group flex w-full flex-col gap-2 rounded-xl p-2 text-center outline-none transition-colors hover:bg-accent/60 focus-visible:shadow-field-focus',
|
|
610
|
+
selected && 'bg-accent hover:bg-accent',
|
|
611
|
+
)
|
|
612
|
+
}
|
|
613
|
+
|
|
614
|
+
/**
|
|
615
|
+
* A file as a tile: the picture framed on its own, its name and type below.
|
|
616
|
+
* Picked, the whole tile takes a grey fill and its box is ticked — a quiet
|
|
617
|
+
* state the eye still finds, rather than a heavy ring around every pick.
|
|
618
|
+
* `checkbox` overlays the frame's corner where the host selects.
|
|
619
|
+
*/
|
|
620
|
+
export function MediaTileBody({
|
|
621
|
+
media,
|
|
622
|
+
checkbox,
|
|
623
|
+
badge,
|
|
624
|
+
}: {
|
|
625
|
+
media: MediaPickerOption
|
|
626
|
+
checkbox?: ReactNode
|
|
627
|
+
/** A short label over the frame's foot, such as "Thumbnail". */
|
|
628
|
+
badge?: ReactNode
|
|
629
|
+
}) {
|
|
630
|
+
return (
|
|
631
|
+
<>
|
|
632
|
+
<span className="relative block">
|
|
633
|
+
<Thumbnail
|
|
634
|
+
media={media}
|
|
635
|
+
className="aspect-square w-full rounded-lg border border-card-edge bg-card shadow-card-lift"
|
|
636
|
+
/>
|
|
637
|
+
{checkbox}
|
|
638
|
+
{badge && (
|
|
639
|
+
<span className="absolute bottom-2 left-2 rounded-md bg-black/70 px-1.5 py-0.5 font-medium text-white text-xs">
|
|
640
|
+
{badge}
|
|
641
|
+
</span>
|
|
642
|
+
)}
|
|
643
|
+
</span>
|
|
644
|
+
<span className="flex min-w-0 flex-col">
|
|
645
|
+
<span className="truncate text-base">{media.alt || media.filename}</span>
|
|
646
|
+
<span className="text-muted-foreground text-xs">{mediaFileTypeLabel(media)}</span>
|
|
647
|
+
</span>
|
|
648
|
+
</>
|
|
649
|
+
)
|
|
650
|
+
}
|
|
651
|
+
|
|
652
|
+
/**
|
|
653
|
+
* The button classes of a media list row; `selected` gives it the grey fill.
|
|
654
|
+
* Rows look like a resource table's: a rounded fill on hover or selection,
|
|
655
|
+
* on a list framed by `mediaListClassName` and headed by `MediaListHeader`.
|
|
656
|
+
*/
|
|
657
|
+
export function mediaRowClassName(selected = false) {
|
|
658
|
+
return cn(
|
|
659
|
+
'flex w-full items-center gap-3 rounded-[10px] px-3 py-2 text-left outline-none transition-colors hover:bg-muted focus-visible:shadow-field-focus data-popup-open:bg-accent-strong/40',
|
|
660
|
+
selected && 'bg-accent-strong/75 hover:bg-accent-strong-hover',
|
|
661
|
+
)
|
|
662
|
+
}
|
|
663
|
+
|
|
664
|
+
/**
|
|
665
|
+
* The `<ul>` of a media list: no frame, a faint divider between rows, and a
|
|
666
|
+
* 2px gap above each so a filled row stands clear of its neighbours. A filled
|
|
667
|
+
* row (hovered, selected, menu open) hides the dividers either side of it.
|
|
668
|
+
*/
|
|
669
|
+
export const mediaListClassName = cn(
|
|
670
|
+
'flex flex-col [&>li]:relative [&>li]:pt-0.5',
|
|
671
|
+
"[&>li+li]:before:absolute [&>li+li]:before:inset-x-0 [&>li+li]:before:top-0 [&>li+li]:before:h-px [&>li+li]:before:bg-border-subtle [&>li+li]:before:content-['']",
|
|
672
|
+
'[&>li:is(:hover,:has([aria-pressed=true]),:has([data-popup-open]))]:before:bg-transparent [&>li:is(:hover,:has([aria-pressed=true]),:has([data-popup-open]))+li]:before:bg-transparent',
|
|
673
|
+
)
|
|
674
|
+
|
|
675
|
+
/**
|
|
676
|
+
* The column labels over a media list, as a resource table's header band.
|
|
677
|
+
* The spacers line the labels up with `MediaRowBody`'s columns; pass the
|
|
678
|
+
* trailing column's width classes when the rows carry one.
|
|
679
|
+
*/
|
|
680
|
+
export function MediaListHeader({
|
|
681
|
+
withCheckbox = false,
|
|
682
|
+
trailingClassName,
|
|
683
|
+
}: {
|
|
684
|
+
withCheckbox?: boolean
|
|
685
|
+
trailingClassName?: string
|
|
686
|
+
}) {
|
|
687
|
+
const { t } = useTranslation()
|
|
688
|
+
return (
|
|
689
|
+
<div
|
|
690
|
+
aria-hidden
|
|
691
|
+
className="flex h-10 items-center gap-3 rounded-[10px] bg-muted px-3 font-medium text-muted-foreground text-xs"
|
|
692
|
+
>
|
|
693
|
+
{withCheckbox && <span className="w-4 shrink-0" />}
|
|
694
|
+
<span className="w-10 shrink-0" />
|
|
695
|
+
<span className="min-w-0 flex-1">{t('admin.media_picker.sort_file_name')}</span>
|
|
696
|
+
<span className="hidden w-14 shrink-0 sm:block">{t('admin.media_picker.filter_type')}</span>
|
|
697
|
+
<span className="hidden w-20 shrink-0 text-right sm:block">
|
|
698
|
+
{t('admin.media_picker.sort_file_size')}
|
|
699
|
+
</span>
|
|
700
|
+
<span className="w-24 shrink-0 text-right">{t('admin.media_picker.sort_date_added')}</span>
|
|
701
|
+
{trailingClassName && <span className={trailingClassName} />}
|
|
702
|
+
</div>
|
|
703
|
+
)
|
|
704
|
+
}
|
|
705
|
+
|
|
706
|
+
/**
|
|
707
|
+
* A file as a row, for finding one by name in a long library: thumbnail, name
|
|
708
|
+
* and file name, then type, size and date added — what tells two similar
|
|
709
|
+
* files apart once the picture is too small to. `leading` goes first (a
|
|
710
|
+
* checkbox), `trailing` after the details (a status).
|
|
711
|
+
*/
|
|
712
|
+
export function MediaRowBody({
|
|
713
|
+
media,
|
|
714
|
+
leading,
|
|
715
|
+
trailing,
|
|
716
|
+
badge,
|
|
717
|
+
}: {
|
|
718
|
+
media: MediaPickerOption
|
|
719
|
+
leading?: ReactNode
|
|
720
|
+
trailing?: ReactNode
|
|
721
|
+
/** A short label beside the name, such as "Thumbnail". */
|
|
722
|
+
badge?: ReactNode
|
|
723
|
+
}) {
|
|
724
|
+
const { i18n } = useTranslation()
|
|
725
|
+
return (
|
|
726
|
+
<>
|
|
727
|
+
{leading}
|
|
728
|
+
<Thumbnail media={media} className="size-10 shrink-0 rounded-md border border-card-edge" />
|
|
729
|
+
<span className="min-w-0 flex-1">
|
|
730
|
+
<span className="flex items-center gap-2 text-base">
|
|
731
|
+
<span className="truncate">{media.alt || media.filename}</span>
|
|
732
|
+
{badge && (
|
|
733
|
+
<span className="shrink-0 rounded-md bg-accent px-1.5 py-0.5 font-medium text-xs">
|
|
734
|
+
{badge}
|
|
735
|
+
</span>
|
|
736
|
+
)}
|
|
737
|
+
</span>
|
|
738
|
+
{media.alt && media.filename && media.alt !== media.filename && (
|
|
739
|
+
<span className="block truncate text-muted-foreground text-xs">{media.filename}</span>
|
|
740
|
+
)}
|
|
741
|
+
</span>
|
|
742
|
+
<span className="hidden w-14 shrink-0 text-muted-foreground text-xs sm:block">
|
|
743
|
+
{mediaFileTypeLabel(media)}
|
|
744
|
+
</span>
|
|
745
|
+
<span className="hidden w-20 shrink-0 text-right text-muted-foreground text-xs tabular-nums sm:block">
|
|
746
|
+
{formatMediaBytes(media.byte_size, i18n.language)}
|
|
747
|
+
</span>
|
|
748
|
+
<span className="w-24 shrink-0 text-right text-muted-foreground text-xs tabular-nums">
|
|
749
|
+
{media.created_at
|
|
750
|
+
? new Date(media.created_at).toLocaleDateString(i18n.language, {
|
|
751
|
+
day: 'numeric',
|
|
752
|
+
month: 'short',
|
|
753
|
+
year: 'numeric',
|
|
754
|
+
})
|
|
755
|
+
: ''}
|
|
756
|
+
</span>
|
|
757
|
+
{trailing}
|
|
758
|
+
</>
|
|
759
|
+
)
|
|
760
|
+
}
|
|
761
|
+
|
|
762
|
+
function GridTile({
|
|
763
|
+
media,
|
|
764
|
+
checked,
|
|
765
|
+
onPick,
|
|
766
|
+
badge,
|
|
767
|
+
}: {
|
|
768
|
+
media: MediaPickerOption
|
|
769
|
+
checked: boolean
|
|
770
|
+
onPick: () => void
|
|
771
|
+
badge?: ReactNode
|
|
772
|
+
}) {
|
|
773
|
+
return (
|
|
774
|
+
<button
|
|
775
|
+
type="button"
|
|
776
|
+
onClick={onPick}
|
|
777
|
+
aria-pressed={checked}
|
|
778
|
+
className={mediaTileClassName(checked)}
|
|
779
|
+
>
|
|
780
|
+
<MediaTileBody
|
|
781
|
+
media={media}
|
|
782
|
+
badge={badge}
|
|
783
|
+
checkbox={
|
|
784
|
+
<Checkbox
|
|
785
|
+
checked={checked}
|
|
786
|
+
tabIndex={-1}
|
|
787
|
+
aria-hidden
|
|
788
|
+
className={cn(
|
|
789
|
+
'pointer-events-none absolute top-2 left-2 transition-opacity',
|
|
790
|
+
!checked && 'opacity-0 group-hover:opacity-100 group-focus-visible:opacity-100',
|
|
791
|
+
)}
|
|
792
|
+
/>
|
|
793
|
+
}
|
|
794
|
+
/>
|
|
795
|
+
</button>
|
|
796
|
+
)
|
|
797
|
+
}
|
|
798
|
+
|
|
799
|
+
function ListRow({
|
|
800
|
+
media,
|
|
801
|
+
checked,
|
|
802
|
+
onPick,
|
|
803
|
+
badge,
|
|
804
|
+
}: {
|
|
805
|
+
media: MediaPickerOption
|
|
806
|
+
checked: boolean
|
|
807
|
+
onPick: () => void
|
|
808
|
+
badge?: ReactNode
|
|
809
|
+
}) {
|
|
810
|
+
return (
|
|
811
|
+
<button
|
|
812
|
+
type="button"
|
|
813
|
+
onClick={onPick}
|
|
814
|
+
aria-pressed={checked}
|
|
815
|
+
className={mediaRowClassName(checked)}
|
|
816
|
+
>
|
|
817
|
+
<MediaRowBody
|
|
818
|
+
media={media}
|
|
819
|
+
badge={badge}
|
|
820
|
+
leading={
|
|
821
|
+
<Checkbox checked={checked} tabIndex={-1} aria-hidden className="pointer-events-none" />
|
|
822
|
+
}
|
|
823
|
+
/>
|
|
824
|
+
</button>
|
|
825
|
+
)
|
|
826
|
+
}
|
|
827
|
+
|
|
828
|
+
/** A filter as a chip showing its current value, opening a one-choice menu. */
|
|
829
|
+
export function MediaFilterChip({
|
|
830
|
+
label,
|
|
831
|
+
value,
|
|
832
|
+
onChange,
|
|
833
|
+
options,
|
|
834
|
+
}: {
|
|
835
|
+
label: string
|
|
836
|
+
value: string
|
|
837
|
+
onChange: (value: string) => void
|
|
838
|
+
options: { value: string; label: string }[]
|
|
839
|
+
}) {
|
|
840
|
+
const current = options.find((option) => option.value === value)
|
|
841
|
+
return (
|
|
842
|
+
<DropdownMenu>
|
|
843
|
+
<DropdownMenuTrigger asChild>
|
|
844
|
+
<Button
|
|
845
|
+
type="button"
|
|
846
|
+
variant="outline"
|
|
847
|
+
size="sm"
|
|
848
|
+
className="h-11 font-normal lg:h-[2.125rem]"
|
|
849
|
+
>
|
|
850
|
+
<span className="text-muted-foreground">{label}</span>
|
|
851
|
+
<span className="font-medium">{current?.label}</span>
|
|
852
|
+
<ChevronsUpDownIcon className="size-3.5 text-muted-foreground" />
|
|
853
|
+
</Button>
|
|
854
|
+
</DropdownMenuTrigger>
|
|
855
|
+
<DropdownMenuContent align="start">
|
|
856
|
+
<DropdownMenuRadioGroup value={value} onValueChange={(next) => onChange(String(next))}>
|
|
857
|
+
{options.map((option) => (
|
|
858
|
+
<DropdownMenuRadioItem key={option.value} value={option.value}>
|
|
859
|
+
{option.label}
|
|
860
|
+
</DropdownMenuRadioItem>
|
|
861
|
+
))}
|
|
862
|
+
</DropdownMenuRadioGroup>
|
|
863
|
+
</DropdownMenuContent>
|
|
864
|
+
</DropdownMenu>
|
|
865
|
+
)
|
|
866
|
+
}
|