@lotics/ui 20.1.0 → 21.0.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 +2 -2
- package/MIGRATION.md +57 -0
- package/docs/ai_patterns.md +38 -0
- package/docs/catalog.md +42 -21
- package/docs/data_entry.md +55 -23
- package/examples/tpl_item_list.tsx +55 -50
- package/examples/tpl_task_board.tsx +11 -2
- package/package.json +2 -1
- package/src/agent_run_pane.tsx +134 -0
- package/src/file_grid.tsx +14 -2
- package/src/files_editor.tsx +241 -132
- package/src/locale.tsx +74 -0
- package/src/uploading_thumbnail.tsx +5 -3
package/src/files_editor.tsx
CHANGED
|
@@ -1,8 +1,6 @@
|
|
|
1
|
-
import { useState } from "react";
|
|
1
|
+
import { createContext, useCallback, useContext, useMemo, useState, type ReactNode } from "react";
|
|
2
2
|
import { View, ScrollView, StyleSheet } from "react-native";
|
|
3
3
|
import { Button } from "./button";
|
|
4
|
-
import { Popover, PopoverTrigger, PopoverContent } from "./popover";
|
|
5
|
-
import { MenuButton } from "./menu_button";
|
|
6
4
|
import { Alert } from "./alert";
|
|
7
5
|
import { FileGrid, type FileUpload } from "./file_grid";
|
|
8
6
|
import { FileGalleryModal } from "./file_gallery_modal";
|
|
@@ -10,21 +8,22 @@ import type { GalleryLabels } from "./file_preview_types";
|
|
|
10
8
|
import { type DisplayFile } from "./file_thumbnail";
|
|
11
9
|
import { pickFiles } from "./file_picker";
|
|
12
10
|
import { downloadFileFromUrl } from "./download";
|
|
11
|
+
import { useLoticsLocale } from "./locale";
|
|
13
12
|
|
|
13
|
+
/**
|
|
14
|
+
* The words the shipped bar pieces say. Each resolves **prop → `LoticsLocale`
|
|
15
|
+
* (`filesEditor`) → nothing**, so an app localizes by supplying its pack once at
|
|
16
|
+
* the root. A HOST verb is a plain `Button` and names itself.
|
|
17
|
+
*/
|
|
14
18
|
export interface FilesEditorLabels {
|
|
15
19
|
upload: string;
|
|
16
20
|
select: string;
|
|
17
21
|
selectAll: string;
|
|
18
22
|
deselectAll: string;
|
|
19
23
|
done: string;
|
|
20
|
-
/** The select-mode actions menu trigger. */
|
|
21
|
-
menu: string;
|
|
22
24
|
downloadAll: string;
|
|
23
|
-
/** Download-selected
|
|
25
|
+
/** Download-selected, given the count. */
|
|
24
26
|
downloadSelected: (n: number) => string;
|
|
25
|
-
/** Download-as-ZIP menu item (shown when `onDownloadZipSelected` + 2+ selected). */
|
|
26
|
-
downloadZip: string;
|
|
27
|
-
share: string;
|
|
28
27
|
delete: string;
|
|
29
28
|
removeTitle: string;
|
|
30
29
|
removeMessage: string;
|
|
@@ -32,83 +31,151 @@ export interface FilesEditorLabels {
|
|
|
32
31
|
removeConfirm: string;
|
|
33
32
|
}
|
|
34
33
|
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
34
|
+
interface FilesEditorCtx {
|
|
35
|
+
files: DisplayFile[];
|
|
36
|
+
selectMode: boolean;
|
|
37
|
+
setSelectMode: (on: boolean) => void;
|
|
38
|
+
selectedIds: Set<string>;
|
|
39
|
+
selected: DisplayFile[];
|
|
40
|
+
toggle: (id: string) => void;
|
|
41
|
+
setAll: (on: boolean) => void;
|
|
42
|
+
clear: () => void;
|
|
43
|
+
exit: () => void;
|
|
44
|
+
onAdd?: (files: File[]) => void;
|
|
45
|
+
onUpload?: () => void;
|
|
46
|
+
onRemove?: (id: string) => void;
|
|
47
|
+
accept?: string;
|
|
48
|
+
labels: FilesEditorLabels;
|
|
49
|
+
download: (files: DisplayFile[]) => void;
|
|
50
|
+
confirmRemove: (files: DisplayFile[], onConfirm: () => void) => void;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const Ctx = createContext<FilesEditorCtx | null>(null);
|
|
54
|
+
|
|
55
|
+
function useCtx(who: string): FilesEditorCtx {
|
|
56
|
+
const ctx = useContext(Ctx);
|
|
57
|
+
if (!ctx) throw new Error(`<${who}> must be rendered inside <FilesEditor>`);
|
|
58
|
+
return ctx;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* What a HOST verb reads: the files the user picked, and how to stand down
|
|
63
|
+
* afterwards. This is the whole point of the compound — an action the kit has
|
|
64
|
+
* never heard of ("read these with AI", "attach to the shipment", "send to the
|
|
65
|
+
* broker") is a plain `Button` in the bar that calls this.
|
|
66
|
+
*
|
|
67
|
+
* ```tsx
|
|
68
|
+
* function ReadWithAi() {
|
|
69
|
+
* const { selected, exit } = useFilesEditorSelection();
|
|
70
|
+
* return <Button title="Đọc bằng AI" disabled={selected.length === 0}
|
|
71
|
+
* onPress={() => { run(selected); exit(); }} />;
|
|
72
|
+
* }
|
|
73
|
+
* ```
|
|
74
|
+
*/
|
|
75
|
+
export function useFilesEditorSelection(): {
|
|
76
|
+
/** The picked files, in the grid's order. Empty outside select mode. */
|
|
77
|
+
selected: DisplayFile[];
|
|
78
|
+
/** Their ids. */
|
|
79
|
+
selectedIds: string[];
|
|
80
|
+
/** Every file on the surface, picked or not. */
|
|
81
|
+
files: DisplayFile[];
|
|
82
|
+
/** Is the surface in batch-select mode? */
|
|
83
|
+
selectMode: boolean;
|
|
84
|
+
/** Drop the selection, stay in select mode. */
|
|
85
|
+
clear: () => void;
|
|
86
|
+
/** Drop the selection AND leave select mode — what a verb does when it is done. */
|
|
87
|
+
exit: () => void;
|
|
88
|
+
} {
|
|
89
|
+
const ctx = useCtx("useFilesEditorSelection");
|
|
90
|
+
return {
|
|
91
|
+
selected: ctx.selected,
|
|
92
|
+
selectedIds: ctx.selected.map((f) => f.id),
|
|
93
|
+
files: ctx.files,
|
|
94
|
+
selectMode: ctx.selectMode,
|
|
95
|
+
clear: ctx.clear,
|
|
96
|
+
exit: ctx.exit,
|
|
97
|
+
};
|
|
98
|
+
}
|
|
52
99
|
|
|
53
100
|
export interface FilesEditorProps {
|
|
54
101
|
/** Completed/saved files. */
|
|
55
102
|
files: DisplayFile[];
|
|
56
103
|
/** The live add-queue (in-flight uploads) — pass through from your upload mechanism. */
|
|
57
104
|
uploads?: FileUpload[];
|
|
58
|
-
/** Picked files → the host uploads + appends to `files`. Omit to hide
|
|
105
|
+
/** Picked files → the host uploads + appends to `files`. Omit to hide `FilesEditorUpload`. */
|
|
59
106
|
onAdd?: (files: File[]) => void;
|
|
60
107
|
/** Override the Upload action's picker (e.g. a native document picker). When
|
|
61
|
-
* set,
|
|
108
|
+
* set, `FilesEditorUpload` calls this instead of the built-in web `pickFiles`. */
|
|
62
109
|
onUpload?: () => void;
|
|
63
|
-
/**
|
|
64
|
-
onDownloadZipSelected?: (files: DisplayFile[]) => void;
|
|
65
|
-
/** Remove a completed file. Omit (or `readOnly`) for a view-only surface. */
|
|
110
|
+
/** Remove a completed file. Omit to hide `FilesEditorRemove` and the per-tile ✕. */
|
|
66
111
|
onRemove?: (id: string) => void;
|
|
67
112
|
/** Show the per-tile ✕ during SELECT mode (default `true`). The default
|
|
68
113
|
* (non-select) view NEVER shows a ✕ regardless — this governs select mode
|
|
69
|
-
* only. Set `false` when
|
|
70
|
-
*
|
|
71
|
-
* nothing else. */
|
|
114
|
+
* only. Set `false` when a bar action is the sole delete path, so a tile in
|
|
115
|
+
* select mode toggles selection and nothing else. */
|
|
72
116
|
selectTileRemove?: boolean;
|
|
73
117
|
/** Cancel an in-flight upload / retry a failed one (when using `uploads`). */
|
|
74
118
|
onUploadRemove?: (id: string) => void;
|
|
75
119
|
onRetry?: (id: string) => void;
|
|
76
120
|
onRetryAll?: () => void;
|
|
77
|
-
/** Show a Share action in select mode, given the selected files. Omit to hide it. */
|
|
78
|
-
onShareSelected?: (files: DisplayFile[]) => void;
|
|
79
121
|
/** Override the default download (`downloadFileFromUrl`). */
|
|
80
122
|
onDownload?: (file: DisplayFile) => void;
|
|
81
|
-
/** Download + preview only (no Upload / Select / remove). */
|
|
82
|
-
readOnly?: boolean;
|
|
83
123
|
/** Native `accept` filter for the file picker. */
|
|
84
124
|
accept?: string;
|
|
85
125
|
itemSize?: number;
|
|
86
126
|
minItemWidth?: number;
|
|
87
127
|
/** Fixed column count for the grid (passed through to `FileGrid`). */
|
|
88
128
|
columns?: number;
|
|
89
|
-
/** Cap the grid's height (px) so it SCROLLS and the
|
|
129
|
+
/** Cap the grid's height (px) so it SCROLLS and the bar pins below it —
|
|
90
130
|
* for a height-bounded container (a popover, a drawer). Omit in free-flow
|
|
91
131
|
* layouts (a form field) where the grid should grow and the page scrolls. */
|
|
92
132
|
gridMaxHeight?: number;
|
|
93
|
-
/**
|
|
133
|
+
/** Per-instance overrides for the shipped bar pieces. Falls back to the
|
|
134
|
+
* active `LoticsLocale`. */
|
|
94
135
|
labels?: Partial<FilesEditorLabels>;
|
|
95
136
|
/** Credentials mode for the built-in gallery's preview fetches — `"include"`
|
|
96
137
|
* for the host's auth-gated proxy URLs, omitted for an app's presigned URLs.
|
|
97
138
|
* See `FilePreviewProps`. */
|
|
98
139
|
credentials?: RequestCredentials;
|
|
99
140
|
/** Translated labels for the built-in full-screen gallery (its toolbar action
|
|
100
|
-
* buttons + confirm dialog). Defaults to
|
|
141
|
+
* buttons + confirm dialog). Defaults to the active locale. */
|
|
101
142
|
galleryLabels?: Partial<GalleryLabels>;
|
|
143
|
+
/**
|
|
144
|
+
* The bar (and anything else) BELOW the grid. Compose it from the shipped
|
|
145
|
+
* pieces plus your own verbs; omit it entirely for a grid that only previews.
|
|
146
|
+
*/
|
|
147
|
+
children?: ReactNode;
|
|
102
148
|
}
|
|
103
149
|
|
|
104
150
|
/**
|
|
105
|
-
* The
|
|
106
|
-
*
|
|
107
|
-
*
|
|
108
|
-
*
|
|
109
|
-
*
|
|
110
|
-
*
|
|
111
|
-
*
|
|
151
|
+
* The attachment surface, as a COMPOUND: this root owns the parts a host cannot
|
|
152
|
+
* reasonably re-implement — the batch selection, the full-screen gallery, and the
|
|
153
|
+
* Alert-confirmed remove — and renders the upload-aware grid (`FileGrid`). What
|
|
154
|
+
* you can DO to the files is composed below it, so a surface offers exactly its
|
|
155
|
+
* own verbs in its own order.
|
|
156
|
+
*
|
|
157
|
+
* It was a monolith with a fixed toolbar and a generic "Menu" holding whatever
|
|
158
|
+
* the kit happened to support (download, share, delete). That shape had two
|
|
159
|
+
* costs, and both showed up in real apps: a HOST verb had nowhere to go, so a
|
|
160
|
+
* screen that needed one (an AI read over the picked papers) hand-rolled the
|
|
161
|
+
* whole grid and lost the gallery and the upload queue with it; and the actions
|
|
162
|
+
* that DID exist hid behind a label that names a widget rather than an act, five
|
|
163
|
+
* interactions deep for "delete this scan".
|
|
164
|
+
*
|
|
165
|
+
* ```tsx
|
|
166
|
+
* <FilesEditor files={docs} uploads={queue} onAdd={add} onRemove={remove}>
|
|
167
|
+
* <FilesEditorBar>
|
|
168
|
+
* <FilesEditorUpload />
|
|
169
|
+
* <FilesEditorSelect />
|
|
170
|
+
* <FilesEditorBarSpacer />
|
|
171
|
+
* <FilesEditorDownload />
|
|
172
|
+
* <FilesEditorRemove />
|
|
173
|
+
* </FilesEditorBar>
|
|
174
|
+
* </FilesEditor>
|
|
175
|
+
* ```
|
|
176
|
+
*
|
|
177
|
+
* For a plain row list use `FileRows`; for the bare grid with no selection or
|
|
178
|
+
* gallery, `FileGrid`.
|
|
112
179
|
*/
|
|
113
180
|
export function FilesEditor(props: FilesEditorProps) {
|
|
114
181
|
const {
|
|
@@ -116,15 +183,12 @@ export function FilesEditor(props: FilesEditorProps) {
|
|
|
116
183
|
uploads,
|
|
117
184
|
onAdd,
|
|
118
185
|
onUpload,
|
|
119
|
-
onDownloadZipSelected,
|
|
120
186
|
onRemove,
|
|
121
187
|
selectTileRemove = true,
|
|
122
188
|
onUploadRemove,
|
|
123
189
|
onRetry,
|
|
124
190
|
onRetryAll,
|
|
125
|
-
onShareSelected,
|
|
126
191
|
onDownload,
|
|
127
|
-
readOnly = false,
|
|
128
192
|
accept,
|
|
129
193
|
itemSize,
|
|
130
194
|
minItemWidth,
|
|
@@ -133,31 +197,44 @@ export function FilesEditor(props: FilesEditorProps) {
|
|
|
133
197
|
labels,
|
|
134
198
|
credentials,
|
|
135
199
|
galleryLabels,
|
|
200
|
+
children,
|
|
136
201
|
} = props;
|
|
137
|
-
const
|
|
202
|
+
const loc = useLoticsLocale().filesEditor;
|
|
203
|
+
const l = useMemo(() => ({ ...loc, ...labels }), [loc, labels]);
|
|
138
204
|
|
|
139
|
-
const [selectMode,
|
|
140
|
-
const [
|
|
141
|
-
const [menuOpen, setMenuOpen] = useState(false);
|
|
205
|
+
const [selectMode, setSelectModeState] = useState(false);
|
|
206
|
+
const [selectedIds, setSelectedIds] = useState<Set<string>>(() => new Set());
|
|
142
207
|
const [galleryIndex, setGalleryIndex] = useState<number | null>(null);
|
|
143
208
|
|
|
144
|
-
const
|
|
145
|
-
|
|
146
|
-
const
|
|
209
|
+
const selected = useMemo(() => files.filter((f) => selectedIds.has(f.id)), [files, selectedIds]);
|
|
210
|
+
|
|
211
|
+
const clear = useCallback(() => setSelectedIds(new Set()), []);
|
|
212
|
+
const exit = useCallback(() => { setSelectModeState(false); setSelectedIds(new Set()); }, []);
|
|
213
|
+
const setSelectMode = useCallback((on: boolean) => { if (on) setSelectModeState(true); else exit(); }, [exit]);
|
|
214
|
+
const toggle = useCallback((id: string) => {
|
|
215
|
+
setSelectedIds((s) => { const n = new Set(s); if (n.has(id)) n.delete(id); else n.add(id); return n; });
|
|
216
|
+
}, []);
|
|
217
|
+
const setAll = useCallback((on: boolean) => {
|
|
218
|
+
setSelectedIds(on ? new Set(files.map((f) => f.id)) : new Set());
|
|
219
|
+
}, [files]);
|
|
220
|
+
|
|
221
|
+
const download = useCallback((fs: DisplayFile[]) => {
|
|
222
|
+
for (const f of fs) {
|
|
223
|
+
if (onDownload) onDownload(f);
|
|
224
|
+
else void downloadFileFromUrl(f.url, f.filename, { credentials });
|
|
225
|
+
}
|
|
226
|
+
}, [onDownload, credentials]);
|
|
147
227
|
|
|
148
|
-
const
|
|
149
|
-
setSelected((s) => { const n = new Set(s); if (n.has(id)) n.delete(id); else n.add(id); return n; });
|
|
150
|
-
const exitSelect = () => { setSelectMode(false); setSelected(new Set()); };
|
|
151
|
-
const download = (fs: DisplayFile[]) =>
|
|
152
|
-
fs.forEach((f) => (onDownload ? onDownload(f) : void downloadFileFromUrl(f.url, f.filename, { credentials })));
|
|
153
|
-
const confirmRemove = (onConfirm: () => void) =>
|
|
228
|
+
const confirmRemove = useCallback((fs: DisplayFile[], onConfirm: () => void) => {
|
|
154
229
|
Alert.alert(l.removeTitle, l.removeMessage, [
|
|
155
230
|
{ text: l.removeCancel, style: "cancel" },
|
|
156
231
|
{ text: l.removeConfirm, style: "destructive", onPress: onConfirm },
|
|
157
232
|
]);
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
233
|
+
}, [l.removeTitle, l.removeMessage, l.removeCancel, l.removeConfirm]);
|
|
234
|
+
|
|
235
|
+
const ctx: FilesEditorCtx = {
|
|
236
|
+
files, selectMode, setSelectMode, selectedIds, selected, toggle, setAll, clear, exit,
|
|
237
|
+
onAdd, onUpload, onRemove, accept, labels: l, download, confirmRemove,
|
|
161
238
|
};
|
|
162
239
|
|
|
163
240
|
const grid = (
|
|
@@ -167,14 +244,16 @@ export function FilesEditor(props: FilesEditorProps) {
|
|
|
167
244
|
itemSize={itemSize}
|
|
168
245
|
minItemWidth={minItemWidth}
|
|
169
246
|
columns={columns}
|
|
170
|
-
selectedIds={selectMode ?
|
|
247
|
+
selectedIds={selectMode ? selectedIds : undefined}
|
|
171
248
|
onToggleSelect={selectMode ? toggle : undefined}
|
|
172
249
|
onFilePress={selectMode ? undefined : (f) => setGalleryIndex(files.findIndex((x) => x.id === f.id))}
|
|
173
250
|
// The per-tile ✕ is a SELECT-mode affordance (opt-out via `selectTileRemove`)
|
|
174
251
|
// — the default view stays a clean preview surface so a stray tap can't drop a
|
|
175
|
-
// file, and a `selectTileRemove={false}` surface deletes only via the
|
|
252
|
+
// file, and a `selectTileRemove={false}` surface deletes only via the bar.
|
|
176
253
|
onDisplayRemove={
|
|
177
|
-
|
|
254
|
+
selectMode && selectTileRemove && onRemove
|
|
255
|
+
? (id) => confirmRemove(files.filter((f) => f.id === id), () => onRemove(id))
|
|
256
|
+
: undefined
|
|
178
257
|
}
|
|
179
258
|
onUploadRemove={onUploadRemove}
|
|
180
259
|
onRetry={onRetry}
|
|
@@ -183,74 +262,104 @@ export function FilesEditor(props: FilesEditorProps) {
|
|
|
183
262
|
);
|
|
184
263
|
|
|
185
264
|
return (
|
|
186
|
-
<
|
|
187
|
-
{
|
|
188
|
-
<ScrollView style={{ maxHeight: gridMaxHeight }}>{grid}</ScrollView>
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
265
|
+
<Ctx.Provider value={ctx}>
|
|
266
|
+
<View style={styles.root}>
|
|
267
|
+
{gridMaxHeight !== undefined ? <ScrollView style={{ maxHeight: gridMaxHeight }}>{grid}</ScrollView> : grid}
|
|
268
|
+
{children}
|
|
269
|
+
<FileGalleryModal
|
|
270
|
+
files={files}
|
|
271
|
+
activeIndex={galleryIndex}
|
|
272
|
+
onIndexChange={setGalleryIndex}
|
|
273
|
+
onRemove={onRemove}
|
|
274
|
+
onDownload={onDownload}
|
|
275
|
+
credentials={credentials}
|
|
276
|
+
labels={galleryLabels}
|
|
277
|
+
/>
|
|
278
|
+
</View>
|
|
279
|
+
</Ctx.Provider>
|
|
280
|
+
);
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
/** The action row under the grid. Lay it out left-to-right; a
|
|
284
|
+
* `FilesEditorBarSpacer` pushes what follows to the right edge. */
|
|
285
|
+
export function FilesEditorBar({ children }: { children: ReactNode }) {
|
|
286
|
+
return <View style={styles.bar}>{children}</View>;
|
|
287
|
+
}
|
|
192
288
|
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
<Button title={l.downloadAll} color="secondary" onPress={() => download(files)} />
|
|
198
|
-
</View>
|
|
199
|
-
) : null
|
|
200
|
-
) : selectMode ? (
|
|
201
|
-
<View style={styles.bar}>
|
|
202
|
-
<Button
|
|
203
|
-
title={allSelected ? l.deselectAll : l.selectAll}
|
|
204
|
-
color="muted"
|
|
205
|
-
onPress={() => setSelected(allSelected ? new Set() : new Set(files.map((f) => f.id)))}
|
|
206
|
-
/>
|
|
207
|
-
<Popover open={menuOpen} onOpenChange={setMenuOpen} align="start">
|
|
208
|
-
<PopoverTrigger>
|
|
209
|
-
<Button title={l.menu} color="secondary" disabled={selected.size === 0} onPress={() => setMenuOpen(true)} />
|
|
210
|
-
</PopoverTrigger>
|
|
211
|
-
<PopoverContent>
|
|
212
|
-
<View style={styles.menu}>
|
|
213
|
-
<MenuButton icon="download" title={l.downloadSelected(selected.size)} onPress={() => { setMenuOpen(false); download(selectedFiles); }} />
|
|
214
|
-
{onDownloadZipSelected && selected.size > 1 ? (
|
|
215
|
-
<MenuButton icon="file-down" title={l.downloadZip} onPress={() => { setMenuOpen(false); onDownloadZipSelected(selectedFiles); }} />
|
|
216
|
-
) : null}
|
|
217
|
-
{onShareSelected ? (
|
|
218
|
-
<MenuButton icon="share" title={l.share} onPress={() => { setMenuOpen(false); onShareSelected(selectedFiles); }} />
|
|
219
|
-
) : null}
|
|
220
|
-
{onRemove ? (
|
|
221
|
-
<MenuButton icon="trash" title={l.delete} danger onPress={() => { setMenuOpen(false); confirmRemove(() => { selectedFiles.forEach((f) => onRemove(f.id)); exitSelect(); }); }} />
|
|
222
|
-
) : null}
|
|
223
|
-
</View>
|
|
224
|
-
</PopoverContent>
|
|
225
|
-
</Popover>
|
|
226
|
-
<View style={styles.spacer} />
|
|
227
|
-
<Button title={l.done} color="secondary" onPress={exitSelect} />
|
|
228
|
-
</View>
|
|
229
|
-
) : (
|
|
230
|
-
<View style={styles.bar}>
|
|
231
|
-
{onAdd || onUpload ? <Button title={l.upload} color="primary" onPress={onUpload ?? upload} /> : null}
|
|
232
|
-
{hasFiles ? <Button title={l.select} color="secondary" onPress={() => setSelectMode(true)} /> : null}
|
|
233
|
-
<View style={styles.spacer} />
|
|
234
|
-
{hasFiles ? <Button title={l.downloadAll} color="secondary" onPress={() => download(files)} /> : null}
|
|
235
|
-
</View>
|
|
236
|
-
)}
|
|
289
|
+
/** Pushes the rest of the bar to the right edge. */
|
|
290
|
+
export function FilesEditorBarSpacer() {
|
|
291
|
+
return <View style={styles.spacer} />;
|
|
292
|
+
}
|
|
237
293
|
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
294
|
+
/** Add files. Renders nothing when the root was given neither `onAdd` nor
|
|
295
|
+
* `onUpload` — a surface cannot offer an upload it has no handler for. */
|
|
296
|
+
export function FilesEditorUpload({ title }: { title?: string } = {}) {
|
|
297
|
+
const { onAdd, onUpload, accept, labels } = useCtx("FilesEditorUpload");
|
|
298
|
+
if (!onAdd && !onUpload) return null;
|
|
299
|
+
const pick = () => {
|
|
300
|
+
if (onUpload) { onUpload(); return; }
|
|
301
|
+
void pickFiles({ multiple: true, accept }).then((picked) => { if (picked.length > 0) onAdd?.(picked); });
|
|
302
|
+
};
|
|
303
|
+
return <Button title={title ?? labels.upload} color="primary" onPress={pick} />;
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
/** Enter batch-select mode, and leave it. One control, because it is one
|
|
307
|
+
* toggle — it reads "Chọn" at rest and "Xong" while selecting. */
|
|
308
|
+
export function FilesEditorSelect() {
|
|
309
|
+
const { files, selectMode, setSelectMode, labels } = useCtx("FilesEditorSelect");
|
|
310
|
+
if (files.length === 0) return null;
|
|
311
|
+
return selectMode ? (
|
|
312
|
+
<Button title={labels.done} color="secondary" onPress={() => setSelectMode(false)} />
|
|
313
|
+
) : (
|
|
314
|
+
<Button title={labels.select} color="secondary" onPress={() => setSelectMode(true)} />
|
|
315
|
+
);
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
/** Select-all / deselect-all. Only meaningful while selecting, so it renders
|
|
319
|
+
* nothing at rest. */
|
|
320
|
+
export function FilesEditorSelectAll() {
|
|
321
|
+
const { files, selectMode, selectedIds, setAll, labels } = useCtx("FilesEditorSelectAll");
|
|
322
|
+
if (!selectMode || files.length === 0) return null;
|
|
323
|
+
const allSelected = selectedIds.size === files.length;
|
|
324
|
+
return <Button title={allSelected ? labels.deselectAll : labels.selectAll} color="muted" onPress={() => setAll(!allSelected)} />;
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
/**
|
|
328
|
+
* Download. Acts on the SELECTION while selecting and on everything at rest, so
|
|
329
|
+
* it is one control rather than two that mean the same thing in different modes.
|
|
330
|
+
*/
|
|
331
|
+
export function FilesEditorDownload() {
|
|
332
|
+
const { files, selectMode, selected, download, labels } = useCtx("FilesEditorDownload");
|
|
333
|
+
if (files.length === 0) return null;
|
|
334
|
+
if (!selectMode) return <Button title={labels.downloadAll} color="secondary" onPress={() => download(files)} />;
|
|
335
|
+
return (
|
|
336
|
+
<Button
|
|
337
|
+
title={labels.downloadSelected(selected.length)}
|
|
338
|
+
color="secondary"
|
|
339
|
+
disabled={selected.length === 0}
|
|
340
|
+
onPress={() => download(selected)}
|
|
341
|
+
/>
|
|
342
|
+
);
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
/** Remove the picked files, behind the root's Alert confirm. Renders nothing
|
|
346
|
+
* when the root was given no `onRemove`, and nothing at rest — removing is an
|
|
347
|
+
* act on a chosen set, never on "everything". */
|
|
348
|
+
export function FilesEditorRemove() {
|
|
349
|
+
const { selectMode, selected, onRemove, confirmRemove, exit, labels } = useCtx("FilesEditorRemove");
|
|
350
|
+
if (!onRemove || !selectMode) return null;
|
|
351
|
+
return (
|
|
352
|
+
<Button
|
|
353
|
+
title={labels.delete}
|
|
354
|
+
color="danger-secondary"
|
|
355
|
+
disabled={selected.length === 0}
|
|
356
|
+
onPress={() => confirmRemove(selected, () => { for (const f of selected) onRemove(f.id); exit(); })}
|
|
357
|
+
/>
|
|
248
358
|
);
|
|
249
359
|
}
|
|
250
360
|
|
|
251
361
|
const styles = StyleSheet.create({
|
|
252
362
|
root: { gap: 10 },
|
|
253
|
-
bar: { flexDirection: "row", alignItems: "center", gap: 8 },
|
|
363
|
+
bar: { flexDirection: "row", alignItems: "center", gap: 8, flexWrap: "wrap" },
|
|
254
364
|
spacer: { flex: 1 },
|
|
255
|
-
menu: { minWidth: 200, gap: 2, padding: 4 },
|
|
256
365
|
});
|
package/src/locale.tsx
CHANGED
|
@@ -118,6 +118,36 @@ export interface LoticsLocale {
|
|
|
118
118
|
fileDropzone: { label: string; hint: string; drop: string };
|
|
119
119
|
/** `FileThumbnail`'s `RemoveButton`: the ✕ a11y name. */
|
|
120
120
|
fileThumbnail: { remove: string };
|
|
121
|
+
/** `FilesEditor`'s shipped bar pieces (upload · select · select-all · download
|
|
122
|
+
* · remove) and the remove confirm. A HOST verb is a plain `Button` and names
|
|
123
|
+
* itself, so nothing here is about what an app happens to do with a file. */
|
|
124
|
+
filesEditor: {
|
|
125
|
+
upload: string;
|
|
126
|
+
select: string;
|
|
127
|
+
selectAll: string;
|
|
128
|
+
deselectAll: string;
|
|
129
|
+
done: string;
|
|
130
|
+
downloadAll: string;
|
|
131
|
+
downloadSelected: (n: number) => string;
|
|
132
|
+
delete: string;
|
|
133
|
+
removeTitle: string;
|
|
134
|
+
removeMessage: string;
|
|
135
|
+
removeCancel: string;
|
|
136
|
+
removeConfirm: string;
|
|
137
|
+
};
|
|
138
|
+
/** `FileGrid`'s in-flight tiles (`UploadingThumbnail`) + its retry-all footer:
|
|
139
|
+
* the states an upload can be caught in, and what to press about them. The
|
|
140
|
+
* plain `uploading` state has no entry on purpose — a spinner needs no word in
|
|
141
|
+
* any language, and only a state that wants ATTENTION earns one. */
|
|
142
|
+
fileUpload: {
|
|
143
|
+
paused: string;
|
|
144
|
+
retrying: string;
|
|
145
|
+
failed: string;
|
|
146
|
+
/** Retry is futile on the same input — a dead picker ref, or zero bytes. */
|
|
147
|
+
unavailable: string;
|
|
148
|
+
retry: string;
|
|
149
|
+
retryAll: string;
|
|
150
|
+
};
|
|
121
151
|
/** `ImageGallery`: the empty state, the inline rotate controls, and the
|
|
122
152
|
* press-to-zoom a11y name. */
|
|
123
153
|
imageGallery: { empty: string; rotateLeft: string; rotateRight: string; zoom: string };
|
|
@@ -250,6 +280,28 @@ export const en: LoticsLocale = {
|
|
|
250
280
|
overlay: { close: "Close" },
|
|
251
281
|
fileDropzone: { label: "Drag files here", hint: "or click, or paste (⌘V)", drop: "Drop to upload" },
|
|
252
282
|
fileThumbnail: { remove: "Remove" },
|
|
283
|
+
filesEditor: {
|
|
284
|
+
upload: "Upload",
|
|
285
|
+
select: "Select",
|
|
286
|
+
selectAll: "Select all",
|
|
287
|
+
deselectAll: "Deselect all",
|
|
288
|
+
done: "Done",
|
|
289
|
+
downloadAll: "Download all",
|
|
290
|
+
downloadSelected: (n: number) => `Download (${n})`,
|
|
291
|
+
delete: "Delete",
|
|
292
|
+
removeTitle: "Remove attachment?",
|
|
293
|
+
removeMessage: "This removes the file.",
|
|
294
|
+
removeCancel: "Cancel",
|
|
295
|
+
removeConfirm: "Remove",
|
|
296
|
+
},
|
|
297
|
+
fileUpload: {
|
|
298
|
+
paused: "Paused",
|
|
299
|
+
retrying: "Retrying",
|
|
300
|
+
failed: "Upload failed",
|
|
301
|
+
unavailable: "Can't upload",
|
|
302
|
+
retry: "Retry upload",
|
|
303
|
+
retryAll: "Retry all",
|
|
304
|
+
},
|
|
253
305
|
imageGallery: { empty: "No images.", rotateLeft: "Rotate left", rotateRight: "Rotate right", zoom: "Zoom image" },
|
|
254
306
|
infoPopover: { more: "More information" },
|
|
255
307
|
matrix: { total: "Total", less: "Less", more: "More" },
|
|
@@ -376,6 +428,28 @@ export const vi: LoticsLocale = {
|
|
|
376
428
|
overlay: { close: "Đóng" },
|
|
377
429
|
fileDropzone: { label: "Kéo tệp vào đây", hint: "hoặc bấm chọn, hoặc dán (Ctrl+V)", drop: "Thả để tải lên" },
|
|
378
430
|
fileThumbnail: { remove: "Xóa" },
|
|
431
|
+
filesEditor: {
|
|
432
|
+
upload: "Tải lên",
|
|
433
|
+
select: "Chọn",
|
|
434
|
+
selectAll: "Chọn tất cả",
|
|
435
|
+
deselectAll: "Bỏ chọn",
|
|
436
|
+
done: "Xong",
|
|
437
|
+
downloadAll: "Tải tất cả",
|
|
438
|
+
downloadSelected: (n: number) => `Tải ${n} tệp`,
|
|
439
|
+
delete: "Xóa",
|
|
440
|
+
removeTitle: "Xóa tệp?",
|
|
441
|
+
removeMessage: "Tệp sẽ bị gỡ khỏi bản ghi.",
|
|
442
|
+
removeCancel: "Hủy",
|
|
443
|
+
removeConfirm: "Xóa",
|
|
444
|
+
},
|
|
445
|
+
fileUpload: {
|
|
446
|
+
paused: "Tạm dừng",
|
|
447
|
+
retrying: "Đang thử lại",
|
|
448
|
+
failed: "Không tải lên được",
|
|
449
|
+
unavailable: "Tệp không đọc được",
|
|
450
|
+
retry: "Thử lại",
|
|
451
|
+
retryAll: "Thử lại tất cả",
|
|
452
|
+
},
|
|
379
453
|
imageGallery: { empty: "Chưa có ảnh.", rotateLeft: "Xoay trái", rotateRight: "Xoay phải", zoom: "Phóng to ảnh" },
|
|
380
454
|
infoPopover: { more: "Thông tin thêm" },
|
|
381
455
|
matrix: { total: "Tổng", less: "Ít", more: "Nhiều" },
|
|
@@ -14,9 +14,11 @@ import { isImageMimeType } from "./mime";
|
|
|
14
14
|
/** In-flight upload states (everything except a completed file). */
|
|
15
15
|
export type UploadStatus = "preparing" | "queued" | "uploading" | "paused_offline" | "retrying" | "error";
|
|
16
16
|
|
|
17
|
-
/** Status text for the non-happy-path states. i18n-free
|
|
18
|
-
*
|
|
19
|
-
*
|
|
17
|
+
/** Status text for the non-happy-path states. This component stays i18n-free —
|
|
18
|
+
* `FileGrid` resolves the words (per-instance labels over the active
|
|
19
|
+
* `LoticsLocale`) and hands them in, so nothing below it reads context. The
|
|
20
|
+
* common `uploading` state stays text-free (a spinner is self-evident) — only
|
|
21
|
+
* states that need attention get a word. */
|
|
20
22
|
export interface UploadStatusLabels {
|
|
21
23
|
/** Upload paused because the device is offline. Default "Paused". */
|
|
22
24
|
paused?: string;
|