@lotics/ui 5.8.0 → 5.10.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 +184 -56
- package/examples/tpl_approvals.tsx +3 -3
- package/examples/tpl_convert.tsx +11 -23
- package/examples/tpl_dossier.tsx +5 -4
- package/examples/tpl_order.tsx +1 -1
- package/examples/tpl_pipeline.tsx +400 -0
- package/examples/tpl_ratedesk.tsx +1 -1
- package/examples/tpl_record.tsx +6 -5
- package/examples/tpl_shifts.tsx +7 -5
- package/examples/tpl_task_board.tsx +473 -0
- package/examples/tpl_tasks.tsx +440 -0
- package/package.json +19 -6
- package/src/accordion.tsx +1 -1
- package/src/agent_progress.tsx +1 -1
- package/src/agent_run.tsx +1 -1
- package/src/breakdown.tsx +2 -2
- package/src/button.tsx +12 -3
- package/src/calendar/calendar_view.tsx +10 -10
- package/src/calendar/month_view.tsx +30 -11
- package/src/calendar/time_grid_view.tsx +25 -7
- package/src/card_select_item.tsx +11 -13
- package/src/check_circle.tsx +88 -0
- package/src/checkbox_input.tsx +5 -1
- package/src/chip.tsx +1 -1
- package/src/chip_group.tsx +1 -1
- package/src/column_filter.tsx +3 -2
- package/src/combobox.tsx +84 -219
- package/src/confidence.tsx +14 -3
- package/src/control_surface.ts +22 -7
- package/src/data_grid.tsx +159 -0
- package/src/date_calendar.tsx +46 -23
- package/src/date_field.tsx +10 -2
- package/src/date_filter.tsx +5 -9
- package/src/date_picker.tsx +78 -26
- package/src/date_picker_value.ts +11 -0
- package/src/date_range_filter_field.tsx +6 -4
- package/src/drawer.tsx +1 -1
- package/src/file_dropzone.tsx +5 -0
- package/src/file_gallery_modal.tsx +227 -139
- package/src/file_grid.tsx +184 -0
- package/src/file_preview.web.tsx +349 -12
- package/src/file_preview_types.ts +49 -0
- package/src/file_row.tsx +118 -0
- package/src/file_rows.tsx +109 -0
- package/src/file_thumbnail.tsx +132 -81
- package/src/file_thumbnail_grid.tsx +135 -55
- package/src/files_editor.tsx +232 -0
- package/src/filter_chip.tsx +25 -12
- package/src/focus_ring_pressable.tsx +34 -0
- package/src/form_picker.tsx +3 -3
- package/src/gantt/gantt_view.tsx +25 -7
- package/src/heatmap.tsx +5 -1
- package/src/icon_button.tsx +14 -3
- package/src/image_gallery.tsx +8 -14
- package/src/index.css +8 -12
- package/src/inline_date_picker.tsx +11 -11
- package/src/inline_edit.tsx +22 -6
- package/src/inline_member_select.tsx +1 -1
- package/src/inline_select.tsx +9 -8
- package/src/inline_text_input.tsx +4 -1
- package/src/link.tsx +11 -1
- package/src/link_button.tsx +1 -1
- package/src/list_item.tsx +2 -2
- package/src/member_select.tsx +9 -4
- package/src/menu_button.tsx +2 -2
- package/src/menu_list_item.tsx +2 -2
- package/src/number_input.tsx +11 -4
- package/src/option_list.tsx +211 -0
- package/src/pdfjs_worker.d.ts +5 -0
- package/src/peek.tsx +1 -1
- package/src/picker.tsx +24 -213
- package/src/pressable_highlight.tsx +42 -23
- package/src/radio_picker.tsx +2 -2
- package/src/range_slider.tsx +35 -7
- package/src/react_native.d.ts +3 -0
- package/src/scroll_to_bottom.tsx +1 -1
- package/src/section.tsx +7 -2
- package/src/segmented_control.tsx +2 -2
- package/src/select.tsx +299 -0
- package/src/sort_header.tsx +1 -1
- package/src/sources.tsx +1 -1
- package/src/spreadsheet_view.tsx +43 -17
- package/src/status_grid.tsx +4 -3
- package/src/stepper.tsx +1 -1
- package/src/switch.tsx +5 -1
- package/src/switch_button.tsx +1 -1
- package/src/switcher.tsx +1 -1
- package/src/table.tsx +5 -1
- package/src/tabs.tsx +2 -2
- package/src/text_input_field.tsx +15 -4
- package/src/time_picker.tsx +11 -4
- package/src/timeline.tsx +1 -1
- package/src/uploading_thumbnail.tsx +212 -0
- package/src/use_focus_ring.ts +80 -0
- package/src/use_hover.ts +26 -0
- package/src/use_list_keyboard_nav.test.ts +71 -0
- package/src/use_list_keyboard_nav.ts +52 -5
- package/src/use_option_list.test.ts +193 -0
- package/src/use_option_list.ts +354 -0
- package/src/command_menu.tsx +0 -205
- package/src/hover_action.tsx +0 -54
- package/src/picker_menu.tsx +0 -355
- package/src/tag_input.tsx +0 -203
- package/src/time_field.tsx +0 -297
|
@@ -1,39 +1,53 @@
|
|
|
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, type ReactNode } from "react";
|
|
9
|
+
import { Linking, Modal, Platform, 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 { Button } from "./button";
|
|
14
|
+
import { FocusRingPressable } from "./focus_ring_pressable";
|
|
15
|
+
import { Alert } from "./alert";
|
|
16
16
|
import { colors } from "./colors";
|
|
17
17
|
import type { DisplayFile } from "./file_thumbnail";
|
|
18
18
|
import { FilePreview } from "./file_preview";
|
|
19
19
|
import { isImageMimeType } from "./mime";
|
|
20
20
|
import { useImageRotation, type ImageRotation } from "./use_image_rotation";
|
|
21
|
-
import type
|
|
21
|
+
import { resolveGalleryLabels, type GalleryLabels } from "./file_preview_types";
|
|
22
|
+
import { downloadFileFromUrl } from "./download";
|
|
22
23
|
|
|
23
24
|
export interface FileGalleryModalProps {
|
|
24
25
|
files: DisplayFile[];
|
|
25
26
|
/** Index of the file currently shown. When null, the modal is closed. */
|
|
26
27
|
activeIndex: number | null;
|
|
27
28
|
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>;
|
|
29
|
+
/** Translated chrome + preview strings; English fallback when omitted. */
|
|
30
|
+
labels?: Partial<GalleryLabels>;
|
|
35
31
|
/** Reported when a file fails to render (host wires to its logger). */
|
|
36
32
|
onError?: (error: unknown, meta: { fileId: string; mimeType: string }) => void;
|
|
33
|
+
/**
|
|
34
|
+
* Override the toolbar's Download action. Omit ⇒ a built-in default (web:
|
|
35
|
+
* fetch+save the presigned URL; native: open it externally) — correct for a
|
|
36
|
+
* custom-code app's same-origin presigned R2 URLs. The host frontend passes
|
|
37
|
+
* its own, which sends `credentials: "include"` for auth-gated proxy URLs.
|
|
38
|
+
*/
|
|
39
|
+
onDownload?: (file: DisplayFile) => void;
|
|
40
|
+
/**
|
|
41
|
+
* Open the active file outside the modal (a new browser tab on the frontend, the
|
|
42
|
+
* SDK's `openExternal` inside a custom-code app — the sandbox can't pop tabs).
|
|
43
|
+
* When omitted, the action is hidden.
|
|
44
|
+
*/
|
|
45
|
+
onOpenExternal?: (file: DisplayFile) => void;
|
|
46
|
+
/**
|
|
47
|
+
* Remove the active file. When provided, a Remove action (with a confirm) shows
|
|
48
|
+
* in the actions menu; the host drops the file from its own state.
|
|
49
|
+
*/
|
|
50
|
+
onRemove?: (fileId: string) => void;
|
|
37
51
|
/**
|
|
38
52
|
* Optional shared rotation state (from `useImageRotation`) — pass it to keep an
|
|
39
53
|
* inline gallery and this modal in sync. Omitted ⇒ the modal keeps its own
|
|
@@ -42,6 +56,21 @@ export interface FileGalleryModalProps {
|
|
|
42
56
|
rotation?: ImageRotation;
|
|
43
57
|
/** Show 90° rotate controls for image files. Default true. */
|
|
44
58
|
rotatable?: boolean;
|
|
59
|
+
/**
|
|
60
|
+
* Override how the active file's CONTENT is rendered (the toolbar/nav chrome is
|
|
61
|
+
* unchanged). Omit ⇒ the built-in `FilePreview`. The host frontend passes this
|
|
62
|
+
* to route Excel to its live editable workbook while delegating every other
|
|
63
|
+
* type to `FilePreview`; custom-code apps never set it. `rotation` (degrees) is
|
|
64
|
+
* forwarded so the override can honor the rotate controls for images.
|
|
65
|
+
*/
|
|
66
|
+
renderPreview?: (file: DisplayFile, opts: { rotation: number }) => ReactNode;
|
|
67
|
+
/**
|
|
68
|
+
* Credentials mode for the built-in `FilePreview`'s byte fetches. The host
|
|
69
|
+
* frontend passes `"include"` so the session cookie rides the cross-origin
|
|
70
|
+
* fetch to its auth-gated proxy; custom-code apps omit it (presigned URLs).
|
|
71
|
+
* Ignored when `renderPreview` is set (the override owns its own fetching).
|
|
72
|
+
*/
|
|
73
|
+
credentials?: RequestCredentials;
|
|
45
74
|
/**
|
|
46
75
|
* Persist the active image's current view-rotation as a new stored file. When
|
|
47
76
|
* provided and the active image is rotated, a confirm (✓) control appears next
|
|
@@ -54,13 +83,27 @@ export interface FileGalleryModalProps {
|
|
|
54
83
|
}
|
|
55
84
|
|
|
56
85
|
/**
|
|
57
|
-
*
|
|
58
|
-
*
|
|
86
|
+
* Full-screen file preview gallery. Renders any previewable MIME type inline via
|
|
87
|
+
* FilePreview; unknown types show a download placeholder.
|
|
59
88
|
*/
|
|
60
89
|
export function FileGalleryModal(props: FileGalleryModalProps) {
|
|
61
|
-
const {
|
|
90
|
+
const {
|
|
91
|
+
files,
|
|
92
|
+
activeIndex,
|
|
93
|
+
onIndexChange,
|
|
94
|
+
labels,
|
|
95
|
+
onError,
|
|
96
|
+
onDownload,
|
|
97
|
+
onOpenExternal,
|
|
98
|
+
onRemove,
|
|
99
|
+
rotation,
|
|
100
|
+
rotatable = true,
|
|
101
|
+
renderPreview,
|
|
102
|
+
credentials,
|
|
103
|
+
onPersistRotation,
|
|
104
|
+
persisting,
|
|
105
|
+
} = props;
|
|
62
106
|
const visible = activeIndex !== null;
|
|
63
|
-
const overlayRef = useRef<View | null>(null);
|
|
64
107
|
// Own the rotation when the host doesn't share one (standalone consumers).
|
|
65
108
|
const internalRotation = useImageRotation();
|
|
66
109
|
const rot = rotation ?? internalRotation;
|
|
@@ -103,84 +146,152 @@ export function FileGalleryModal(props: FileGalleryModalProps) {
|
|
|
103
146
|
const file = files[activeIndex];
|
|
104
147
|
if (!file) return null;
|
|
105
148
|
|
|
149
|
+
const l = resolveGalleryLabels(labels);
|
|
106
150
|
const isImage = isImageMimeType(file.mimeType);
|
|
107
151
|
const total = files.length;
|
|
108
|
-
const
|
|
109
|
-
|
|
110
|
-
|
|
152
|
+
const rotated = rot.rotationFor(file.id) !== 0;
|
|
153
|
+
|
|
154
|
+
const handleDownload = () => {
|
|
155
|
+
if (onDownload) {
|
|
156
|
+
onDownload(file);
|
|
157
|
+
return;
|
|
158
|
+
}
|
|
159
|
+
// Default: the web fetch+save path. `credentials` rides the host's auth-gated
|
|
160
|
+
// proxy URLs (presigned app URLs are same-origin and omit it). On native the
|
|
161
|
+
// web primitive can't run, so open the URL externally.
|
|
162
|
+
if (Platform.OS === "web") {
|
|
163
|
+
void downloadFileFromUrl(file.url, file.filename, { credentials });
|
|
164
|
+
} else {
|
|
165
|
+
void Linking.openURL(file.url);
|
|
166
|
+
}
|
|
167
|
+
};
|
|
168
|
+
const handleOpenExternal = () => {
|
|
169
|
+
onOpenExternal?.(file);
|
|
170
|
+
};
|
|
171
|
+
const handleRemove = () => {
|
|
172
|
+
if (!onRemove) return;
|
|
173
|
+
Alert.alert(l.removeConfirmTitle, l.removeConfirmMessage(file.filename), [
|
|
174
|
+
{ text: l.cancel, style: "cancel" },
|
|
175
|
+
{
|
|
176
|
+
text: l.remove,
|
|
177
|
+
style: "destructive",
|
|
178
|
+
onPress: () => {
|
|
179
|
+
if (total === 1) {
|
|
180
|
+
onRemove(file.id);
|
|
181
|
+
close();
|
|
182
|
+
return;
|
|
183
|
+
}
|
|
184
|
+
// Pre-move before the list shrinks, clamping off the end.
|
|
185
|
+
onIndexChange(activeIndex >= total - 1 ? activeIndex - 1 : activeIndex);
|
|
186
|
+
onRemove(file.id);
|
|
187
|
+
},
|
|
188
|
+
},
|
|
189
|
+
]);
|
|
190
|
+
};
|
|
111
191
|
|
|
112
192
|
return (
|
|
113
193
|
<Modal visible transparent onRequestClose={close} animationType="fade">
|
|
114
|
-
<View style={styles.
|
|
115
|
-
<
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
194
|
+
<View style={styles.surface}>
|
|
195
|
+
<View style={styles.toolbar}>
|
|
196
|
+
<Text size="sm" weight="medium" numberOfLines={1} style={styles.filename}>
|
|
197
|
+
{file.filename}
|
|
198
|
+
</Text>
|
|
199
|
+
{total > 1 ? (
|
|
200
|
+
<Text size="xs" color="muted" style={styles.counter}>
|
|
201
|
+
{`${activeIndex + 1} / ${total}`}
|
|
202
|
+
</Text>
|
|
203
|
+
) : null}
|
|
204
|
+
<View style={styles.spacer} />
|
|
121
205
|
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
206
|
+
{isImage && rotatable ? (
|
|
207
|
+
<>
|
|
208
|
+
<IconButton
|
|
209
|
+
size="lg"
|
|
210
|
+
icon="rotate-ccw"
|
|
211
|
+
tooltip={l.rotateLeft}
|
|
212
|
+
color="secondary"
|
|
213
|
+
onPress={() => rot.rotate(file.id, -1)}
|
|
214
|
+
/>
|
|
215
|
+
<IconButton
|
|
216
|
+
size="lg"
|
|
217
|
+
icon="rotate-cw"
|
|
218
|
+
tooltip={l.rotateRight}
|
|
219
|
+
color="secondary"
|
|
220
|
+
onPress={() => rot.rotate(file.id, 1)}
|
|
221
|
+
/>
|
|
222
|
+
{onPersistRotation && rotated ? (
|
|
223
|
+
<IconButton
|
|
224
|
+
size="lg"
|
|
225
|
+
icon="check"
|
|
226
|
+
tooltip={l.saveRotation}
|
|
227
|
+
color="primary"
|
|
228
|
+
disabled={persisting}
|
|
229
|
+
onPress={() => {
|
|
230
|
+
if (!persisting) onPersistRotation(file, rot.rotationFor(file.id));
|
|
231
|
+
}}
|
|
232
|
+
/>
|
|
233
|
+
) : null}
|
|
234
|
+
</>
|
|
235
|
+
) : null}
|
|
125
236
|
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
onPress={
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
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}
|
|
237
|
+
<Button
|
|
238
|
+
icon="download"
|
|
239
|
+
title={l.download}
|
|
240
|
+
color="secondary"
|
|
241
|
+
onPress={handleDownload}
|
|
242
|
+
/>
|
|
243
|
+
{onOpenExternal ? (
|
|
244
|
+
<Button
|
|
245
|
+
icon="external-link"
|
|
246
|
+
title={l.openExternal}
|
|
247
|
+
color="secondary"
|
|
248
|
+
onPress={handleOpenExternal}
|
|
249
|
+
/>
|
|
250
|
+
) : null}
|
|
251
|
+
{onRemove ? (
|
|
252
|
+
<Button
|
|
253
|
+
icon="trash"
|
|
254
|
+
title={l.remove}
|
|
255
|
+
color="danger-secondary"
|
|
256
|
+
onPress={handleRemove}
|
|
257
|
+
/>
|
|
258
|
+
) : null}
|
|
157
259
|
|
|
158
|
-
|
|
159
|
-
|
|
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}
|
|
260
|
+
<IconButton size="lg" icon="x" tooltip={l.close} color="secondary" onPress={close} />
|
|
261
|
+
</View>
|
|
168
262
|
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
263
|
+
<View style={styles.previewArea}>
|
|
264
|
+
{renderPreview ? (
|
|
265
|
+
renderPreview(file, { rotation: rot.rotationFor(file.id) })
|
|
266
|
+
) : (
|
|
267
|
+
<FilePreview
|
|
268
|
+
file={file}
|
|
269
|
+
labels={labels}
|
|
270
|
+
onError={onError}
|
|
271
|
+
rotation={rot.rotationFor(file.id)}
|
|
272
|
+
credentials={credentials}
|
|
273
|
+
/>
|
|
274
|
+
)}
|
|
179
275
|
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
276
|
+
{activeIndex > 0 ? (
|
|
277
|
+
<FocusRingPressable accessibilityRole="button"
|
|
278
|
+
onPress={prev}
|
|
279
|
+
accessibilityLabel={l.previous}
|
|
280
|
+
style={[styles.nav, styles.navLeft]}
|
|
281
|
+
>
|
|
282
|
+
<Icon name="chevron-left" size={28} color={colors.white} />
|
|
283
|
+
</FocusRingPressable>
|
|
284
|
+
) : null}
|
|
285
|
+
|
|
286
|
+
{activeIndex < total - 1 ? (
|
|
287
|
+
<FocusRingPressable accessibilityRole="button"
|
|
288
|
+
onPress={next}
|
|
289
|
+
accessibilityLabel={l.next}
|
|
290
|
+
style={[styles.nav, styles.navRight]}
|
|
291
|
+
>
|
|
292
|
+
<Icon name="chevron-right" size={28} color={colors.white} />
|
|
293
|
+
</FocusRingPressable>
|
|
294
|
+
) : null}
|
|
184
295
|
</View>
|
|
185
296
|
</View>
|
|
186
297
|
</Modal>
|
|
@@ -188,20 +299,34 @@ export function FileGalleryModal(props: FileGalleryModalProps) {
|
|
|
188
299
|
}
|
|
189
300
|
|
|
190
301
|
const styles = StyleSheet.create({
|
|
191
|
-
|
|
302
|
+
surface: {
|
|
192
303
|
flex: 1,
|
|
193
|
-
backgroundColor:
|
|
194
|
-
|
|
304
|
+
backgroundColor: colors.background,
|
|
305
|
+
},
|
|
306
|
+
toolbar: {
|
|
307
|
+
flexDirection: "row",
|
|
195
308
|
alignItems: "center",
|
|
309
|
+
gap: 8,
|
|
310
|
+
paddingHorizontal: 12,
|
|
311
|
+
paddingVertical: 8,
|
|
312
|
+
borderBottomWidth: 1,
|
|
313
|
+
borderBottomColor: colors.border,
|
|
314
|
+
zIndex: 1,
|
|
196
315
|
},
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
overflow: "hidden",
|
|
316
|
+
filename: {
|
|
317
|
+
flexShrink: 1,
|
|
318
|
+
},
|
|
319
|
+
counter: {
|
|
320
|
+
flexShrink: 0,
|
|
203
321
|
},
|
|
204
|
-
|
|
322
|
+
spacer: {
|
|
323
|
+
flex: 1,
|
|
324
|
+
},
|
|
325
|
+
previewArea: {
|
|
326
|
+
flex: 1,
|
|
327
|
+
justifyContent: "center",
|
|
328
|
+
},
|
|
329
|
+
nav: {
|
|
205
330
|
position: "absolute",
|
|
206
331
|
top: "50%",
|
|
207
332
|
transform: [{ translateY: -28 }],
|
|
@@ -218,41 +343,4 @@ const styles = StyleSheet.create({
|
|
|
218
343
|
navRight: {
|
|
219
344
|
right: 16,
|
|
220
345
|
},
|
|
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
346
|
});
|
|
@@ -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
|
+
}
|