@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.
Files changed (86) hide show
  1. package/dist/vite/index.js +81 -5
  2. package/package.json +19 -19
  3. package/src/api-client.ts +7 -1
  4. package/src/client.ts +2 -0
  5. package/src/components/address-form-dialog.tsx +75 -28
  6. package/src/components/app-shell.tsx +5 -4
  7. package/src/components/app-sidebar.tsx +2 -1
  8. package/src/components/bulk-action-bar.tsx +6 -5
  9. package/src/components/country-state-fields.tsx +3 -0
  10. package/src/components/currency-select.tsx +6 -4
  11. package/src/components/developer-inspector-drawer.tsx +322 -0
  12. package/src/components/file-upload-field.tsx +64 -37
  13. package/src/components/image-upload-field.tsx +4 -0
  14. package/src/components/import-wizard-dialog.tsx +14 -12
  15. package/src/components/invitation-acceptance.tsx +15 -11
  16. package/src/components/media-editor-dialog.tsx +131 -0
  17. package/src/components/media-picker-dialog.tsx +866 -0
  18. package/src/components/media-picker-sheet.tsx +13 -300
  19. package/src/components/mobile-top-bar.tsx +1 -1
  20. package/src/components/nav-main.tsx +13 -2
  21. package/src/components/page-header.tsx +57 -13
  22. package/src/components/page-tabs.tsx +1 -1
  23. package/src/components/product-stock-summary.tsx +1 -1
  24. package/src/components/read-row.tsx +38 -0
  25. package/src/components/resource-combobox.tsx +1 -1
  26. package/src/components/resource-image-field.tsx +150 -0
  27. package/src/components/resource-picker-sheet.tsx +2 -2
  28. package/src/components/resource-table.tsx +161 -40
  29. package/src/components/section-heading.tsx +2 -2
  30. package/src/components/settings-container.tsx +19 -0
  31. package/src/components/settings-fields.tsx +165 -0
  32. package/src/components/settings-section.tsx +46 -0
  33. package/src/components/settings-sidebar.tsx +35 -18
  34. package/src/components/sheet-top-bar.tsx +7 -8
  35. package/src/components/sidebar-search.tsx +11 -12
  36. package/src/components/sidebar-user.tsx +39 -44
  37. package/src/components/skip-link.tsx +1 -1
  38. package/src/components/store-switcher.tsx +1 -1
  39. package/src/components/table-toolbar.tsx +189 -323
  40. package/src/components/timezone-combobox.tsx +119 -0
  41. package/src/components/top-bar-breadcrumbs.tsx +1 -1
  42. package/src/hooks/use-stock-locations.ts +6 -2
  43. package/src/hooks/use-table-density.ts +50 -0
  44. package/src/index.ts +19 -2
  45. package/src/lib/curl.test.ts +18 -0
  46. package/src/lib/curl.ts +28 -0
  47. package/src/lib/developer-inspector.test.ts +169 -0
  48. package/src/lib/developer-inspector.ts +134 -0
  49. package/src/lib/docs.ts +3 -2
  50. package/src/lib/fulfillment-items.test.ts +51 -0
  51. package/src/lib/fulfillment-items.ts +53 -0
  52. package/src/lib/request-snippets.ts +41 -0
  53. package/src/lib/table-registry.ts +13 -6
  54. package/src/lib/timestamp-columns.tsx +1 -1
  55. package/src/locales/ar.json +120 -14
  56. package/src/locales/de.json +120 -14
  57. package/src/locales/en.json +120 -14
  58. package/src/locales/es.json +120 -14
  59. package/src/locales/fr.json +120 -14
  60. package/src/locales/ja.json +120 -14
  61. package/src/locales/pl.json +120 -14
  62. package/src/locales/vi.json +120 -14
  63. package/src/locales/zh-CN.json +120 -14
  64. package/src/pages/settings-index.tsx +79 -48
  65. package/src/pages/stock-locations.tsx +14 -6
  66. package/src/products/digital-asset-edit-sheet.tsx +1 -1
  67. package/src/products/digital-asset-provider-sheet.tsx +1 -1
  68. package/src/products/digital-assets-card.tsx +7 -7
  69. package/src/products/inventory-section.tsx +2 -2
  70. package/src/products/media-edit-sheet.tsx +136 -133
  71. package/src/products/product-form-cards.tsx +201 -70
  72. package/src/products/product-form-mapping.ts +1 -0
  73. package/src/products/product-schema.ts +8 -0
  74. package/src/products/product-types.ts +2 -0
  75. package/src/products/use-product-form-data.ts +30 -3
  76. package/src/products/variant-edit-sheet.tsx +3 -3
  77. package/src/products/variants-options-builder.tsx +13 -8
  78. package/src/products/variants-section.tsx +196 -4
  79. package/src/providers/auth-provider.tsx +2 -0
  80. package/src/providers/developer-inspector-provider.tsx +145 -0
  81. package/src/providers/json-link-resolver-provider.tsx +27 -0
  82. package/src/sellers/seller-schema.ts +107 -0
  83. package/src/tables/package-types.tsx +3 -3
  84. package/src/tables/stock-locations.tsx +15 -14
  85. package/src/virtual.d.ts +4 -0
  86. 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
+ }