@asteby/metacore-runtime-react 34.0.1 → 34.1.0

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 (48) hide show
  1. package/CHANGELOG.md +12 -0
  2. package/dist/dialogs/dynamic-record.d.ts.map +1 -1
  3. package/dist/dialogs/dynamic-record.js +19 -2
  4. package/dist/dialogs/import.d.ts.map +1 -1
  5. package/dist/dialogs/import.js +6 -10
  6. package/dist/display-value.d.ts +23 -0
  7. package/dist/display-value.d.ts.map +1 -1
  8. package/dist/display-value.js +34 -1
  9. package/dist/dynamic-columns.d.ts +3 -0
  10. package/dist/dynamic-columns.d.ts.map +1 -1
  11. package/dist/dynamic-columns.js +36 -6
  12. package/dist/dynamic-select-field.d.ts.map +1 -1
  13. package/dist/dynamic-select-field.js +2 -6
  14. package/dist/dynamic-table.d.ts.map +1 -1
  15. package/dist/dynamic-table.js +10 -7
  16. package/dist/file-pick-button.d.ts +21 -0
  17. package/dist/file-pick-button.d.ts.map +1 -0
  18. package/dist/file-pick-button.js +39 -0
  19. package/dist/index.d.ts +3 -0
  20. package/dist/index.d.ts.map +1 -1
  21. package/dist/index.js +3 -0
  22. package/dist/metadata-cache.d.ts.map +1 -1
  23. package/dist/metadata-cache.js +8 -1
  24. package/dist/types.d.ts +1 -1
  25. package/dist/types.d.ts.map +1 -1
  26. package/dist/upload-field.d.ts.map +1 -1
  27. package/dist/upload-field.js +27 -32
  28. package/dist/use-debounced-value.d.ts +8 -0
  29. package/dist/use-debounced-value.d.ts.map +1 -0
  30. package/dist/use-debounced-value.js +15 -0
  31. package/dist/use-dynamic-filters.d.ts.map +1 -1
  32. package/dist/use-dynamic-filters.js +6 -4
  33. package/package.json +1 -1
  34. package/src/__tests__/image-stack.test.tsx +52 -0
  35. package/src/__tests__/use-debounced-value.test.ts +56 -0
  36. package/src/dialogs/dynamic-record.tsx +43 -1
  37. package/src/dialogs/import.tsx +9 -15
  38. package/src/display-value.tsx +82 -4
  39. package/src/dynamic-columns.tsx +79 -6
  40. package/src/dynamic-select-field.tsx +2 -6
  41. package/src/dynamic-table.tsx +9 -6
  42. package/src/file-pick-button.tsx +125 -0
  43. package/src/index.ts +11 -0
  44. package/src/metadata-cache.ts +8 -1
  45. package/src/types.ts +2 -0
  46. package/src/upload-field.tsx +41 -71
  47. package/src/use-debounced-value.ts +17 -0
  48. package/src/use-dynamic-filters.ts +6 -4
@@ -78,24 +78,102 @@ export const RelationThumbnail: React.FC<{
78
78
  size?: number
79
79
  }> = ({ src, alt, getImageUrl, size = 20 }) => (
80
80
  <Avatar
81
- className="shrink-0 rounded-md bg-muted/50 p-[3px] ring-1 ring-border/40"
81
+ className="shrink-0 rounded-md"
82
82
  style={{ width: size, height: size }}
83
83
  >
84
84
  {/* object-contain: brand logos are typically wide/rectangular, so
85
85
  object-cover (fills the square, cropping the sides) was cutting
86
- them off. contain lets the whole mark show, letterboxed by the
87
- padded neutral background above instead of clipped. */}
86
+ them off. contain shows the whole mark without a padded plate. */}
88
87
  <AvatarImage
89
88
  src={getImageUrl ? getImageUrl(src) : src}
90
89
  alt={alt}
91
90
  className="object-contain"
92
91
  />
93
- <AvatarFallback className="rounded-md bg-primary/10 text-[8px] font-bold text-primary">
92
+ <AvatarFallback className="rounded-md bg-transparent text-[8px] font-bold text-muted-foreground">
94
93
  {getInitials(alt)}
95
94
  </AvatarFallback>
96
95
  </Avatar>
97
96
  )
98
97
 
98
+ /**
99
+ * Landscape-friendly identity block: wide image ON TOP, label (and optional
100
+ * subtitle) UNDERNEATH. Use for brand logos, product photos and any mark that
101
+ * is wider than it is tall — a square thumb next to text crops logos and
102
+ * wastes the cell. Declared via `display: "image_stack"` on a column (image
103
+ * URL or FK relation with a logo/photo sibling).
104
+ *
105
+ * No padded card / muted plate behind the mark — that ate table row height.
106
+ * The image is sized with max-width/max-height and `object-contain` only.
107
+ *
108
+ * Sizes (CSS, not Tailwind arbitrary — host safelists vary):
109
+ * sm — compact table (~88×32)
110
+ * md — default table (~112×40)
111
+ * lg — detail/modal (~180×72)
112
+ */
113
+ export const ImageStack: React.FC<{
114
+ src?: string | null
115
+ label?: string | null
116
+ subtitle?: string | null
117
+ getImageUrl?: (path: string) => string
118
+ size?: 'sm' | 'md' | 'lg'
119
+ className?: string
120
+ }> = ({ src, label, subtitle, getImageUrl, size = 'md', className }) => {
121
+ const dims =
122
+ size === 'lg'
123
+ ? { w: 180, h: 72, text: 'text-sm' }
124
+ : size === 'sm'
125
+ ? { w: 88, h: 32, text: 'text-[11px]' }
126
+ : { w: 112, h: 40, text: 'text-xs' }
127
+ const alt = label || ''
128
+ const resolved = src
129
+ ? getImageUrl
130
+ ? getImageUrl(src)
131
+ : src
132
+ : undefined
133
+ const [broken, setBroken] = React.useState(false)
134
+ React.useEffect(() => {
135
+ setBroken(false)
136
+ }, [resolved])
137
+
138
+ return (
139
+ <span
140
+ className={`inline-flex max-w-[220px] flex-col items-start gap-0.5 ${className ?? ''}`}
141
+ title={subtitle ? `${alt} · ${subtitle}` : alt || undefined}
142
+ >
143
+ {resolved && !broken ? (
144
+ <img
145
+ src={resolved}
146
+ alt={alt}
147
+ className="block object-contain"
148
+ style={{ maxWidth: dims.w, maxHeight: dims.h, width: 'auto', height: 'auto' }}
149
+ onError={() => setBroken(true)}
150
+ />
151
+ ) : (
152
+ <span
153
+ className="flex items-center text-[10px] font-semibold tracking-wide text-muted-foreground"
154
+ style={{ minHeight: Math.round(dims.h * 0.55) }}
155
+ >
156
+ {getInitials(alt || '?')}
157
+ </span>
158
+ )}
159
+ {(label || subtitle) && (
160
+ <span className={`flex w-full min-w-0 flex-col items-start leading-tight ${dims.text}`}>
161
+ {label ? (
162
+ <span className="w-full truncate text-left font-medium text-foreground/90">
163
+ {label}
164
+ </span>
165
+ ) : null}
166
+ {subtitle ? (
167
+ <span className="w-full truncate text-left text-[0.7em] text-muted-foreground">
168
+ {subtitle}
169
+ </span>
170
+ ) : null}
171
+ </span>
172
+ )}
173
+ </span>
174
+ )
175
+ }
176
+
99
177
  export interface DisplayOption {
100
178
  value: string
101
179
  label: string
@@ -44,7 +44,7 @@ import {
44
44
  import { Progress } from './dialogs/_primitives'
45
45
  import { humanizeToken } from './dynamic-columns-helpers'
46
46
  import { objectLabel } from './dynamic-relation-helpers'
47
- import {
47
+ import { ImageStack,
48
48
  OptionBadge,
49
49
  RelationThumbnail,
50
50
  statusColorFor,
@@ -494,7 +494,7 @@ export const resolveRelationLabel = (col: ColumnDefinition, row: any): string =>
494
494
  export const resolveRelationImage = (col: ColumnDefinition, row: any): string => {
495
495
  const sibling = getNestedValue(row, relationKeyFor(col))
496
496
  if (sibling && typeof sibling === 'object') {
497
- const img = sibling.image ?? sibling.avatar ?? sibling.photo
497
+ const img = sibling.image ?? sibling.avatar ?? sibling.photo ?? sibling.logo ?? sibling.thumbnail
498
498
  if (img !== undefined && img !== null && img !== '') return String(img)
499
499
  }
500
500
  return ''
@@ -608,11 +608,24 @@ const RelationCell: React.FC<{
608
608
  col: ColumnDefinition
609
609
  row: any
610
610
  getImageUrl?: (path: string) => string
611
- }> = ({ col, row, getImageUrl }) => {
611
+ /** Landscape stack: image above, text below (display: image_stack). */
612
+ stack?: boolean
613
+ }> = ({ col, row, getImageUrl, stack = false }) => {
612
614
  const display = resolveRelationLabel(col, row)
613
615
  if (!display) return <EmptyCell />
614
616
  const image = resolveRelationImage(col, row)
615
617
  const subtitle = resolveRelationSubtitle(col, row)
618
+ if (stack) {
619
+ return (
620
+ <ImageStack
621
+ src={image || undefined}
622
+ label={display}
623
+ subtitle={subtitle || undefined}
624
+ getImageUrl={getImageUrl}
625
+ size="md"
626
+ />
627
+ )
628
+ }
616
629
  // FLAT reference cell: no tinted capsule around the pair. A reference is
617
630
  // data, not a status — the pill treatment (and its per-label tint) made a
618
631
  // products/warehouses listing read as a wall of badges. What identifies the
@@ -751,15 +764,29 @@ const AvatarCell: React.FC<{
751
764
  export const ImageCell: React.FC<{
752
765
  value: unknown
753
766
  getImageUrl: (path: string) => string
754
- }> = ({ value, getImageUrl }) => {
755
- if (!value) return <span className="text-muted-foreground">-</span>
756
- if (isLucideIconName(value)) {
767
+ /** Optional caption under the image (display: image_stack). */
768
+ label?: string
769
+ stack?: boolean
770
+ }> = ({ value, getImageUrl, label, stack = false }) => {
771
+ if (!value && !label) return <span className="text-muted-foreground">-</span>
772
+ if (value && isLucideIconName(value)) {
757
773
  return (
758
774
  <div className="h-10 w-10 flex items-center justify-center rounded bg-muted">
759
775
  <DynamicIcon name={value} className="h-5 w-5" />
760
776
  </div>
761
777
  )
762
778
  }
779
+ if (stack) {
780
+ return (
781
+ <ImageStack
782
+ src={value ? String(value) : undefined}
783
+ label={label}
784
+ getImageUrl={getImageUrl}
785
+ size="md"
786
+ />
787
+ )
788
+ }
789
+ if (!value) return <span className="text-muted-foreground">-</span>
763
790
  return (
764
791
  <div className="h-10 w-10 relative rounded overflow-hidden bg-muted flex items-center justify-center">
765
792
  <img
@@ -916,6 +943,37 @@ export function makeDefaultGetDynamicColumns(
916
943
  return <ReferenceCell col={col} row={row.original} />
917
944
  }
918
945
 
946
+ // Landscape stack: wide image ON TOP, label UNDERNEATH.
947
+ if (renderAs === 'image_stack') {
948
+ const looksRelation =
949
+ !!col.ref ||
950
+ (typeof col.key === 'string' &&
951
+ col.key.endsWith('_id') &&
952
+ resolveRelationLabel(col, row.original) != null)
953
+ if (looksRelation) {
954
+ return (
955
+ <RelationCell
956
+ col={col}
957
+ row={row.original}
958
+ getImageUrl={getImageUrl}
959
+ stack
960
+ />
961
+ )
962
+ }
963
+ const labelField = styleCfg(col, 'label_field', 'labelField')
964
+ const caption = labelField
965
+ ? String(getNestedValue(row.original, labelField) ?? '')
966
+ : undefined
967
+ return (
968
+ <ImageCell
969
+ value={value}
970
+ getImageUrl={getImageUrl}
971
+ label={caption || undefined}
972
+ stack
973
+ />
974
+ )
975
+ }
976
+
919
977
  // Resolved FK relation chip. Triggers on an explicit
920
978
  // `cellStyle: 'relation'` or on any column carrying a `ref`
921
979
  // (a belongs_to FK) that isn't being rendered as an
@@ -1274,6 +1332,21 @@ export function makeDefaultGetDynamicColumns(
1274
1332
  )
1275
1333
  }
1276
1334
 
1335
+ case 'image_stack': {
1336
+ const imageValue =
1337
+ value ||
1338
+ (Array.isArray(row.original.media)
1339
+ ? row.original.media.find((m: any) => m.type === 'image')?.url
1340
+ : null)
1341
+ return (
1342
+ <ImageCell
1343
+ value={imageValue}
1344
+ getImageUrl={getImageUrl}
1345
+ stack
1346
+ />
1347
+ )
1348
+ }
1349
+
1277
1350
  case 'image': {
1278
1351
  const imageValue =
1279
1352
  value ||
@@ -40,6 +40,7 @@ import { Check, ChevronsUpDown, Loader2, Plus, ScanLine } from 'lucide-react'
40
40
  import { resolveColorCss } from '@asteby/metacore-ui/lib'
41
41
  import { BarcodeScanner } from './barcode-scanner'
42
42
  import { DynamicIcon, isLucideIconName } from './dynamic-icon'
43
+ import { useDebouncedValue } from './use-debounced-value'
43
44
  import { useOptionsResolver, type ResolvedOption } from './use-options-resolver'
44
45
  import { getDependsOn, getFieldRef, resolveOptionsSource } from './dynamic-form-schema'
45
46
  import type { ActionFieldDef } from './types'
@@ -140,12 +141,7 @@ export function OptionLead({
140
141
  }
141
142
 
142
143
  function useDebounced<T>(value: T, ms: number): T {
143
- const [debounced, setDebounced] = useState(value)
144
- useEffect(() => {
145
- const t = setTimeout(() => setDebounced(value), ms)
146
- return () => clearTimeout(t)
147
- }, [value, ms])
148
- return debounced
144
+ return useDebouncedValue(value, ms)
149
145
  }
150
146
 
151
147
  export interface DynamicSelectFieldProps {
@@ -72,6 +72,7 @@ import { dedupeById, useInfiniteScrollSentinel } from './use-infinite-scroll'
72
72
  import { OptionsContext } from './options-context'
73
73
  import type { TableMetadata, ApiResponse } from './types'
74
74
  import { getSearchableColumnKeys } from './column-visibility'
75
+ import { useDebouncedValue } from './use-debounced-value'
75
76
  import { useCan, usePermissionsActive, gateTableMetadata } from './permissions-context'
76
77
  import { useDynamicRowActions } from './dynamic-row-actions'
77
78
  import { ExportDialog } from './dialogs/export'
@@ -320,6 +321,8 @@ export function DynamicTable({
320
321
  pageSize: storedPageSizeRef.current ?? 10,
321
322
  })
322
323
  const [globalFilter, setGlobalFilter] = useState('')
324
+ // Debounce search → URL + fetch so each keystroke does not thrash the server.
325
+ const debouncedGlobalFilter = useDebouncedValue(globalFilter)
323
326
  const [rowCount, setRowCount] = useState(bootData?.rowCount ?? 0)
324
327
 
325
328
  const [dateRange, setDateRange] = useState<DateRange | undefined>(undefined)
@@ -474,7 +477,7 @@ export function DynamicTable({
474
477
  params.set('sortBy', sorting[0].id)
475
478
  params.set('order', sorting[0].desc ? 'desc' : 'asc')
476
479
  }
477
- if (globalFilter) params.set('search', globalFilter)
480
+ if (debouncedGlobalFilter) params.set('search', debouncedGlobalFilter)
478
481
  Object.entries(dynamicFilters).forEach(([key, values]) => {
479
482
  if (values.length === 0) return
480
483
  if (defaultFilters && key in defaultFilters) return
@@ -495,7 +498,7 @@ export function DynamicTable({
495
498
  const newUrl = search ? `${window.location.pathname}?${search}` : window.location.pathname
496
499
  lastSelfSearch.current = search ? `?${search}` : ''
497
500
  window.history.replaceState(null, '', newUrl)
498
- }, [enableUrlSync, urlSynced, pagination, sorting, globalFilter, dynamicFilters, defaultFilters])
501
+ }, [enableUrlSync, urlSynced, pagination, sorting, debouncedGlobalFilter, dynamicFilters, defaultFilters])
499
502
 
500
503
  // The host router can rewrite the query string WITHOUT remounting the
501
504
  // table — e.g. sidebar sibling entries deep-link different `f_` filters
@@ -670,11 +673,11 @@ export function DynamicTable({
670
673
  params.sortBy = sorting[0].id
671
674
  params.order = sorting[0].desc ? 'desc' : 'asc'
672
675
  }
673
- if (globalFilter) {
676
+ if (debouncedGlobalFilter) {
674
677
  if (searchableKeys === null) {
675
- params.search = globalFilter
678
+ params.search = debouncedGlobalFilter
676
679
  } else if (searchableKeys.length > 0) {
677
- params.search = globalFilter
680
+ params.search = debouncedGlobalFilter
678
681
  params.search_columns = searchableKeys.join(',')
679
682
  }
680
683
  // searchableKeys === [] → drop the search request entirely
@@ -700,7 +703,7 @@ export function DynamicTable({
700
703
  params['f_created_at'] = `${startDate}_${endDate}`
701
704
  }
702
705
  return params
703
- }, [sorting, globalFilter, columnFilters, defaultFilters, dynamicFilters, dateRange, searchableKeys])
706
+ }, [sorting, debouncedGlobalFilter, columnFilters, defaultFilters, dynamicFilters, dateRange, searchableKeys])
704
707
 
705
708
  const hasActiveFilters = useMemo(() => {
706
709
  if (globalFilter) return true
@@ -0,0 +1,125 @@
1
+ // FilePickButton — locale-aware file picker. Native <input type="file"> always
2
+ // paints the browser/OS chrome ("Choose File" / "No file chosen") in the
3
+ // *browser* language, not the app language — so Spanish UIs on an English
4
+ // Chrome show English. Hide the native control and drive it from a Button
5
+ // whose labels come from i18n (common.upload.*).
6
+ import { useCallback, useId, useRef } from 'react'
7
+ import { useTranslation } from 'react-i18next'
8
+ import { Button } from '@asteby/metacore-ui/primitives'
9
+ import { Loader2, Paperclip, X } from 'lucide-react'
10
+
11
+ export interface FilePickButtonProps {
12
+ /** Called with the picked File, or null when cleared. */
13
+ onFile: (file: File | null) => void
14
+ accept?: string
15
+ /** HTML capture attribute (e.g. "environment" for rear camera). */
16
+ capture?: boolean | 'user' | 'environment'
17
+ disabled?: boolean
18
+ loading?: boolean
19
+ /** When true, shows replace + clear affordances. */
20
+ hasFile?: boolean
21
+ /** Optional display name next to the button when hasFile. */
22
+ fileName?: string
23
+ className?: string
24
+ /** Override the empty-state pick label (still falls back to i18n). */
25
+ pickLabel?: string
26
+ replaceLabel?: string
27
+ clearLabel?: string
28
+ id?: string
29
+ }
30
+
31
+ export function FilePickButton({
32
+ onFile,
33
+ accept,
34
+ capture,
35
+ disabled,
36
+ loading,
37
+ hasFile,
38
+ fileName,
39
+ className,
40
+ pickLabel,
41
+ replaceLabel,
42
+ clearLabel,
43
+ id: idProp,
44
+ }: FilePickButtonProps) {
45
+ const { t } = useTranslation()
46
+ const autoId = useId()
47
+ const id = idProp || autoId
48
+ const inputRef = useRef<HTMLInputElement | null>(null)
49
+ const busy = Boolean(disabled || loading)
50
+
51
+ const pick = useCallback(() => {
52
+ if (busy) return
53
+ inputRef.current?.click()
54
+ }, [busy])
55
+
56
+ const onChange = useCallback(
57
+ (e: React.ChangeEvent<HTMLInputElement>) => {
58
+ const file = e.target.files?.[0] ?? null
59
+ if (inputRef.current) inputRef.current.value = ''
60
+ if (file) onFile(file)
61
+ },
62
+ [onFile],
63
+ )
64
+
65
+ const onClear = useCallback(() => {
66
+ if (busy) return
67
+ onFile(null)
68
+ }, [busy, onFile])
69
+
70
+ const emptyLabel =
71
+ pickLabel || t('common.upload.choose', { defaultValue: 'Choose file' })
72
+ const filledLabel =
73
+ replaceLabel || t('common.upload.replace', { defaultValue: 'Replace' })
74
+ const removeLabel =
75
+ clearLabel || t('common.upload.remove', { defaultValue: 'Remove file' })
76
+ const emptyHint = t('common.upload.none', { defaultValue: 'No file chosen' })
77
+
78
+ return (
79
+ <div className={className ? `grid gap-1.5 ${className}` : 'grid gap-1.5'} data-widget="file-pick">
80
+ <input
81
+ ref={inputRef}
82
+ id={id}
83
+ type="file"
84
+ accept={accept}
85
+ {...(capture !== undefined ? { capture } : {})}
86
+ className="sr-only"
87
+ onChange={onChange}
88
+ tabIndex={-1}
89
+ aria-hidden="true"
90
+ />
91
+ <div className="flex flex-wrap items-center gap-2">
92
+ <Button type="button" variant="outline" size="sm" onClick={pick} disabled={busy}>
93
+ {loading ? (
94
+ <Loader2 className="mr-2 h-4 w-4 animate-spin" />
95
+ ) : (
96
+ <Paperclip className="mr-2 h-4 w-4" />
97
+ )}
98
+ {hasFile ? filledLabel : emptyLabel}
99
+ </Button>
100
+ {hasFile ? (
101
+ <div className="flex min-w-0 items-center gap-1 text-sm text-muted-foreground">
102
+ {fileName ? (
103
+ <span className="truncate" title={fileName}>
104
+ {fileName}
105
+ </span>
106
+ ) : null}
107
+ <Button
108
+ type="button"
109
+ variant="ghost"
110
+ size="sm"
111
+ className="h-6 w-6 p-0"
112
+ onClick={onClear}
113
+ disabled={busy}
114
+ aria-label={removeLabel}
115
+ >
116
+ <X className="h-3.5 w-3.5" />
117
+ </Button>
118
+ </div>
119
+ ) : (
120
+ <span className="text-xs text-muted-foreground">{emptyHint}</span>
121
+ )}
122
+ </div>
123
+ </div>
124
+ )
125
+ }
package/src/index.ts CHANGED
@@ -119,7 +119,12 @@ export {
119
119
  type UseDynamicFiltersOptions,
120
120
  type UseDynamicFiltersResult,
121
121
  } from './use-dynamic-filters'
122
+ export { useDebouncedValue, SEARCH_DEBOUNCE_MS } from './use-debounced-value'
122
123
  export * from './dynamic-form'
124
+ export {
125
+ FilePickButton,
126
+ type FilePickButtonProps,
127
+ } from './file-pick-button'
123
128
  export * from './barcode-scanner'
124
129
  export * from './form-layout'
125
130
  export * from './form-layout-ui'
@@ -261,6 +266,12 @@ export {
261
266
  type UrlKind,
262
267
  type LinkifyOptions,
263
268
  } from './rich-url'
269
+ export {
270
+ ImageStack,
271
+ OptionBadge,
272
+ statusColorFor,
273
+ useIsDarkTheme,
274
+ } from './display-value'
264
275
  export {
265
276
  CollectionCell,
266
277
  formatScalar,
@@ -178,7 +178,14 @@ export const useMetadataCache = create<MetadataCacheState>()(
178
178
  }),
179
179
  {
180
180
  name: 'metacore-metadata-cache',
181
- version: 3,
181
+ // Bump when display hints (e.g. image_stack) must wipe stale cache.
182
+ version: 4,
183
+ migrate: () => ({
184
+ cache: {},
185
+ modalCache: {},
186
+ metadataVersion: '',
187
+ prefetched: false,
188
+ }),
182
189
  partialize: (state) => ({
183
190
  cache: state.cache,
184
191
  modalCache: state.modalCache,
package/src/types.ts CHANGED
@@ -252,6 +252,8 @@ export interface ColumnDefinition {
252
252
  | 'phone'
253
253
  | 'media-gallery'
254
254
  | 'image'
255
+ // Landscape stack: wide image on top, label underneath (logos/photos).
256
+ | 'image_stack'
255
257
  // Declarative pro cell renderers (resolved via `cellStyle ?? type`).
256
258
  | 'url'
257
259
  | 'link'
@@ -1,8 +1,8 @@
1
1
  // UploadField — the `upload` widget renderer shared by DynamicForm's
2
2
  // FieldRenderer and the action-modal-dispatcher's renderField so the two stay
3
- // in lockstep. Renders a themed Button that proxies a hidden <input type=file>,
4
- // POSTs the picked file to the host upload endpoint as multipart/form-data, and
5
- // stores the returned file url/path as the field value.
3
+ // in lockstep. Renders a locale-aware FilePickButton that proxies a hidden
4
+ // <input type=file>, POSTs the picked file to the host upload endpoint as
5
+ // multipart/form-data, and stores the returned file url/path as the field value.
6
6
  //
7
7
  // Endpoint assumption: `POST /uploads` (multipart) returning
8
8
  // { success: true, data: { file_url?, url?, path?, file_path? } }
@@ -10,11 +10,11 @@
10
10
  // `field.searchEndpoint` (reused as the upload endpoint escape hatch) — kept
11
11
  // generic so this carries no host-specific route. Honors field.accept /
12
12
  // field.maxSize and forwards field.storagePath as `storage_path`.
13
- import { useCallback, useRef, useState } from 'react'
14
- import { Button } from '@asteby/metacore-ui/primitives'
15
- import { Loader2, Paperclip, X } from 'lucide-react'
16
- import { useApi } from './api-context'
13
+ import { useCallback, useState } from 'react'
14
+ import { useTranslation } from 'react-i18next'
17
15
  import { getUploadConfig } from './dynamic-form-schema'
16
+ import { FilePickButton } from './file-pick-button'
17
+ import { useApi } from './api-context'
18
18
  import type { ActionFieldDef } from './types'
19
19
 
20
20
  export interface UploadFieldProps {
@@ -52,29 +52,31 @@ export function uploadedDisplayName(value: unknown): string {
52
52
  }
53
53
 
54
54
  export function UploadField({ field, value, onChange }: UploadFieldProps) {
55
+ const { t } = useTranslation()
55
56
  const api = useApi()
56
- const inputRef = useRef<HTMLInputElement | null>(null)
57
57
  const [uploading, setUploading] = useState(false)
58
58
  const [error, setError] = useState<string | null>(null)
59
59
 
60
60
  const { accept, maxSize, storagePath } = getUploadConfig(field)
61
61
  const endpoint = field.searchEndpoint || DEFAULT_UPLOAD_ENDPOINT
62
-
63
- const handlePick = useCallback(() => {
64
- if (uploading) return
65
- inputRef.current?.click()
66
- }, [uploading])
62
+ const hasValue = typeof value === 'string' && value !== ''
67
63
 
68
64
  const handleFile = useCallback(
69
- async (e: React.ChangeEvent<HTMLInputElement>) => {
70
- const file = e.target.files?.[0]
71
- // Reset the input so picking the same file again re-fires change.
72
- if (inputRef.current) inputRef.current.value = ''
73
- if (!file) return
65
+ async (file: File | null) => {
66
+ if (!file) {
67
+ setError(null)
68
+ onChange('')
69
+ return
70
+ }
74
71
  setError(null)
75
72
  if (maxSize && file.size > maxSize) {
76
73
  const mb = (maxSize / (1024 * 1024)).toFixed(1)
77
- setError(`Archivo muy grande (máx. ${mb} MB).`)
74
+ setError(
75
+ t('common.upload.too_large', {
76
+ mb,
77
+ defaultValue: `File too large (max {{mb}} MB).`,
78
+ }),
79
+ )
78
80
  return
79
81
  }
80
82
  const form = new FormData()
@@ -94,77 +96,45 @@ export function UploadField({ field, value, onChange }: UploadFieldProps) {
94
96
  })
95
97
  const body = (res as { data?: any })?.data
96
98
  if (body && body.success === false) {
97
- setError(body.message || 'No se pudo subir el archivo.')
99
+ setError(
100
+ body.message ||
101
+ t('common.upload.failed', { defaultValue: 'Could not upload the file.' }),
102
+ )
98
103
  return
99
104
  }
100
105
  const stored = extractUploadedValue(body)
101
106
  if (!stored) {
102
- setError('Respuesta de subida inválida.')
107
+ setError(
108
+ t('common.upload.invalid', {
109
+ defaultValue: 'Invalid upload response.',
110
+ }),
111
+ )
103
112
  return
104
113
  }
105
114
  onChange(stored)
106
115
  } catch (err: any) {
107
- setError(err?.response?.data?.message || 'No se pudo subir el archivo.')
116
+ setError(
117
+ err?.response?.data?.message ||
118
+ t('common.upload.failed', { defaultValue: 'Could not upload the file.' }),
119
+ )
108
120
  } finally {
109
121
  setUploading(false)
110
122
  }
111
123
  },
112
- [api, endpoint, maxSize, storagePath, onChange],
124
+ [api, endpoint, maxSize, storagePath, onChange, t],
113
125
  )
114
126
 
115
- const handleClear = useCallback(() => {
116
- if (uploading) return
117
- setError(null)
118
- onChange('')
119
- }, [uploading, onChange])
120
-
121
- const hasValue = typeof value === 'string' && value !== ''
122
-
123
127
  return (
124
128
  <div className="grid gap-1.5" data-widget="upload">
125
- <input
126
- ref={inputRef}
129
+ <FilePickButton
127
130
  id={field.key}
128
- type="file"
129
131
  accept={accept}
130
- className="sr-only"
131
- onChange={handleFile}
132
- tabIndex={-1}
133
- aria-hidden="true"
132
+ loading={uploading}
133
+ hasFile={hasValue}
134
+ fileName={hasValue ? uploadedDisplayName(value) : undefined}
135
+ onFile={(f) => void handleFile(f)}
136
+ pickLabel={field.placeholder || undefined}
134
137
  />
135
- <div className="flex items-center gap-2">
136
- <Button
137
- type="button"
138
- variant="outline"
139
- size="sm"
140
- onClick={handlePick}
141
- disabled={uploading}
142
- >
143
- {uploading ? (
144
- <Loader2 className="mr-2 h-4 w-4 animate-spin" />
145
- ) : (
146
- <Paperclip className="mr-2 h-4 w-4" />
147
- )}
148
- {hasValue ? 'Reemplazar' : field.placeholder || 'Subir archivo'}
149
- </Button>
150
- {hasValue && !uploading && (
151
- <div className="flex min-w-0 items-center gap-1 text-sm text-muted-foreground">
152
- <span className="truncate" title={String(value)}>
153
- {uploadedDisplayName(value)}
154
- </span>
155
- <Button
156
- type="button"
157
- variant="ghost"
158
- size="sm"
159
- className="h-6 w-6 p-0"
160
- onClick={handleClear}
161
- aria-label="Quitar archivo"
162
- >
163
- <X className="h-3.5 w-3.5" />
164
- </Button>
165
- </div>
166
- )}
167
- </div>
168
138
  {error && (
169
139
  <span className="text-sm text-destructive" role="alert">
170
140
  {error}