@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,322 @@
1
+ import {
2
+ Badge,
3
+ Button,
4
+ CopyToClipboardButton,
5
+ cn,
6
+ Sheet,
7
+ SheetContent,
8
+ SheetDescription,
9
+ SheetTitle,
10
+ Tabs,
11
+ TabsContent,
12
+ TabsList,
13
+ TabsTrigger,
14
+ } from '@spree/dashboard-ui'
15
+ import { BugIcon, InfoIcon, Trash2Icon, XIcon } from '@spree/dashboard-ui/icons'
16
+ // Deep import keeps the JSON viewer out of the dashboard-ui barrel's chunk.
17
+ import { JsonValueView } from '@spree/dashboard-ui/spree/json-value-view'
18
+ import { Link } from '@tanstack/react-router'
19
+ import { memo, useMemo, useState } from 'react'
20
+ import { useTranslation } from 'react-i18next'
21
+ import {
22
+ clearDeveloperInspector,
23
+ type DeveloperInspectorEntry,
24
+ isFailedEntry,
25
+ } from '../lib/developer-inspector'
26
+ import { entryToCurl, entryToSdkCall } from '../lib/request-snippets'
27
+ import {
28
+ useDeveloperInspectorEntries,
29
+ useOptionalDeveloperInspector,
30
+ } from '../providers/developer-inspector-provider'
31
+ import { useJsonLinkResolver } from '../providers/json-link-resolver-provider'
32
+
33
+ export interface DeveloperInspectorDrawerProps {
34
+ open: boolean
35
+ onOpenChange: (open: boolean) => void
36
+ systemInfoPath?: string
37
+ }
38
+
39
+ export function DeveloperInspectorDrawer({
40
+ open,
41
+ onOpenChange,
42
+ systemInfoPath,
43
+ }: DeveloperInspectorDrawerProps) {
44
+ const { t } = useTranslation()
45
+ const entries = useDeveloperInspectorEntries()
46
+ const errors = entries.filter(isFailedEntry)
47
+ const sendFeedback = useOptionalDeveloperInspector()?.sendFeedback
48
+ const [selectedId, setSelectedId] = useState<number | null>(null)
49
+
50
+ return (
51
+ <Sheet open={open} onOpenChange={onOpenChange}>
52
+ <SheetContent
53
+ side="bottom"
54
+ showCloseButton={false}
55
+ // Always dark, like browser devtools. The height uses the side variant
56
+ // so it replaces the sheet's own `data-[side=bottom]:h-auto`.
57
+ className="dark overflow-hidden rounded-t-2xl data-[side=bottom]:h-[min(75dvh,720px)]"
58
+ >
59
+ <div className="flex shrink-0 items-start gap-3 border-b border-border-subtle px-4 py-3">
60
+ <div className="min-w-0 flex-1">
61
+ <SheetTitle>{t('admin.components.developer_inspector.title')}</SheetTitle>
62
+ <SheetDescription className="text-xs">
63
+ {t('admin.components.developer_inspector.description')}
64
+ </SheetDescription>
65
+ </div>
66
+ {sendFeedback && (
67
+ <Button
68
+ variant="ghost"
69
+ size="sm"
70
+ onClick={() => {
71
+ onOpenChange(false)
72
+ sendFeedback('bug')
73
+ }}
74
+ >
75
+ <BugIcon />
76
+ {t('admin.account.report_issue')}
77
+ </Button>
78
+ )}
79
+ {systemInfoPath && (
80
+ <Button variant="ghost" size="sm" asChild>
81
+ <Link to={systemInfoPath as never} onClick={() => onOpenChange(false)}>
82
+ <InfoIcon />
83
+ {t('admin.components.developer_inspector.system_info')}
84
+ </Link>
85
+ </Button>
86
+ )}
87
+ <Button
88
+ variant="ghost"
89
+ size="sm"
90
+ onClick={() => {
91
+ clearDeveloperInspector()
92
+ setSelectedId(null)
93
+ }}
94
+ disabled={entries.length === 0}
95
+ >
96
+ <Trash2Icon />
97
+ {t('admin.components.developer_inspector.clear')}
98
+ </Button>
99
+ <Button
100
+ variant="ghost"
101
+ size="icon-sm"
102
+ onClick={() => onOpenChange(false)}
103
+ aria-label={t('admin.actions.close')}
104
+ >
105
+ <XIcon />
106
+ </Button>
107
+ </div>
108
+
109
+ <Tabs defaultValue="requests" className="min-h-0 flex-1 gap-0">
110
+ <div className="shrink-0 px-4 pt-3">
111
+ <TabsList>
112
+ <TabsTrigger value="requests" count={entries.length}>
113
+ {t('admin.components.developer_inspector.tabs.requests')}
114
+ </TabsTrigger>
115
+ <TabsTrigger value="errors" count={errors.length}>
116
+ {t('admin.components.developer_inspector.tabs.errors')}
117
+ </TabsTrigger>
118
+ </TabsList>
119
+ </div>
120
+ <TabsContent value="requests" className="min-h-0 overflow-hidden">
121
+ <RequestsPanel
122
+ entries={entries}
123
+ selectedId={selectedId}
124
+ onSelect={setSelectedId}
125
+ emptyLabel={t('admin.components.developer_inspector.empty_requests')}
126
+ />
127
+ </TabsContent>
128
+ <TabsContent value="errors" className="min-h-0 overflow-hidden">
129
+ <RequestsPanel
130
+ entries={errors}
131
+ selectedId={selectedId}
132
+ onSelect={setSelectedId}
133
+ emptyLabel={t('admin.components.developer_inspector.empty_errors')}
134
+ showErrorCode
135
+ />
136
+ </TabsContent>
137
+ </Tabs>
138
+ </SheetContent>
139
+ </Sheet>
140
+ )
141
+ }
142
+
143
+ function RequestsPanel({
144
+ entries,
145
+ selectedId,
146
+ onSelect,
147
+ emptyLabel,
148
+ showErrorCode = false,
149
+ }: {
150
+ entries: DeveloperInspectorEntry[]
151
+ selectedId: number | null
152
+ onSelect: (id: number) => void
153
+ emptyLabel: string
154
+ showErrorCode?: boolean
155
+ }) {
156
+ const { t } = useTranslation()
157
+ const selected = entries.find((entry) => entry.id === selectedId)
158
+
159
+ if (entries.length === 0) {
160
+ return <p className="p-4 text-muted-foreground">{emptyLabel}</p>
161
+ }
162
+
163
+ return (
164
+ <div className="grid h-full min-h-0 grid-rows-[minmax(0,1fr)_minmax(0,1fr)] md:grid-cols-[minmax(0,2fr)_minmax(0,3fr)] md:grid-rows-1">
165
+ <ul className="themed-scrollbar min-h-0 overflow-auto border-border-subtle border-b p-2 md:border-r md:border-b-0">
166
+ {entries.map((entry) => (
167
+ <li key={entry.id}>
168
+ <button
169
+ type="button"
170
+ onClick={() => onSelect(entry.id)}
171
+ aria-current={entry.id === selectedId || undefined}
172
+ className={cn(
173
+ 'grid w-full grid-cols-[3.5rem_3rem_minmax(0,1fr)_auto] items-center gap-2 rounded-md px-2 py-1.5 text-left font-mono text-xs transition-colors duration-100 hover:bg-accent',
174
+ entry.id === selectedId && 'bg-accent',
175
+ )}
176
+ >
177
+ <span className="font-medium">{entry.method}</span>
178
+ <StatusBadge entry={entry} />
179
+ <span className="min-w-0">
180
+ <span className="block truncate">{entry.path}</span>
181
+ <span className="block truncate text-muted-foreground">
182
+ {showErrorCode && entry.errorCode ? `${entry.errorCode} · ` : ''}
183
+ {entry.requestId ?? '—'}
184
+ </span>
185
+ </span>
186
+ <span className="text-muted-foreground tabular-nums">
187
+ {t('admin.components.developer_inspector.duration_ms', { value: entry.durationMs })}
188
+ </span>
189
+ </button>
190
+ </li>
191
+ ))}
192
+ </ul>
193
+ <div className="themed-scrollbar min-h-0 overflow-auto p-4">
194
+ {selected ? (
195
+ <RequestDetail entry={selected} />
196
+ ) : (
197
+ <p className="text-muted-foreground">
198
+ {t('admin.components.developer_inspector.select_request')}
199
+ </p>
200
+ )}
201
+ </div>
202
+ </div>
203
+ )
204
+ }
205
+
206
+ function StatusBadge({ entry }: { entry: DeveloperInspectorEntry }) {
207
+ const { t } = useTranslation()
208
+ if (entry.status === null) {
209
+ return (
210
+ <Badge variant="destructive">{t('admin.components.developer_inspector.network_error')}</Badge>
211
+ )
212
+ }
213
+ const variant = entry.status >= 400 ? 'destructive' : 'success'
214
+ return <Badge variant={variant}>{entry.status}</Badge>
215
+ }
216
+
217
+ // Memoised: the drawer re-renders on every recorded request, and an entry
218
+ // never changes once recorded.
219
+ const RequestDetail = memo(function RequestDetail({ entry }: { entry: DeveloperInspectorEntry }) {
220
+ const { t } = useTranslation()
221
+ const copied = t('admin.components.developer_inspector.copied')
222
+ const hasQuery = !!entry.query && Object.keys(entry.query).length > 0
223
+ const copies = useMemo(() => {
224
+ const candidates: [string, string | null][] = [
225
+ ['copy_curl', entryToCurl(entry)],
226
+ ['copy_sdk', entryToSdkCall(entry)],
227
+ [
228
+ 'copy_response',
229
+ entry.responseBody === undefined ? null : JSON.stringify(entry.responseBody, null, 2),
230
+ ],
231
+ ['copy_request_id', entry.requestId],
232
+ ]
233
+ return candidates.flatMap(([key, value]) => (value == null ? [] : [{ key, value }]))
234
+ }, [entry])
235
+
236
+ return (
237
+ <div className="flex flex-col gap-4">
238
+ <p className="break-all font-mono text-xs">
239
+ <span className="font-medium">{entry.method}</span> {entry.url}
240
+ </p>
241
+
242
+ <dl className="grid grid-cols-[auto_minmax(0,1fr)] gap-x-4 gap-y-1 text-xs">
243
+ <dt className="text-muted-foreground">
244
+ {t('admin.components.developer_inspector.status')}
245
+ </dt>
246
+ <dd>
247
+ <StatusBadge entry={entry} />
248
+ </dd>
249
+ <dt className="text-muted-foreground">
250
+ {t('admin.components.developer_inspector.duration')}
251
+ </dt>
252
+ <dd className="tabular-nums">
253
+ {t('admin.components.developer_inspector.duration_ms', { value: entry.durationMs })}
254
+ </dd>
255
+ <dt className="text-muted-foreground">
256
+ {t('admin.components.developer_inspector.request_id')}
257
+ </dt>
258
+ <dd className="break-all font-mono" data-testid="developer-inspector-request-id">
259
+ {entry.requestId ?? '—'}
260
+ </dd>
261
+ {entry.errorCode && (
262
+ <>
263
+ <dt className="text-muted-foreground">
264
+ {t('admin.components.developer_inspector.error_code')}
265
+ </dt>
266
+ <dd className="break-all font-mono">
267
+ {entry.errorCode}
268
+ {entry.errorMessage && (
269
+ <span className="block font-sans text-muted-foreground">{entry.errorMessage}</span>
270
+ )}
271
+ </dd>
272
+ </>
273
+ )}
274
+ </dl>
275
+
276
+ <div className="flex flex-wrap gap-2">
277
+ {copies.map(({ key, value }) => (
278
+ <CopyToClipboardButton
279
+ key={key}
280
+ value={value}
281
+ aria-label={t(`admin.components.developer_inspector.${key}`)}
282
+ label={t(`admin.components.developer_inspector.${key}`)}
283
+ copiedLabel={copied}
284
+ size="sm"
285
+ variant="outline"
286
+ />
287
+ ))}
288
+ </div>
289
+
290
+ {hasQuery && (
291
+ <BodySection title={t('admin.components.developer_inspector.query')} value={entry.query} />
292
+ )}
293
+ {entry.requestBody !== undefined && entry.requestBody !== null && (
294
+ <BodySection
295
+ title={t('admin.components.developer_inspector.request_body')}
296
+ value={entry.requestBody}
297
+ />
298
+ )}
299
+ <BodySection
300
+ title={t('admin.components.developer_inspector.response_body')}
301
+ value={entry.responseBody}
302
+ />
303
+ </div>
304
+ )
305
+ })
306
+
307
+ function BodySection({ title, value }: { title: string; value: unknown }) {
308
+ const { t } = useTranslation()
309
+ const resolveLink = useJsonLinkResolver()
310
+ return (
311
+ <section className="flex flex-col gap-1.5">
312
+ <h3 className="font-medium text-muted-foreground text-xs">{title}</h3>
313
+ {value !== null && typeof value === 'object' ? (
314
+ <JsonValueView value={value} collapsed={1} resolveLink={resolveLink} />
315
+ ) : (
316
+ <p className="rounded-md bg-zinc-950 p-3 font-mono text-xs text-zinc-100">
317
+ {value === undefined ? t('admin.components.developer_inspector.no_body') : String(value)}
318
+ </p>
319
+ )}
320
+ </section>
321
+ )
322
+ }
@@ -12,8 +12,8 @@ import {
12
12
  FieldLabel,
13
13
  toastManager,
14
14
  } from '@spree/dashboard-ui'
15
- import { FileIcon, UploadCloudIcon, XIcon } from '@spree/dashboard-ui/icons'
16
- import { type ReactNode, useState } from 'react'
15
+ import { FileIcon, UploadCloudIcon, UploadIcon, XIcon } from '@spree/dashboard-ui/icons'
16
+ import { type ReactNode, useId, useRef, useState } from 'react'
17
17
  import { useTranslation } from 'react-i18next'
18
18
  import { useFileUpload } from '../hooks/use-file-upload'
19
19
 
@@ -203,46 +203,65 @@ export function FileUploadField({
203
203
  }
204
204
  : null
205
205
 
206
+ const inputRef = useRef<HTMLInputElement>(null)
207
+ const inputId = useId()
208
+ const input = (
209
+ <input
210
+ ref={inputRef}
211
+ id={inputId}
212
+ type="file"
213
+ accept={accept}
214
+ className="sr-only"
215
+ // With a file attached, Replace is the keyboard's way in; the bare
216
+ // input would be a second, unlabelled tab stop.
217
+ tabIndex={attached ? -1 : undefined}
218
+ disabled={disabled || !!pending}
219
+ onChange={(e) => {
220
+ const picked = e.target.files?.[0]
221
+ if (picked) void handlePicked(picked)
222
+ e.target.value = ''
223
+ }}
224
+ />
225
+ )
226
+
206
227
  return (
207
228
  <Field>
208
229
  {label && <FieldLabel>{label}</FieldLabel>}
209
230
  <div className="flex flex-col gap-2">
210
- {/* A <label> wrapping the file input: clicking anywhere opens the
211
- picker natively. The input is sr-only (not display:none) so it
212
- stays in the tab order — keyboard users Tab to it and press
213
- Enter/Space; the label paints a focus ring via has-[:focus-visible]. */}
214
- <label
215
- className={cn(
216
- 'flex cursor-pointer flex-col items-center justify-center gap-2 rounded-md border border-border border-dashed px-4 py-8 text-center transition-colors hover:bg-accent/50',
217
- 'has-[:focus-visible]:border-ring has-[:focus-visible]:ring-[3px] has-[:focus-visible]:ring-ring/50',
218
- (disabled || !!pending) && 'pointer-events-none opacity-60',
219
- )}
220
- onDragOver={(e) => e.preventDefault()}
221
- onDrop={(e) => {
222
- e.preventDefault()
223
- const dropped = e.dataTransfer.files?.[0]
224
- if (dropped) void handlePicked(dropped)
225
- }}
226
- >
227
- <UploadCloudIcon className="size-6 text-muted-foreground" />
228
- <span className="text-muted-foreground text-sm">
229
- {dropLabel ?? t('admin.components.file_upload.drop_label')}
230
- </span>
231
- <span className="mt-1 inline-flex h-8 items-center rounded-md border border-border bg-background px-3 font-medium text-sm hover:bg-accent">
232
- {browseLabel ?? t('admin.components.file_upload.browse')}
233
- </span>
234
- <input
235
- type="file"
236
- accept={accept}
237
- className="sr-only"
238
- disabled={disabled || !!pending}
239
- onChange={(e) => {
240
- const picked = e.target.files?.[0]
241
- if (picked) void handlePicked(picked)
242
- e.target.value = ''
231
+ {/* With a file attached the row below is the whole field — Replace
232
+ and Remove act on it — so the drop zone only shows while empty.
233
+ The input itself always renders, so Replace has something to open. */}
234
+ {attached ? (
235
+ input
236
+ ) : (
237
+ // A <label> wrapping the file input: clicking anywhere opens the
238
+ // picker natively. The input is sr-only (not display:none) so it
239
+ // stays in the tab order — keyboard users Tab to it and press
240
+ // Enter/Space; the label paints a focus ring via has-[:focus-visible].
241
+ <label
242
+ htmlFor={inputId}
243
+ className={cn(
244
+ 'flex cursor-pointer flex-col items-center justify-center gap-2 rounded-md border border-border border-dashed px-4 py-8 text-center transition-colors hover:bg-accent/50',
245
+ 'has-[:focus-visible]:border-ring has-[:focus-visible]:ring-[3px] has-[:focus-visible]:ring-ring/50',
246
+ disabled && 'pointer-events-none opacity-60',
247
+ )}
248
+ onDragOver={(e) => e.preventDefault()}
249
+ onDrop={(e) => {
250
+ e.preventDefault()
251
+ const dropped = e.dataTransfer.files?.[0]
252
+ if (dropped) void handlePicked(dropped)
243
253
  }}
244
- />
245
- </label>
254
+ >
255
+ <UploadCloudIcon className="size-6 text-muted-foreground" />
256
+ <span className="text-muted-foreground text-base">
257
+ {dropLabel ?? t('admin.components.file_upload.drop_label')}
258
+ </span>
259
+ <span className="mt-1 inline-flex h-8 items-center rounded-md border border-border bg-background px-3 font-medium text-base hover:bg-accent">
260
+ {browseLabel ?? t('admin.components.file_upload.browse')}
261
+ </span>
262
+ {input}
263
+ </label>
264
+ )}
246
265
 
247
266
  {attached && (
248
267
  <Attachment className="w-full" state={attached.state}>
@@ -269,6 +288,14 @@ export function FileUploadField({
269
288
  </AttachmentDescription>
270
289
  </AttachmentContent>
271
290
  <AttachmentActions>
291
+ <AttachmentAction
292
+ type="button"
293
+ aria-label={t('admin.components.file_upload.replace')}
294
+ onClick={() => inputRef.current?.click()}
295
+ disabled={disabled || !!pending}
296
+ >
297
+ <UploadIcon />
298
+ </AttachmentAction>
272
299
  <AttachmentAction
273
300
  type="button"
274
301
  aria-label={t('admin.actions.remove')}
@@ -17,6 +17,8 @@ export interface ImageUploadFieldProps {
17
17
  /** Accepted MIME types. Defaults to PNG/JPEG/WebP. */
18
18
  accept?: string
19
19
  disabled?: boolean
20
+ /** Called with true while a picked image uploads; see `FileUploadField`. */
21
+ onUploadingChange?: (uploading: boolean) => void
20
22
  }
21
23
 
22
24
  /**
@@ -32,6 +34,7 @@ export function ImageUploadField({
32
34
  square = false,
33
35
  accept = 'image/png,image/jpeg,image/webp',
34
36
  disabled = false,
37
+ onUploadingChange,
35
38
  }: ImageUploadFieldProps) {
36
39
  const { t } = useTranslation()
37
40
 
@@ -45,6 +48,7 @@ export function ImageUploadField({
45
48
  label={label}
46
49
  help={help}
47
50
  disabled={disabled}
51
+ onUploadingChange={onUploadingChange}
48
52
  dropLabel={t('admin.components.file_upload.drop_image_label')}
49
53
  browseLabel={t('admin.image_upload.upload_cta')}
50
54
  mediaClassName={square ? 'size-16' : 'h-16 w-28'}
@@ -195,7 +195,7 @@ function ImportWizard({
195
195
  {imp && (
196
196
  <>
197
197
  <span aria-hidden className="h-4 w-px shrink-0 bg-border" />
198
- <span className="truncate text-muted-foreground text-sm">
198
+ <span className="truncate text-muted-foreground text-base">
199
199
  {`${importTypeLabel(imp.type)} · ${imp.number}`}
200
200
  </span>
201
201
  </>
@@ -243,7 +243,7 @@ function ImportWizard({
243
243
  {isError ? (
244
244
  <div className="flex flex-col items-center gap-3 py-12 text-center">
245
245
  <AlertTriangleIcon className="size-8 text-destructive" />
246
- <p className="text-muted-foreground text-sm">{t('admin.imports.load_failed')}</p>
246
+ <p className="text-muted-foreground text-base">{t('admin.imports.load_failed')}</p>
247
247
  <Button variant="outline" onClick={() => refetch()}>
248
248
  {t('admin.imports.try_again')}
249
249
  </Button>
@@ -279,7 +279,7 @@ function ImportWizard({
279
279
  button below the fold. */}
280
280
  {(isMapping || isCompleted) && imp && (
281
281
  <DialogFooter className="h-16 shrink-0 flex-row items-center justify-between border-border-subtle bg-background px-4 py-0 sm:justify-between">
282
- <span className="text-muted-foreground text-sm">
282
+ <span className="text-muted-foreground text-base">
283
283
  {isMapping && missingRequired.length > 0
284
284
  ? t('admin.imports.mapping.missing_required', {
285
285
  fields: missingRequired.map((f) => f.label).join(', '),
@@ -401,7 +401,7 @@ function MappingStep({
401
401
  <Card>
402
402
  <CardHeader>
403
403
  <CardTitle>{t('admin.imports.mapping.title')}</CardTitle>
404
- <p className="text-muted-foreground text-sm">
404
+ <p className="text-muted-foreground text-base">
405
405
  {t('admin.imports.mapping.description', { type: importTypeLabel(imp.type) })}
406
406
  </p>
407
407
  </CardHeader>
@@ -458,7 +458,7 @@ function MappingStep({
458
458
  </SelectContent>
459
459
  </Select>
460
460
  </TableCell>
461
- <TableCell className="max-w-48 truncate text-muted-foreground text-sm">
461
+ <TableCell className="max-w-48 truncate text-muted-foreground text-base">
462
462
  {sample}
463
463
  </TableCell>
464
464
  </TableRow>
@@ -503,7 +503,7 @@ function ProcessingCard({ imp, retryPass }: { imp: PanelImport; retryPass: boole
503
503
  {/* value={null} = Base UI's indeterminate state while rows are created */}
504
504
  <Progress value={preparing ? null : percent} />
505
505
 
506
- <p className="text-sm">
506
+ <p className="text-base">
507
507
  {preparing ? (
508
508
  t('admin.imports.processing.preparing')
509
509
  ) : retryPass && imp.failed_rows_count > 0 ? (
@@ -524,7 +524,7 @@ function ProcessingCard({ imp, retryPass }: { imp: PanelImport; retryPass: boole
524
524
  )}
525
525
  </p>
526
526
 
527
- <p className="text-muted-foreground text-sm">{t('admin.imports.processing.note')}</p>
527
+ <p className="text-muted-foreground text-base">{t('admin.imports.processing.note')}</p>
528
528
  </CardContent>
529
529
  </Card>
530
530
  )
@@ -542,7 +542,7 @@ function ResultsCard({ imp }: { imp: PanelImport }) {
542
542
  <CardContent className="flex flex-col items-center gap-3 py-8 text-center">
543
543
  <CheckCircle2Icon className="size-10 text-success" />
544
544
  <p className="font-medium">{t('admin.imports.results.completed_title')}</p>
545
- <p className="text-muted-foreground text-sm">
545
+ <p className="text-muted-foreground text-base">
546
546
  {t('admin.imports.results.summary', {
547
547
  completed: imp.completed_rows_count.toLocaleString(),
548
548
  failed: imp.failed_rows_count.toLocaleString(),
@@ -561,7 +561,7 @@ function FailedCard({ imp }: { imp: PanelImport }) {
561
561
  <CardContent className="flex flex-col items-center gap-3 py-8 text-center">
562
562
  <AlertTriangleIcon className="size-10 text-destructive" />
563
563
  <p className="font-medium">{t('admin.imports.failed.title')}</p>
564
- <p className="text-muted-foreground text-sm">{t('admin.imports.failed.description')}</p>
564
+ <p className="text-muted-foreground text-base">{t('admin.imports.failed.description')}</p>
565
565
  {imp.processing_errors && (
566
566
  <code className="max-w-full overflow-x-auto rounded-md bg-muted p-3 text-left text-xs">
567
567
  {imp.processing_errors}
@@ -609,7 +609,9 @@ function FailedRowsCard({ imp }: { imp: PanelImport }) {
609
609
  {isError ? (
610
610
  <div className="flex items-center gap-3 p-4">
611
611
  <AlertTriangleIcon className="size-4 shrink-0 text-destructive" />
612
- <span className="text-muted-foreground text-sm">{t('admin.imports.load_failed')}</span>
612
+ <span className="text-muted-foreground text-base">
613
+ {t('admin.imports.load_failed')}
614
+ </span>
613
615
  <Button variant="outline" size="sm" onClick={() => refetch()}>
614
616
  {t('admin.imports.try_again')}
615
617
  </Button>
@@ -651,7 +653,7 @@ function FailedRowsCard({ imp }: { imp: PanelImport }) {
651
653
  >
652
654
  <ChevronLeftIcon className="size-4" />
653
655
  </Button>
654
- <span className="text-muted-foreground text-sm">
656
+ <span className="text-muted-foreground text-base">
655
657
  {page} / {meta.pages}
656
658
  </span>
657
659
  <Button
@@ -680,7 +682,7 @@ function FailedRow({ row }: { row: PanelImportRow }) {
680
682
  <>
681
683
  <TableRow>
682
684
  <TableCell className="text-muted-foreground">{row.row_number}</TableCell>
683
- <TableCell className="text-destructive text-sm">
685
+ <TableCell className="text-destructive text-base">
684
686
  {String(row.validation_errors ?? '')}
685
687
  </TableCell>
686
688
  <TableCell className="text-right">