@spree/dashboard-core 0.13.0 → 1.0.0-beta.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (126) hide show
  1. package/README.md +4 -0
  2. package/dist/vite/index.js +17 -3
  3. package/package.json +7 -7
  4. package/src/api-client.ts +643 -0
  5. package/src/components/address-form-dialog.test.ts +25 -0
  6. package/src/components/address-form-dialog.tsx +203 -79
  7. package/src/components/app-shell.tsx +171 -0
  8. package/src/components/app-sidebar.tsx +184 -27
  9. package/src/components/bulk-action-bar.tsx +89 -157
  10. package/src/components/country-combobox.tsx +69 -34
  11. package/src/components/country-state-fields.tsx +43 -11
  12. package/src/components/currency-select.tsx +81 -66
  13. package/src/components/export-button.tsx +62 -5
  14. package/src/components/file-upload-field.tsx +32 -10
  15. package/src/components/import-button.tsx +97 -36
  16. package/src/components/import-wizard-dialog.tsx +710 -0
  17. package/src/components/locale-select.tsx +70 -57
  18. package/src/components/media-picker-sheet.tsx +307 -0
  19. package/src/components/mobile-top-bar.tsx +75 -0
  20. package/src/components/nav-main.tsx +197 -27
  21. package/src/components/page-header.tsx +203 -61
  22. package/src/components/page-tabs.tsx +4 -1
  23. package/src/components/preferences-form.tsx +98 -4
  24. package/src/components/resource-combobox.tsx +52 -4
  25. package/src/components/resource-picker-sheet.tsx +209 -73
  26. package/src/components/resource-table.tsx +425 -131
  27. package/src/components/route-announcer.tsx +64 -0
  28. package/src/components/section-heading.tsx +95 -0
  29. package/src/components/settings-sidebar.tsx +290 -60
  30. package/src/components/sheet-top-bar.tsx +44 -0
  31. package/src/components/sidebar-search.tsx +83 -0
  32. package/src/components/sidebar-user.tsx +213 -0
  33. package/src/components/skip-link.tsx +36 -0
  34. package/src/components/store-date-picker.tsx +6 -2
  35. package/src/components/store-switcher.tsx +63 -22
  36. package/src/components/table-toolbar.tsx +1373 -442
  37. package/src/components/tag-combobox.tsx +12 -4
  38. package/src/components/top-bar-breadcrumbs.tsx +235 -0
  39. package/src/components/wizard-close-guard.test.ts +54 -0
  40. package/src/hooks/use-auto-collapse-sidebar.ts +57 -0
  41. package/src/hooks/use-command-palette.tsx +13 -0
  42. package/src/hooks/use-countries.test.ts +56 -0
  43. package/src/hooks/use-countries.ts +51 -5
  44. package/src/hooks/use-direct-upload.ts +16 -3
  45. package/src/hooks/use-export.ts +51 -16
  46. package/src/hooks/use-import.ts +261 -14
  47. package/src/hooks/use-package-types.ts +132 -0
  48. package/src/hooks/use-resource-mutation.ts +74 -13
  49. package/src/hooks/use-stock-locations.ts +131 -0
  50. package/src/hooks/use-switch-admin-locale.ts +2 -2
  51. package/src/hooks/use-visual-viewport-offset.ts +61 -0
  52. package/src/index.ts +45 -1
  53. package/src/lib/cancel-order-form.ts +27 -0
  54. package/src/lib/create-action-registry.ts +278 -0
  55. package/src/lib/date-presets.test.ts +86 -0
  56. package/src/lib/date-presets.ts +98 -0
  57. package/src/lib/delivery-form.ts +15 -0
  58. package/src/lib/docs.ts +32 -0
  59. package/src/lib/download.ts +13 -1
  60. package/src/lib/filters-to-ransack.test.ts +113 -0
  61. package/src/lib/filters-to-ransack.ts +22 -1
  62. package/src/lib/form-errors.ts +122 -10
  63. package/src/lib/form-mappers.ts +76 -0
  64. package/src/lib/fulfillment-items.test.ts +213 -0
  65. package/src/lib/fulfillment-items.ts +171 -0
  66. package/src/lib/i18n.ts +19 -11
  67. package/src/lib/import-types.ts +25 -0
  68. package/src/lib/label-upload-form.ts +22 -0
  69. package/src/lib/nav-registry.ts +71 -3
  70. package/src/lib/permissions.ts +46 -1
  71. package/src/lib/progress.ts +18 -0
  72. package/src/lib/query-keys.ts +13 -13
  73. package/src/lib/sample-csv.ts +21 -0
  74. package/src/lib/search-registry.ts +6 -2
  75. package/src/lib/settings-nav-registry.ts +104 -11
  76. package/src/lib/storefront.ts +19 -0
  77. package/src/lib/table-registry.ts +56 -0
  78. package/src/lib/type-labels.test.ts +68 -0
  79. package/src/lib/type-labels.ts +78 -0
  80. package/src/locales/ar.json +1889 -516
  81. package/src/locales/de.json +1883 -515
  82. package/src/locales/en.json +1886 -516
  83. package/src/locales/fr.json +1884 -516
  84. package/src/locales/pl.json +1913 -516
  85. package/src/locales/zh-CN.json +1883 -516
  86. package/src/pages/package-types.tsx +484 -0
  87. package/src/pages/settings-index.tsx +112 -0
  88. package/src/pages/stock-locations.tsx +674 -0
  89. package/src/plugin.ts +29 -0
  90. package/src/products/add-video-dialog.tsx +100 -0
  91. package/src/products/bulk-variants-dialog.tsx +301 -0
  92. package/src/products/carton-size.test.ts +51 -0
  93. package/src/products/carton-size.ts +20 -0
  94. package/src/products/currency-parts.ts +21 -0
  95. package/src/products/inventory-section.tsx +259 -0
  96. package/src/products/media-edit-sheet.tsx +336 -0
  97. package/src/products/normalize-customs.test.ts +39 -0
  98. package/src/products/normalize-customs.ts +27 -0
  99. package/src/products/normalize-money.test.ts +58 -0
  100. package/src/products/normalize-money.ts +73 -0
  101. package/src/products/normalize-quantity.test.ts +32 -0
  102. package/src/products/normalize-quantity.ts +31 -0
  103. package/src/products/product-bulk-price-editor.tsx +165 -0
  104. package/src/products/product-form-cards.tsx +1167 -0
  105. package/src/products/product-form-mapping.ts +212 -0
  106. package/src/products/product-schema.ts +318 -0
  107. package/src/products/product-types.ts +130 -0
  108. package/src/products/use-product-form-data.ts +273 -0
  109. package/src/products/variant-edit-sheet.tsx +725 -0
  110. package/src/products/variants-matrix.test.ts +154 -0
  111. package/src/products/variants-matrix.ts +244 -0
  112. package/src/products/variants-options-builder.tsx +571 -0
  113. package/src/products/variants-section.tsx +535 -0
  114. package/src/products/video-url.test.ts +71 -0
  115. package/src/products/video-url.ts +83 -0
  116. package/src/providers/auth-provider.tsx +70 -12
  117. package/src/providers/permission-provider.tsx +68 -28
  118. package/src/providers/sticky-header-provider.tsx +134 -0
  119. package/src/providers/store-provider.tsx +36 -3
  120. package/src/providers/tenant-provider.tsx +33 -0
  121. package/src/schemas/package-type.ts +95 -0
  122. package/src/schemas/stock-location.ts +126 -0
  123. package/src/tables/package-types.tsx +74 -0
  124. package/src/tables/stock-locations.tsx +124 -0
  125. package/src/vite/index.ts +40 -7
  126. package/src/components/top-bar.tsx +0 -210
@@ -3,9 +3,11 @@ import {
3
3
  ResourceCombobox as HeadlessResourceCombobox,
4
4
  type ResourceComboboxProps as HeadlessResourceComboboxProps,
5
5
  } from '@spree/dashboard-ui'
6
+ import { PlusIcon } from '@spree/dashboard-ui/icons'
6
7
  import { useQuery } from '@tanstack/react-query'
7
8
  import { useDeferredValue, useMemo, useState } from 'react'
8
9
  import { useTranslation } from 'react-i18next'
10
+ import { useTenantId } from '../providers/tenant-provider'
9
11
 
10
12
  export type { ComboboxOption } from '@spree/dashboard-ui'
11
13
 
@@ -19,14 +21,16 @@ export interface ResourceComboboxProps<T extends ComboboxOption>
19
21
  | 'placeholder'
20
22
  | 'emptyText'
21
23
  | 'disabled'
24
+ | 'id'
22
25
  > {
23
26
  /**
24
27
  * Server-side search. Called as the user types (debounced via React's
25
28
  * `useDeferredValue`). Receives the trimmed query string — returning
26
29
  * `{ data: [] }` for empty queries is a fine default, as is showing
27
- * "recent records".
30
+ * "recent records". When the response includes `meta.count`, rows beyond
31
+ * `data.length` trigger a footer hint so a capped list does not read complete.
28
32
  */
29
- search: (query: string) => Promise<{ data: T[] }>
33
+ search: (query: string) => Promise<{ data: T[]; meta?: { count: number } }>
30
34
 
31
35
  /**
32
36
  * Hydrate the currently-selected ID into a record so the trigger can show
@@ -44,6 +48,14 @@ export interface ResourceComboboxProps<T extends ComboboxOption>
44
48
  * Returning `false` drops the row from the dropdown.
45
49
  */
46
50
  filterOption?: (option: T) => boolean
51
+
52
+ /**
53
+ * A row pinned to the bottom of the dropdown, for creating what the search
54
+ * could not find — "New supplier" on a purchase order, say. It sits with the
55
+ * results rather than beside the field so it is found where the merchant is
56
+ * already looking, and it closes the dropdown when chosen.
57
+ */
58
+ action?: { label: string; onSelect: () => void }
47
59
  }
48
60
 
49
61
  /**
@@ -68,6 +80,8 @@ export function ResourceCombobox<T extends ComboboxOption>({
68
80
  emptyText,
69
81
  disabled,
70
82
  filterOption,
83
+ action,
84
+ id,
71
85
  }: ResourceComboboxProps<T>) {
72
86
  const { t } = useTranslation()
73
87
  const placeholderLabel = placeholder ?? t('admin.common.search_placeholder')
@@ -78,6 +92,10 @@ export function ResourceCombobox<T extends ComboboxOption>({
78
92
  // `onInputChange` on the Root rather than controlling
79
93
  // `<ComboboxInput value>` directly — controlling the input blocks Base UI
80
94
  // from updating it on selection.
95
+ // Scoped here, not at the call site: results are tenant-specific (a store in
96
+ // the dashboard, a seller in the seller panel), and a caller passing a
97
+ // constant key would otherwise serve one tenant's records in another.
98
+ const tenantId = useTenantId()
81
99
  const [input, setInput] = useState('')
82
100
  // Defer the search query so a fast typist doesn't fire one request per
83
101
  // keystroke — React batches the search to the next idle paint.
@@ -85,7 +103,7 @@ export function ResourceCombobox<T extends ComboboxOption>({
85
103
  const trimmedQuery = deferredInput.trim()
86
104
 
87
105
  const { data: searchData } = useQuery({
88
- queryKey: [queryKey, 'search', trimmedQuery],
106
+ queryKey: [queryKey, tenantId, 'search', trimmedQuery],
89
107
  queryFn: () => search(trimmedQuery),
90
108
  staleTime: 30_000,
91
109
  })
@@ -95,7 +113,7 @@ export function ResourceCombobox<T extends ComboboxOption>({
95
113
  // Skipped when the search results already include the ID.
96
114
  const searchHasValue = !!(value && searchData?.data.some((r) => r.id === value))
97
115
  const { data: hydratedData } = useQuery({
98
- queryKey: [queryKey, 'hydrate', value],
116
+ queryKey: [queryKey, tenantId, 'hydrate', value],
99
117
  queryFn: () => hydrate([value as string]),
100
118
  enabled: !!value && !searchHasValue,
101
119
  staleTime: 60_000,
@@ -110,6 +128,9 @@ export function ResourceCombobox<T extends ComboboxOption>({
110
128
  return filterOption ? merged.filter(filterOption) : merged
111
129
  }, [searchData, hydratedData, filterOption])
112
130
 
131
+ const visibleSearchCount = searchData?.data.length ?? 0
132
+ const hiddenCount = Math.max(0, (searchData?.meta?.count ?? 0) - visibleSearchCount)
133
+
113
134
  return (
114
135
  <HeadlessResourceCombobox
115
136
  value={value}
@@ -120,7 +141,34 @@ export function ResourceCombobox<T extends ComboboxOption>({
120
141
  renderOption={renderOption}
121
142
  placeholder={placeholderLabel}
122
143
  emptyText={emptyLabel}
144
+ listFooter={
145
+ hiddenCount > 0 || action ? (
146
+ <>
147
+ {hiddenCount > 0 && (
148
+ <p className="border-t border-border px-2.5 py-2 text-muted-foreground text-xs">
149
+ {t('admin.common.combobox_more_results', { count: hiddenCount })}
150
+ </p>
151
+ )}
152
+ {action && (
153
+ // `onMouseDown` rather than `onClick`: the input blurs first and
154
+ // the dropdown unmounts, so a click never lands.
155
+ <button
156
+ type="button"
157
+ className="flex w-full cursor-pointer items-center gap-2 border-t border-border px-2.5 py-2 text-left text-sm hover:bg-accent"
158
+ onMouseDown={(event) => {
159
+ event.preventDefault()
160
+ action.onSelect()
161
+ }}
162
+ >
163
+ <PlusIcon className="size-4" />
164
+ {action.label}
165
+ </button>
166
+ )}
167
+ </>
168
+ ) : undefined
169
+ }
123
170
  disabled={disabled}
171
+ id={id}
124
172
  />
125
173
  )
126
174
  }
@@ -1,28 +1,33 @@
1
1
  import {
2
- Avatar,
3
- AvatarFallback,
4
- AvatarImage,
5
2
  Button,
6
3
  Checkbox,
7
- InputGroup,
8
- InputGroupAddon,
9
- InputGroupInput,
4
+ type PaginationMeta,
5
+ SearchInput,
10
6
  Sheet,
11
7
  SheetContent,
12
8
  SheetDescription,
13
9
  SheetFooter,
14
10
  SheetHeader,
15
11
  SheetTitle,
12
+ Thumbnail,
16
13
  } from '@spree/dashboard-ui'
17
- import { useQuery } from '@tanstack/react-query'
18
- import { ImageIcon, Loader2Icon, SearchIcon, XIcon } from 'lucide-react'
19
- import { useDeferredValue, useMemo, useState } from 'react'
14
+ import { Loader2Icon } from '@spree/dashboard-ui/icons'
15
+ import { useInfiniteQuery } from '@tanstack/react-query'
16
+ import { useDeferredValue, useMemo, useRef, useState } from 'react'
20
17
  import { useTranslation } from 'react-i18next'
21
18
 
22
19
  export interface PickerOption {
23
20
  id: string
24
21
  }
25
22
 
23
+ /** One page of async picker search results, optionally with list pagination metadata. */
24
+ export interface ResourcePickerSearchResult<T extends PickerOption> {
25
+ /** Rows returned for the requested page. */
26
+ data: T[]
27
+ /** Total match count and page cursors when the search is paginated. */
28
+ meta?: PaginationMeta
29
+ }
30
+
26
31
  export interface ResourcePickerSheetProps<T extends PickerOption> {
27
32
  open: boolean
28
33
  onOpenChange: (open: boolean) => void
@@ -30,8 +35,8 @@ export interface ResourcePickerSheetProps<T extends PickerOption> {
30
35
  selectedIds: string[]
31
36
  /** Called with the freshly-picked IDs (excludes `selectedIds`) when the user confirms. */
32
37
  onConfirm: (ids: string[], options: T[]) => void | Promise<void>
33
- /** Paginated async search. Called with the trimmed query (empty = initial list). */
34
- search: (query: string) => Promise<{ data: T[] }>
38
+ /** Paginated async search. Called with the trimmed query (empty = initial list) and page. */
39
+ search: (query: string, page: number) => Promise<ResourcePickerSearchResult<T>>
35
40
  getOptionLabel: (option: T) => string
36
41
  /** Optional thumbnail URL shown as an avatar. */
37
42
  getOptionImageUrl?: (option: T) => string | null | undefined
@@ -79,20 +84,61 @@ export function ResourcePickerSheet<T extends PickerOption>({
79
84
  const deferredInput = useDeferredValue(input)
80
85
  const trimmedQuery = deferredInput.trim()
81
86
 
87
+ const [selectAllMeta, setSelectAllMeta] = useState<PaginationMeta | undefined>()
88
+ const selectAllGenerationRef = useRef(0)
89
+ const selectAllSessionRef = useRef({ query: trimmedQuery, open })
90
+
91
+ if (
92
+ selectAllSessionRef.current.query !== trimmedQuery ||
93
+ (selectAllSessionRef.current.open && !open)
94
+ ) {
95
+ selectAllGenerationRef.current += 1
96
+ selectAllSessionRef.current = { query: trimmedQuery, open }
97
+ }
98
+
82
99
  // Staging selection (ids picked in this session) + a label cache so the
83
100
  // "selected" chips keep their text even after the search results change.
84
101
  const [staged, setStaged] = useState<Map<string, T>>(new Map())
85
102
  const alreadyIn = useMemo(() => new Set(selectedIds), [selectedIds])
86
103
  const [submitting, setSubmitting] = useState(false)
104
+ const [selectingAll, setSelectingAll] = useState(false)
87
105
 
88
- const { data, isFetching } = useQuery({
106
+ // The accumulated pages live in the query cache rather than in local state.
107
+ // Mirroring them into state via an effect broke reopening the sheet: the
108
+ // cached page came back reference-identical, so the effect that filled the
109
+ // local list never re-ran and the sheet rendered "no results" over a cache
110
+ // that held plenty.
111
+ const { data, isFetching, isFetchingNextPage, fetchNextPage } = useInfiniteQuery({
89
112
  queryKey: [queryKey, 'picker-search', trimmedQuery],
90
- queryFn: () => search(trimmedQuery),
113
+ queryFn: ({ pageParam }) => search(trimmedQuery, pageParam),
114
+ initialPageParam: 1,
115
+ getNextPageParam: (lastPage, allPages) =>
116
+ lastPage.meta?.next != null ? allPages.length + 1 : undefined,
91
117
  enabled: open,
92
118
  staleTime: 30_000,
93
119
  })
94
120
 
95
- const results = data?.data ?? []
121
+ const results = useMemo(() => {
122
+ const seen = new Set<string>()
123
+ const flattened: T[] = []
124
+ for (const loadedPage of data?.pages ?? []) {
125
+ for (const option of loadedPage.data) {
126
+ if (seen.has(option.id)) continue
127
+ seen.add(option.id)
128
+ flattened.push(option)
129
+ }
130
+ }
131
+ return flattened
132
+ }, [data])
133
+
134
+ const lastPage = data?.pages.at(-1)
135
+ const meta = selectAllMeta ?? lastPage?.meta
136
+ const hasMore = lastPage?.meta?.next != null
137
+ const matchCount = meta?.count ?? results.length
138
+ const currentPageResults = lastPage?.data ?? []
139
+ const selectableOnPage = currentPageResults.filter((option) => !alreadyIn.has(option.id))
140
+ const allOnPageSelected =
141
+ selectableOnPage.length > 0 && selectableOnPage.every((option) => staged.has(option.id))
96
142
 
97
143
  function toggle(option: T) {
98
144
  if (alreadyIn.has(option.id)) return
@@ -104,6 +150,53 @@ export function ResourcePickerSheet<T extends PickerOption>({
104
150
  })
105
151
  }
106
152
 
153
+ function togglePageSelection() {
154
+ setStaged((prev) => {
155
+ const next = new Map(prev)
156
+ if (allOnPageSelected) {
157
+ for (const option of selectableOnPage) next.delete(option.id)
158
+ } else {
159
+ for (const option of selectableOnPage) next.set(option.id, option)
160
+ }
161
+ return next
162
+ })
163
+ }
164
+
165
+ async function selectAllMatching() {
166
+ if (matchCount === 0 || selectingAll) return
167
+ const generation = selectAllGenerationRef.current
168
+ setSelectingAll(true)
169
+ try {
170
+ const collected = new Map<string, T>()
171
+ let nextPage = 1
172
+ let lastMeta: PaginationMeta | undefined
173
+
174
+ while (true) {
175
+ const response = await search(trimmedQuery, nextPage)
176
+ if (generation !== selectAllGenerationRef.current) return
177
+ lastMeta = response.meta
178
+ for (const option of response.data) {
179
+ if (!alreadyIn.has(option.id)) collected.set(option.id, option)
180
+ }
181
+ if (!response.meta || nextPage >= response.meta.pages) break
182
+ nextPage += 1
183
+ }
184
+
185
+ if (generation !== selectAllGenerationRef.current) return
186
+
187
+ setStaged((prev) => {
188
+ const next = new Map(prev)
189
+ for (const [id, option] of collected) next.set(id, option)
190
+ return next
191
+ })
192
+ if (lastMeta) setSelectAllMeta(lastMeta)
193
+ } catch (error) {
194
+ onConfirmError?.(error)
195
+ } finally {
196
+ setSelectingAll(false)
197
+ }
198
+ }
199
+
107
200
  async function confirm() {
108
201
  if (staged.size === 0) return
109
202
  setSubmitting(true)
@@ -111,6 +204,7 @@ export function ResourcePickerSheet<T extends PickerOption>({
111
204
  await onConfirm(Array.from(staged.keys()), Array.from(staged.values()))
112
205
  setStaged(new Map())
113
206
  setInput('')
207
+ setSelectAllMeta(undefined)
114
208
  onOpenChange(false)
115
209
  } catch (error) {
116
210
  // The mutation reports its own error toast; swallow so the click handler
@@ -124,12 +218,17 @@ export function ResourcePickerSheet<T extends PickerOption>({
124
218
 
125
219
  function handleOpenChange(next: boolean) {
126
220
  if (!next) {
221
+ selectAllGenerationRef.current += 1
127
222
  setStaged(new Map())
128
223
  setInput('')
224
+ setSelectAllMeta(undefined)
129
225
  }
130
226
  onOpenChange(next)
131
227
  }
132
228
 
229
+ const initialLoading = isFetching && results.length === 0
230
+ const loadingMore = isFetchingNextPage
231
+
133
232
  return (
134
233
  <Sheet open={open} onOpenChange={handleOpenChange}>
135
234
  <SheetContent className="w-full gap-0 p-0 sm:max-w-lg">
@@ -138,20 +237,18 @@ export function ResourcePickerSheet<T extends PickerOption>({
138
237
  {description && <SheetDescription>{description}</SheetDescription>}
139
238
  </SheetHeader>
140
239
 
141
- <div className="border-b border-border p-4">
142
- <InputGroup>
143
- <InputGroupAddon>
144
- <SearchIcon className="size-4 text-muted-foreground" />
145
- </InputGroupAddon>
146
- <InputGroupInput
147
- type="search"
148
- value={input}
149
- onChange={(e) => setInput(e.target.value)}
150
- // Enter has no action here; swallow it so it can't submit a host form.
151
- onKeyDown={(e) => e.key === 'Enter' && e.preventDefault()}
152
- placeholder={searchPlaceholder ?? t('admin.resource_picker.search_placeholder')}
153
- />
154
- </InputGroup>
240
+ <div className="border-b border-border-subtle p-4">
241
+ <SearchInput
242
+ value={input}
243
+ onValueChange={(value) => {
244
+ setInput(value)
245
+ setSelectAllMeta(undefined)
246
+ }}
247
+ // Enter has no action here; swallow it so it can't submit a host form.
248
+ onKeyDown={(e) => e.key === 'Enter' && e.preventDefault()}
249
+ placeholder={searchPlaceholder ?? t('admin.resource_picker.search_placeholder')}
250
+ clearLabel={t('admin.common.clear')}
251
+ />
155
252
  {staged.size > 0 && (
156
253
  <p className="mt-2 text-muted-foreground text-xs">
157
254
  {t('admin.resource_picker.selected_count', { count: staged.size })}
@@ -160,7 +257,7 @@ export function ResourcePickerSheet<T extends PickerOption>({
160
257
  </div>
161
258
 
162
259
  <div className="min-h-0 flex-1 overflow-y-auto">
163
- {isFetching && results.length === 0 ? (
260
+ {initialLoading ? (
164
261
  <div className="flex items-center justify-center py-10 text-muted-foreground">
165
262
  <Loader2Icon className="size-5 animate-spin" />
166
263
  </div>
@@ -169,59 +266,98 @@ export function ResourcePickerSheet<T extends PickerOption>({
169
266
  {t('admin.resource_picker.empty')}
170
267
  </p>
171
268
  ) : (
172
- <ul className="divide-y divide-border">
173
- {results.map((option) => {
174
- const inList = alreadyIn.has(option.id)
175
- const checked = inList || staged.has(option.id)
176
- const imageUrl = getOptionImageUrl?.(option)
177
- const subtitle = getOptionSubtitle?.(option)
178
- return (
179
- <li key={option.id}>
180
- <button
269
+ <>
270
+ {matchCount > 0 && (
271
+ <div className="flex items-center justify-between gap-3 border-b border-border-subtle px-4 py-2">
272
+ <button
273
+ type="button"
274
+ disabled={selectableOnPage.length === 0 || selectingAll}
275
+ onClick={togglePageSelection}
276
+ className="flex min-w-0 flex-1 items-center gap-3 text-left hover:bg-accent/50 disabled:cursor-not-allowed disabled:opacity-60"
277
+ >
278
+ <Checkbox
279
+ checked={allOnPageSelected}
280
+ disabled={selectableOnPage.length === 0 || selectingAll}
281
+ className="pointer-events-none"
282
+ />
283
+ <span className="truncate text-sm">
284
+ {t('admin.resource_picker.select_page', { count: selectableOnPage.length })}
285
+ </span>
286
+ </button>
287
+ {matchCount > 0 &&
288
+ (hasMore || !allOnPageSelected || matchCount > selectableOnPage.length) ? (
289
+ <Button
181
290
  type="button"
182
- disabled={inList}
183
- onClick={() => toggle(option)}
184
- className="flex w-full items-center gap-3 px-4 py-2.5 text-left hover:bg-muted/50 disabled:cursor-not-allowed disabled:opacity-60"
291
+ variant="link"
292
+ size="sm"
293
+ className="h-auto shrink-0 px-0"
294
+ disabled={selectingAll}
295
+ onClick={selectAllMatching}
185
296
  >
186
- <Checkbox
187
- checked={checked}
297
+ {selectingAll && <Loader2Icon className="size-3.5 animate-spin" />}
298
+ {t('admin.resource_picker.select_all_matching', { count: matchCount })}
299
+ </Button>
300
+ ) : null}
301
+ </div>
302
+ )}
303
+ <ul className="divide-y divide-border">
304
+ {results.map((option) => {
305
+ const inList = alreadyIn.has(option.id)
306
+ const checked = inList || staged.has(option.id)
307
+ const imageUrl = getOptionImageUrl?.(option)
308
+ const subtitle = getOptionSubtitle?.(option)
309
+ return (
310
+ <li key={option.id}>
311
+ <button
312
+ type="button"
188
313
  disabled={inList}
189
- className="pointer-events-none"
190
- />
191
- {getOptionImageUrl && (
192
- <Avatar className="size-9 rounded-md">
193
- {imageUrl ? (
194
- <AvatarImage src={imageUrl} alt="" className="object-cover" />
195
- ) : null}
196
- <AvatarFallback className="rounded-md bg-muted">
197
- <ImageIcon className="size-4 text-muted-foreground" />
198
- </AvatarFallback>
199
- </Avatar>
200
- )}
201
- <span className="min-w-0 flex-1">
202
- <span className="block truncate text-sm">{getOptionLabel(option)}</span>
203
- {subtitle && (
204
- <span className="block truncate text-muted-foreground text-xs">
205
- {subtitle}
314
+ onClick={() => toggle(option)}
315
+ className="flex w-full items-center gap-3 px-4 py-2.5 text-left hover:bg-accent/50 disabled:cursor-not-allowed disabled:opacity-60"
316
+ >
317
+ <Checkbox
318
+ checked={checked}
319
+ disabled={inList}
320
+ className="pointer-events-none"
321
+ />
322
+ {getOptionImageUrl && <Thumbnail src={imageUrl} size="sm" />}
323
+ <span className="min-w-0 flex-1">
324
+ <span className="block truncate text-sm">{getOptionLabel(option)}</span>
325
+ {subtitle && (
326
+ <span className="block truncate text-muted-foreground text-xs">
327
+ {subtitle}
328
+ </span>
329
+ )}
330
+ </span>
331
+ {inList && (
332
+ <span className="shrink-0 text-muted-foreground text-xs">
333
+ {t('admin.resource_picker.already_added')}
206
334
  </span>
207
335
  )}
208
- </span>
209
- {inList && (
210
- <span className="shrink-0 text-muted-foreground text-xs">
211
- {t('admin.resource_picker.already_added')}
212
- </span>
213
- )}
214
- </button>
215
- </li>
216
- )
217
- })}
218
- </ul>
336
+ </button>
337
+ </li>
338
+ )
339
+ })}
340
+ </ul>
341
+ {hasMore && (
342
+ <div className="border-t border-border p-4">
343
+ <Button
344
+ type="button"
345
+ variant="outline"
346
+ className="w-full"
347
+ disabled={loadingMore}
348
+ onClick={() => fetchNextPage()}
349
+ >
350
+ {loadingMore && <Loader2Icon className="size-4 animate-spin" />}
351
+ {t('admin.resource_picker.load_more')}
352
+ </Button>
353
+ </div>
354
+ )}
355
+ </>
219
356
  )}
220
357
  </div>
221
358
 
222
359
  <SheetFooter className="flex-row items-center justify-between border-t border-border">
223
- <Button type="button" variant="ghost" onClick={() => handleOpenChange(false)}>
224
- <XIcon className="size-4" />
360
+ <Button type="button" variant="outline" onClick={() => handleOpenChange(false)}>
225
361
  {t('admin.actions.cancel')}
226
362
  </Button>
227
363
  <Button type="button" onClick={confirm} disabled={staged.size === 0 || submitting}>