@lotics/ui 5.8.0 → 5.9.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.
@@ -1,39 +1,54 @@
1
- // FileGalleryModal — fullscreen file preview with prev/next nav + ESC close.
2
- // Pure primitive: takes DisplayFile[] only, no Lotics domain coupling. Renders
3
- // every previewable type (image/PDF/video/audio/Word/Excel/CSV) via FilePreview,
4
- // whose document engines (@lotics/docx, @lotics/xlsx) are optional peer deps
5
- // loaded lazily so a consumer that only shows images pays nothing extra.
1
+ // FileGalleryModal — full-screen file preview with a toolbar (filename · counter
2
+ // · actions · close), prev/next nav, and ESC close. Pure primitive: takes
3
+ // DisplayFile[] only, no Lotics domain coupling. Renders every previewable type
4
+ // (image/PDF/video/audio/Word/Excel/CSV) via FilePreview, whose document engines
5
+ // (pdfjs-dist, @lotics/docx, @lotics/xlsx) are optional peer deps loaded lazily
6
+ // so a consumer that only shows images pays nothing extra.
6
7
 
7
- import { useCallback, useEffect, useRef } from "react";
8
- import {
9
- Modal,
10
- Pressable,
11
- StyleSheet,
12
- View,
13
- } from "react-native";
8
+ import { useCallback, useEffect, useState, type ReactNode } from "react";
9
+ import { Linking, Modal, Platform, Pressable, StyleSheet, View } from "react-native";
14
10
  import { Text } from "./text";
15
11
  import { Icon } from "./icon";
12
+ import { IconButton } from "./icon_button";
13
+ import { Alert } from "./alert";
14
+ import { MenuButton } from "./menu_button";
15
+ import { Popover, PopoverTrigger, PopoverContent } from "./popover";
16
+ import { useScreenSize } from "./use_screen_size";
16
17
  import { colors } from "./colors";
17
18
  import type { DisplayFile } from "./file_thumbnail";
18
19
  import { FilePreview } from "./file_preview";
19
20
  import { isImageMimeType } from "./mime";
20
21
  import { useImageRotation, type ImageRotation } from "./use_image_rotation";
21
- import type { PreviewLabels } from "./file_preview_types";
22
+ import { resolveGalleryLabels, type GalleryLabels } from "./file_preview_types";
23
+ import { downloadFileFromUrl } from "./download";
22
24
 
23
25
  export interface FileGalleryModalProps {
24
26
  files: DisplayFile[];
25
27
  /** Index of the file currently shown. When null, the modal is closed. */
26
28
  activeIndex: number | null;
27
29
  onIndexChange: (next: number | null) => void;
28
- /**
29
- * Optional caption suffix shown under the file. Useful for hint text like
30
- * "ESC để đóng · ←/→ để chuyển". When omitted, just shows filename + count.
31
- */
32
- captionHint?: string;
33
- /** Translated preview strings; English fallback when omitted. */
34
- labels?: Partial<PreviewLabels>;
30
+ /** Translated chrome + preview strings; English fallback when omitted. */
31
+ labels?: Partial<GalleryLabels>;
35
32
  /** Reported when a file fails to render (host wires to its logger). */
36
33
  onError?: (error: unknown, meta: { fileId: string; mimeType: string }) => void;
34
+ /**
35
+ * Override the toolbar's Download action. Omit ⇒ a built-in default (web:
36
+ * fetch+save the presigned URL; native: open it externally) — correct for a
37
+ * custom-code app's same-origin presigned R2 URLs. The host frontend passes
38
+ * its own, which sends `credentials: "include"` for auth-gated proxy URLs.
39
+ */
40
+ onDownload?: (file: DisplayFile) => void;
41
+ /**
42
+ * Open the active file outside the modal (a new browser tab on the frontend, the
43
+ * SDK's `openExternal` inside a custom-code app — the sandbox can't pop tabs).
44
+ * When omitted, the action is hidden.
45
+ */
46
+ onOpenExternal?: (file: DisplayFile) => void;
47
+ /**
48
+ * Remove the active file. When provided, a Remove action (with a confirm) shows
49
+ * in the actions menu; the host drops the file from its own state.
50
+ */
51
+ onRemove?: (fileId: string) => void;
37
52
  /**
38
53
  * Optional shared rotation state (from `useImageRotation`) — pass it to keep an
39
54
  * inline gallery and this modal in sync. Omitted ⇒ the modal keeps its own
@@ -42,6 +57,14 @@ export interface FileGalleryModalProps {
42
57
  rotation?: ImageRotation;
43
58
  /** Show 90° rotate controls for image files. Default true. */
44
59
  rotatable?: boolean;
60
+ /**
61
+ * Override how the active file's CONTENT is rendered (the toolbar/nav chrome is
62
+ * unchanged). Omit ⇒ the built-in `FilePreview`. The host frontend passes this
63
+ * to route Excel to its live editable workbook while delegating every other
64
+ * type to `FilePreview`; custom-code apps never set it. `rotation` (degrees) is
65
+ * forwarded so the override can honor the rotate controls for images.
66
+ */
67
+ renderPreview?: (file: DisplayFile, opts: { rotation: number }) => ReactNode;
45
68
  /**
46
69
  * Persist the active image's current view-rotation as a new stored file. When
47
70
  * provided and the active image is rotated, a confirm (✓) control appears next
@@ -54,13 +77,28 @@ export interface FileGalleryModalProps {
54
77
  }
55
78
 
56
79
  /**
57
- * Fullscreen file preview gallery. Renders any previewable MIME type inline
58
- * via FilePreview; unknown types show a download placeholder.
80
+ * Full-screen file preview gallery. Renders any previewable MIME type inline via
81
+ * FilePreview; unknown types show a download placeholder.
59
82
  */
60
83
  export function FileGalleryModal(props: FileGalleryModalProps) {
61
- const { files, activeIndex, onIndexChange, captionHint, labels, onError, rotation, rotatable = true, onPersistRotation, persisting } = props;
84
+ const {
85
+ files,
86
+ activeIndex,
87
+ onIndexChange,
88
+ labels,
89
+ onError,
90
+ onDownload,
91
+ onOpenExternal,
92
+ onRemove,
93
+ rotation,
94
+ rotatable = true,
95
+ renderPreview,
96
+ onPersistRotation,
97
+ persisting,
98
+ } = props;
62
99
  const visible = activeIndex !== null;
63
- const overlayRef = useRef<View | null>(null);
100
+ const [actionsOpen, setActionsOpen] = useState(false);
101
+ const { small } = useScreenSize();
64
102
  // Own the rotation when the host doesn't share one (standalone consumers).
65
103
  const internalRotation = useImageRotation();
66
104
  const rot = rotation ?? internalRotation;
@@ -103,84 +141,153 @@ export function FileGalleryModal(props: FileGalleryModalProps) {
103
141
  const file = files[activeIndex];
104
142
  if (!file) return null;
105
143
 
144
+ const l = resolveGalleryLabels(labels);
106
145
  const isImage = isImageMimeType(file.mimeType);
107
146
  const total = files.length;
108
- const caption = captionHint
109
- ? `${file.filename} · ${activeIndex + 1} / ${total} · ${captionHint}`
110
- : `${file.filename} · ${activeIndex + 1} / ${total}`;
147
+ const rotated = rot.rotationFor(file.id) !== 0;
148
+
149
+ const handleDownload = () => {
150
+ setActionsOpen(false);
151
+ if (onDownload) {
152
+ onDownload(file);
153
+ return;
154
+ }
155
+ // Default: the web fetch+save path (presigned URLs are same-origin to an
156
+ // app). On native the web primitive can't run, so open the URL externally.
157
+ if (Platform.OS === "web") {
158
+ void downloadFileFromUrl(file.url, file.filename);
159
+ } else {
160
+ void Linking.openURL(file.url);
161
+ }
162
+ };
163
+ const handleOpenExternal = () => {
164
+ setActionsOpen(false);
165
+ onOpenExternal?.(file);
166
+ };
167
+ const handleRemove = () => {
168
+ if (!onRemove) return;
169
+ setActionsOpen(false);
170
+ Alert.alert(l.removeConfirmTitle, l.removeConfirmMessage(file.filename), [
171
+ { text: l.cancel, style: "cancel" },
172
+ {
173
+ text: l.remove,
174
+ style: "destructive",
175
+ onPress: () => {
176
+ if (total === 1) {
177
+ onRemove(file.id);
178
+ close();
179
+ return;
180
+ }
181
+ // Pre-move before the list shrinks, clamping off the end.
182
+ onIndexChange(activeIndex >= total - 1 ? activeIndex - 1 : activeIndex);
183
+ onRemove(file.id);
184
+ },
185
+ },
186
+ ]);
187
+ };
111
188
 
112
189
  return (
113
190
  <Modal visible transparent onRequestClose={close} animationType="fade">
114
- <View style={styles.overlay} ref={overlayRef}>
115
- <Pressable
116
- accessibilityRole="button"
117
- accessibilityLabel="Close"
118
- onPress={close}
119
- style={StyleSheet.absoluteFill}
120
- />
191
+ <View style={styles.surface}>
192
+ <View style={styles.toolbar}>
193
+ <Text size="sm" weight="medium" numberOfLines={1} style={styles.filename}>
194
+ {file.filename}
195
+ </Text>
196
+ {total > 1 ? (
197
+ <Text size="xs" color="muted" style={styles.counter}>
198
+ {`${activeIndex + 1} / ${total}`}
199
+ </Text>
200
+ ) : null}
201
+ <View style={styles.spacer} />
121
202
 
122
- <View style={styles.previewPanel}>
123
- <FilePreview file={file} labels={labels} onError={onError} rotation={rot.rotationFor(file.id)} />
203
+ {isImage && rotatable ? (
204
+ <>
205
+ <IconButton
206
+ size="lg"
207
+ icon="rotate-ccw"
208
+ tooltip={l.rotateLeft}
209
+ color="secondary"
210
+ onPress={() => rot.rotate(file.id, -1)}
211
+ />
212
+ <IconButton
213
+ size="lg"
214
+ icon="rotate-cw"
215
+ tooltip={l.rotateRight}
216
+ color="secondary"
217
+ onPress={() => rot.rotate(file.id, 1)}
218
+ />
219
+ {onPersistRotation && rotated ? (
220
+ <IconButton
221
+ size="lg"
222
+ icon="check"
223
+ tooltip={l.saveRotation}
224
+ color="primary"
225
+ disabled={persisting}
226
+ onPress={() => {
227
+ if (!persisting) onPersistRotation(file, rot.rotationFor(file.id));
228
+ }}
229
+ />
230
+ ) : null}
231
+ </>
232
+ ) : null}
233
+
234
+ <Popover open={actionsOpen} onOpenChange={setActionsOpen}>
235
+ <PopoverTrigger>
236
+ <IconButton size="lg" icon="ellipsis" tooltip={l.actions} color="secondary" />
237
+ </PopoverTrigger>
238
+ <PopoverContent small={small}>
239
+ <View style={styles.menu}>
240
+ <MenuButton icon="download" title={l.download} onPress={handleDownload} />
241
+ {onOpenExternal ? (
242
+ <MenuButton
243
+ icon="external-link"
244
+ title={l.openExternal}
245
+ onPress={handleOpenExternal}
246
+ />
247
+ ) : null}
248
+ {onRemove ? (
249
+ <MenuButton icon="trash" title={l.remove} danger onPress={handleRemove} />
250
+ ) : null}
251
+ </View>
252
+ </PopoverContent>
253
+ </Popover>
254
+
255
+ <IconButton size="lg" icon="x" tooltip={l.close} color="secondary" onPress={close} />
124
256
  </View>
125
257
 
126
- {isImage && rotatable ? (
127
- <View style={styles.controls}>
258
+ <View style={styles.previewArea}>
259
+ {renderPreview ? (
260
+ renderPreview(file, { rotation: rot.rotationFor(file.id) })
261
+ ) : (
262
+ <FilePreview
263
+ file={file}
264
+ labels={labels}
265
+ onError={onError}
266
+ rotation={rot.rotationFor(file.id)}
267
+ />
268
+ )}
269
+
270
+ {activeIndex > 0 ? (
128
271
  <Pressable
129
- onPress={() => rot.rotate(file.id, -1)}
272
+ onPress={prev}
130
273
  accessibilityRole="button"
131
- accessibilityLabel="Rotate left"
132
- style={styles.controlButton}
274
+ accessibilityLabel={l.previous}
275
+ style={[styles.nav, styles.navLeft]}
133
276
  >
134
- <Icon name="rotate-ccw" size={20} color={colors.white} />
277
+ <Icon name="chevron-left" size={28} color={colors.white} />
135
278
  </Pressable>
279
+ ) : null}
280
+
281
+ {activeIndex < total - 1 ? (
136
282
  <Pressable
137
- onPress={() => rot.rotate(file.id, 1)}
283
+ onPress={next}
138
284
  accessibilityRole="button"
139
- accessibilityLabel="Rotate right"
140
- style={styles.controlButton}
285
+ accessibilityLabel={l.next}
286
+ style={[styles.nav, styles.navRight]}
141
287
  >
142
- <Icon name="rotate-cw" size={20} color={colors.white} />
288
+ <Icon name="chevron-right" size={28} color={colors.white} />
143
289
  </Pressable>
144
- {onPersistRotation && rot.rotationFor(file.id) !== 0 ? (
145
- <Pressable
146
- onPress={() => { if (!persisting) onPersistRotation(file, rot.rotationFor(file.id)); }}
147
- accessibilityRole="button"
148
- accessibilityLabel="Save rotation"
149
- disabled={persisting}
150
- style={[styles.controlButton, styles.controlButtonPrimary, persisting && styles.controlButtonBusy]}
151
- >
152
- <Icon name="check" size={20} color={colors.white} />
153
- </Pressable>
154
- ) : null}
155
- </View>
156
- ) : null}
157
-
158
- {activeIndex > 0 ? (
159
- <Pressable
160
- onPress={prev}
161
- accessibilityRole="button"
162
- accessibilityLabel="Previous"
163
- style={[styles.navButton, styles.navLeft]}
164
- >
165
- <Icon name="chevron-left" size={28} color={colors.white} />
166
- </Pressable>
167
- ) : null}
168
-
169
- {activeIndex < total - 1 ? (
170
- <Pressable
171
- onPress={next}
172
- accessibilityRole="button"
173
- accessibilityLabel="Next"
174
- style={[styles.navButton, styles.navRight]}
175
- >
176
- <Icon name="chevron-right" size={28} color={colors.white} />
177
- </Pressable>
178
- ) : null}
179
-
180
- <View style={styles.captionWrap} pointerEvents="none">
181
- <View style={styles.captionPill}>
182
- <Text size="sm" style={styles.captionText}>{caption}</Text>
183
- </View>
290
+ ) : null}
184
291
  </View>
185
292
  </View>
186
293
  </Modal>
@@ -188,20 +295,37 @@ export function FileGalleryModal(props: FileGalleryModalProps) {
188
295
  }
189
296
 
190
297
  const styles = StyleSheet.create({
191
- overlay: {
298
+ surface: {
192
299
  flex: 1,
193
- backgroundColor: "rgba(0, 0, 0, 0.88)",
194
- justifyContent: "center",
300
+ backgroundColor: colors.background,
301
+ },
302
+ toolbar: {
303
+ flexDirection: "row",
195
304
  alignItems: "center",
305
+ gap: 8,
306
+ paddingHorizontal: 12,
307
+ paddingVertical: 8,
308
+ borderBottomWidth: 1,
309
+ borderBottomColor: colors.border,
310
+ zIndex: 1,
196
311
  },
197
- previewPanel: {
198
- width: "92%",
199
- height: "86%",
200
- backgroundColor: colors.background,
201
- borderRadius: 8,
202
- overflow: "hidden",
312
+ filename: {
313
+ flexShrink: 1,
314
+ },
315
+ counter: {
316
+ flexShrink: 0,
317
+ },
318
+ spacer: {
319
+ flex: 1,
203
320
  },
204
- navButton: {
321
+ menu: {
322
+ minWidth: 180,
323
+ },
324
+ previewArea: {
325
+ flex: 1,
326
+ justifyContent: "center",
327
+ },
328
+ nav: {
205
329
  position: "absolute",
206
330
  top: "50%",
207
331
  transform: [{ translateY: -28 }],
@@ -218,41 +342,4 @@ const styles = StyleSheet.create({
218
342
  navRight: {
219
343
  right: 16,
220
344
  },
221
- controls: {
222
- position: "absolute",
223
- top: 16,
224
- right: 16,
225
- flexDirection: "row",
226
- gap: 8,
227
- },
228
- controlButton: {
229
- width: 44,
230
- height: 44,
231
- borderRadius: 22,
232
- backgroundColor: "rgba(0, 0, 0, 0.4)",
233
- justifyContent: "center",
234
- alignItems: "center",
235
- },
236
- controlButtonPrimary: {
237
- backgroundColor: colors.blue["600"],
238
- },
239
- controlButtonBusy: {
240
- opacity: 0.6,
241
- },
242
- captionWrap: {
243
- position: "absolute",
244
- bottom: 24,
245
- left: 0,
246
- right: 0,
247
- alignItems: "center",
248
- },
249
- captionPill: {
250
- backgroundColor: "rgba(0, 0, 0, 0.7)",
251
- paddingHorizontal: 12,
252
- paddingVertical: 6,
253
- borderRadius: 6,
254
- },
255
- captionText: {
256
- color: colors.white,
257
- },
258
345
  });
@@ -0,0 +1,184 @@
1
+ // FileGrid — the upload-aware file grid: a ThumbnailGrid that interleaves
2
+ // completed files (FileThumbnail) with in-flight uploads (UploadingThumbnail).
3
+ // Pure: the host supplies completed files + a normalized upload queue + callbacks;
4
+ // no domain/upload-transport coupling. Each host wires its own upload mechanism
5
+ // (a custom-code app: app-sdk useAttachments; the frontend: its upload queue) and
6
+ // feeds the result here.
7
+ //
8
+ // Layout: by default tiles FILL the container width (uniform size derived from
9
+ // `minItemWidth`, reflowing as the width changes); a short last row keeps that
10
+ // size and is left-aligned — no stretched tiles, no empty spacer cells. Pass
11
+ // `columns` for a fixed column count, or `itemSize` for exact fixed-size tiles.
12
+
13
+ import { useCallback } from "react";
14
+ import type { ViewStyle } from "react-native";
15
+ import { FileThumbnail, type DisplayFile } from "./file_thumbnail";
16
+ import { ThumbnailGrid } from "./file_thumbnail_grid";
17
+ import { UploadingThumbnail, type UploadStatus, type UploadStatusLabels } from "./uploading_thumbnail";
18
+ import { Button } from "./button";
19
+
20
+ /** An in-flight upload item (everything except a completed file). */
21
+ export interface PendingUpload {
22
+ id: string;
23
+ filename: string;
24
+ mimeType: string;
25
+ previewUrl?: string;
26
+ status: UploadStatus;
27
+ staleFile?: boolean;
28
+ }
29
+
30
+ /** A live upload-queue entry: a just-completed file (in place) or an in-flight
31
+ * upload. The host maps its own upload state to this. */
32
+ export type FileUpload = { status: "complete"; id: string; file: DisplayFile } | PendingUpload;
33
+
34
+ export interface FileGridLabels {
35
+ /** "Retry all" footer button (shown when any upload errored). */
36
+ retryAll?: string;
37
+ /** Status text for in-flight upload tiles (failed / paused / retrying / …). */
38
+ upload?: UploadStatusLabels;
39
+ }
40
+
41
+ export interface FileGridProps {
42
+ /** Pre-existing completed files (e.g. a record's saved attachments). */
43
+ files?: DisplayFile[];
44
+ /** The live add-queue: completed-in-place or in-flight uploads. */
45
+ uploads?: FileUpload[];
46
+ /** EXACT fixed tile size (px) — opts out of fill, for small fixed tiles. */
47
+ itemSize?: number;
48
+ /** FILL with a fixed column count (tiles take an equal 1/N of the width). */
49
+ columns?: number;
50
+ /** FILL target width (default 96): the default mode derives the column count so
51
+ * each tile is ≥ this and rows span the container. */
52
+ minItemWidth?: number;
53
+ gap?: number;
54
+ maxVisible?: number;
55
+ /** SINGLE non-wrapping row, fit-to-width: shows as many tiles as fit, reserving
56
+ * a "+N" overflow tile. For a dense strip in a fixed-width container. */
57
+ singleRow?: boolean;
58
+ /** Press handler for the "+N" overflow tile (the hidden count). Host wires it —
59
+ * e.g. open the gallery at the first hidden file. */
60
+ onOverflowPress?: (hiddenCount: number) => void;
61
+ style?: ViewStyle;
62
+ disablePress?: boolean;
63
+ partialRowAlign?: "start" | "end";
64
+ selectedIds?: ReadonlySet<string>;
65
+ /** When provided, tapping toggles selection instead of `onFilePress`. */
66
+ onToggleSelect?: (id: string) => void;
67
+ /** A completed file was pressed (open it / a gallery). */
68
+ onFilePress?: (file: DisplayFile) => void;
69
+ /** Remove a completed file (its id). */
70
+ onDisplayRemove?: (id: string) => void;
71
+ /** Remove an in-flight upload (its id) — cancels it. */
72
+ onUploadRemove?: (id: string) => void;
73
+ /** Retry one failed upload. */
74
+ onRetry?: (id: string) => void;
75
+ /** Retry all failed uploads. */
76
+ onRetryAll?: () => void;
77
+ labels?: FileGridLabels;
78
+ }
79
+
80
+ type RenderItem =
81
+ | { kind: "display"; file: DisplayFile; removeId: string }
82
+ | { kind: "upload"; removeId: string; upload: PendingUpload };
83
+
84
+ function buildRenderItems(files?: DisplayFile[], uploads?: FileUpload[]): RenderItem[] {
85
+ const items: RenderItem[] = [];
86
+ if (files) {
87
+ for (const file of files) items.push({ kind: "display", file, removeId: file.id });
88
+ }
89
+ if (uploads) {
90
+ for (const upload of uploads) {
91
+ if (upload.status === "complete") {
92
+ items.push({ kind: "display", file: upload.file, removeId: upload.id });
93
+ } else {
94
+ items.push({ kind: "upload", removeId: upload.id, upload });
95
+ }
96
+ }
97
+ }
98
+ return items;
99
+ }
100
+
101
+ export function FileGrid(props: FileGridProps) {
102
+ const {
103
+ files,
104
+ uploads,
105
+ itemSize,
106
+ columns,
107
+ minItemWidth,
108
+ gap = 8,
109
+ maxVisible,
110
+ singleRow,
111
+ onOverflowPress,
112
+ style,
113
+ disablePress,
114
+ partialRowAlign,
115
+ selectedIds,
116
+ onToggleSelect,
117
+ onFilePress,
118
+ onDisplayRemove,
119
+ onUploadRemove,
120
+ onRetry,
121
+ onRetryAll,
122
+ labels,
123
+ } = props;
124
+
125
+ const renderItems = buildRenderItems(files, uploads);
126
+
127
+ const handleFilePress = useCallback(
128
+ (file: DisplayFile) => {
129
+ if (disablePress) return;
130
+ if (onToggleSelect) {
131
+ onToggleSelect(file.id);
132
+ return;
133
+ }
134
+ onFilePress?.(file);
135
+ },
136
+ [disablePress, onToggleSelect, onFilePress],
137
+ );
138
+
139
+ const retryAll =
140
+ onRetryAll && renderItems.some((item) => item.kind === "upload" && item.upload.status === "error") ? (
141
+ <Button onPress={onRetryAll} color="danger" title={labels?.retryAll ?? "Retry all"} />
142
+ ) : null;
143
+
144
+ return (
145
+ <ThumbnailGrid
146
+ items={renderItems}
147
+ keyExtractor={(item) => item.removeId}
148
+ itemSize={itemSize}
149
+ columns={columns}
150
+ minItemWidth={minItemWidth}
151
+ gap={gap}
152
+ maxVisible={maxVisible}
153
+ singleRow={singleRow}
154
+ onOverflowPress={onOverflowPress}
155
+ partialRowAlign={partialRowAlign}
156
+ style={style}
157
+ disablePress={disablePress}
158
+ footer={retryAll}
159
+ renderItem={(item, size) =>
160
+ item.kind === "display" ? (
161
+ <FileThumbnail
162
+ file={item.file}
163
+ size={size}
164
+ onPress={disablePress ? undefined : () => handleFilePress(item.file)}
165
+ onRemove={onDisplayRemove ? () => onDisplayRemove(item.removeId) : undefined}
166
+ selected={selectedIds?.has(item.file.id)}
167
+ />
168
+ ) : (
169
+ <UploadingThumbnail
170
+ filename={item.upload.filename}
171
+ mimeType={item.upload.mimeType}
172
+ previewUrl={item.upload.previewUrl}
173
+ status={item.upload.status}
174
+ staleFile={item.upload.status === "error" ? item.upload.staleFile : undefined}
175
+ size={size}
176
+ onRemove={onUploadRemove ? () => onUploadRemove(item.removeId) : undefined}
177
+ onRetry={onRetry ? () => onRetry(item.removeId) : undefined}
178
+ labels={labels?.upload}
179
+ />
180
+ )
181
+ }
182
+ />
183
+ );
184
+ }