@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.
- package/AGENTS.md +63 -18
- package/examples/tpl_convert.tsx +11 -23
- package/examples/tpl_order.tsx +1 -1
- package/examples/tpl_ratedesk.tsx +1 -1
- package/examples/tpl_record.tsx +1 -1
- package/package.json +10 -2
- package/src/file_gallery_modal.tsx +220 -133
- package/src/file_grid.tsx +184 -0
- package/src/file_preview.web.tsx +329 -6
- package/src/file_preview_types.ts +40 -0
- package/src/file_row.tsx +114 -0
- package/src/file_rows.tsx +105 -0
- package/src/file_thumbnail.tsx +121 -79
- package/src/file_thumbnail_grid.tsx +130 -55
- package/src/icon_button.tsx +12 -1
- package/src/image_gallery.tsx +0 -4
- package/src/pdfjs_worker.d.ts +5 -0
- package/src/uploading_thumbnail.tsx +212 -0
- package/src/hover_action.tsx +0 -54
|
@@ -1,39 +1,54 @@
|
|
|
1
|
-
// FileGalleryModal —
|
|
2
|
-
//
|
|
3
|
-
// every previewable type
|
|
4
|
-
//
|
|
5
|
-
//
|
|
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,
|
|
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
|
|
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
|
-
|
|
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
|
-
*
|
|
58
|
-
*
|
|
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 {
|
|
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
|
|
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
|
|
109
|
-
|
|
110
|
-
|
|
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.
|
|
115
|
-
<
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
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
|
-
|
|
123
|
-
|
|
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
|
-
{
|
|
127
|
-
|
|
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={
|
|
272
|
+
onPress={prev}
|
|
130
273
|
accessibilityRole="button"
|
|
131
|
-
accessibilityLabel=
|
|
132
|
-
style={styles.
|
|
274
|
+
accessibilityLabel={l.previous}
|
|
275
|
+
style={[styles.nav, styles.navLeft]}
|
|
133
276
|
>
|
|
134
|
-
<Icon name="
|
|
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={
|
|
283
|
+
onPress={next}
|
|
138
284
|
accessibilityRole="button"
|
|
139
|
-
accessibilityLabel=
|
|
140
|
-
style={styles.
|
|
285
|
+
accessibilityLabel={l.next}
|
|
286
|
+
style={[styles.nav, styles.navRight]}
|
|
141
287
|
>
|
|
142
|
-
<Icon name="
|
|
288
|
+
<Icon name="chevron-right" size={28} color={colors.white} />
|
|
143
289
|
</Pressable>
|
|
144
|
-
|
|
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
|
-
|
|
298
|
+
surface: {
|
|
192
299
|
flex: 1,
|
|
193
|
-
backgroundColor:
|
|
194
|
-
|
|
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
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
312
|
+
filename: {
|
|
313
|
+
flexShrink: 1,
|
|
314
|
+
},
|
|
315
|
+
counter: {
|
|
316
|
+
flexShrink: 0,
|
|
317
|
+
},
|
|
318
|
+
spacer: {
|
|
319
|
+
flex: 1,
|
|
203
320
|
},
|
|
204
|
-
|
|
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
|
+
}
|