@lotics/ui 1.15.0 → 1.17.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/package.json +17 -1
- package/src/combobox.tsx +13 -1
- package/src/custom_option.test.ts +50 -0
- package/src/custom_option.ts +30 -0
- package/src/date_filter.tsx +389 -0
- package/src/date_filter_presets.test.ts +62 -0
- package/src/date_filter_presets.ts +100 -0
- package/src/file_gallery_modal.tsx +22 -40
- package/src/file_preview.tsx +38 -0
- package/src/file_preview.web.tsx +198 -0
- package/src/file_preview_types.ts +35 -0
- package/src/mime.ts +46 -0
- package/src/picker_menu.tsx +29 -5
- package/src/spreadsheet_view.tsx +304 -0
- package/src/table.tsx +105 -36
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
import type { DateFilterValue } from "./date_filter";
|
|
2
|
+
|
|
3
|
+
// Pure preset-range math for DateFilter. Kept free of React/RN so it can be
|
|
4
|
+
// unit-tested deterministically: every range is computed from an injected
|
|
5
|
+
// `now`, never an ambient clock.
|
|
6
|
+
|
|
7
|
+
export type PresetId =
|
|
8
|
+
| "custom"
|
|
9
|
+
| "today"
|
|
10
|
+
| "yesterday"
|
|
11
|
+
| "tomorrow"
|
|
12
|
+
| "this_week"
|
|
13
|
+
| "this_month"
|
|
14
|
+
| "last_month";
|
|
15
|
+
|
|
16
|
+
/** Display order. "custom" carries no range — it clears the value. */
|
|
17
|
+
export const PRESET_IDS: PresetId[] = [
|
|
18
|
+
"today",
|
|
19
|
+
"yesterday",
|
|
20
|
+
"tomorrow",
|
|
21
|
+
"this_week",
|
|
22
|
+
"this_month",
|
|
23
|
+
"last_month",
|
|
24
|
+
"custom",
|
|
25
|
+
];
|
|
26
|
+
|
|
27
|
+
function startOfDay(date: Date): Date {
|
|
28
|
+
const d = new Date(date);
|
|
29
|
+
d.setHours(0, 0, 0, 0);
|
|
30
|
+
return d;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function endOfDay(date: Date): Date {
|
|
34
|
+
const d = new Date(date);
|
|
35
|
+
d.setHours(23, 59, 59, 999);
|
|
36
|
+
return d;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function startOfWeek(date: Date): Date {
|
|
40
|
+
const d = new Date(date);
|
|
41
|
+
const day = d.getDay();
|
|
42
|
+
const diff = d.getDate() - day + (day === 0 ? -6 : 1); // Monday is first day
|
|
43
|
+
d.setDate(diff);
|
|
44
|
+
d.setHours(0, 0, 0, 0);
|
|
45
|
+
return d;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function endOfWeek(date: Date): Date {
|
|
49
|
+
const start = startOfWeek(date);
|
|
50
|
+
const end = new Date(start);
|
|
51
|
+
end.setDate(end.getDate() + 6);
|
|
52
|
+
end.setHours(23, 59, 59, 999);
|
|
53
|
+
return end;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function startOfMonth(date: Date): Date {
|
|
57
|
+
return new Date(date.getFullYear(), date.getMonth(), 1);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function endOfMonth(date: Date): Date {
|
|
61
|
+
return new Date(date.getFullYear(), date.getMonth() + 1, 0, 23, 59, 59, 999);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function range(start: Date, end: Date): DateFilterValue {
|
|
65
|
+
return { start: { date: start, time: null }, end: { date: end, time: null } };
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Resolve a preset to a concrete date range relative to `now`. Returns `null`
|
|
70
|
+
* for "custom" (no range — the caller clears the value so the user picks
|
|
71
|
+
* manually). The boundary math is identical to the view-page filter so a
|
|
72
|
+
* resolved range round-trips back to the same preset.
|
|
73
|
+
*/
|
|
74
|
+
export function getPresetValue(id: PresetId, now: Date): DateFilterValue | null {
|
|
75
|
+
switch (id) {
|
|
76
|
+
case "today":
|
|
77
|
+
return range(startOfDay(now), endOfDay(now));
|
|
78
|
+
case "yesterday": {
|
|
79
|
+
const d = new Date(now);
|
|
80
|
+
d.setDate(d.getDate() - 1);
|
|
81
|
+
return range(startOfDay(d), endOfDay(d));
|
|
82
|
+
}
|
|
83
|
+
case "tomorrow": {
|
|
84
|
+
const d = new Date(now);
|
|
85
|
+
d.setDate(d.getDate() + 1);
|
|
86
|
+
return range(startOfDay(d), endOfDay(d));
|
|
87
|
+
}
|
|
88
|
+
case "this_week":
|
|
89
|
+
return range(startOfWeek(now), endOfWeek(now));
|
|
90
|
+
case "this_month":
|
|
91
|
+
return range(startOfMonth(now), endOfMonth(now));
|
|
92
|
+
case "last_month": {
|
|
93
|
+
const d = new Date(now);
|
|
94
|
+
d.setMonth(d.getMonth() - 1);
|
|
95
|
+
return range(startOfMonth(d), endOfMonth(d));
|
|
96
|
+
}
|
|
97
|
+
case "custom":
|
|
98
|
+
return null;
|
|
99
|
+
}
|
|
100
|
+
}
|
|
@@ -1,12 +1,11 @@
|
|
|
1
|
-
// FileGalleryModal —
|
|
2
|
-
// Pure primitive: takes DisplayFile[] only, no Lotics domain coupling.
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
//
|
|
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.
|
|
6
6
|
|
|
7
7
|
import { useCallback, useEffect, useRef } from "react";
|
|
8
8
|
import {
|
|
9
|
-
Image,
|
|
10
9
|
Modal,
|
|
11
10
|
Pressable,
|
|
12
11
|
StyleSheet,
|
|
@@ -15,8 +14,9 @@ import {
|
|
|
15
14
|
import { Text } from "./text";
|
|
16
15
|
import { Icon } from "./icon";
|
|
17
16
|
import { colors } from "./colors";
|
|
18
|
-
import { isImageMimeType } from "./mime";
|
|
19
17
|
import type { DisplayFile } from "./file_thumbnail";
|
|
18
|
+
import { FilePreview } from "./file_preview";
|
|
19
|
+
import type { PreviewLabels } from "./file_preview_types";
|
|
20
20
|
|
|
21
21
|
export interface FileGalleryModalProps {
|
|
22
22
|
files: DisplayFile[];
|
|
@@ -24,19 +24,22 @@ export interface FileGalleryModalProps {
|
|
|
24
24
|
activeIndex: number | null;
|
|
25
25
|
onIndexChange: (next: number | null) => void;
|
|
26
26
|
/**
|
|
27
|
-
* Optional caption suffix shown under the
|
|
27
|
+
* Optional caption suffix shown under the file. Useful for hint text like
|
|
28
28
|
* "ESC để đóng · ←/→ để chuyển". When omitted, just shows filename + count.
|
|
29
29
|
*/
|
|
30
30
|
captionHint?: string;
|
|
31
|
+
/** Translated preview strings; English fallback when omitted. */
|
|
32
|
+
labels?: Partial<PreviewLabels>;
|
|
33
|
+
/** Reported when a file fails to render (host wires to its logger). */
|
|
34
|
+
onError?: (error: unknown, meta: { fileId: string; mimeType: string }) => void;
|
|
31
35
|
}
|
|
32
36
|
|
|
33
37
|
/**
|
|
34
|
-
*
|
|
35
|
-
*
|
|
36
|
-
* a Lotics-specific gallery instead.
|
|
38
|
+
* Fullscreen file preview gallery. Renders any previewable MIME type inline
|
|
39
|
+
* via FilePreview; unknown types show a download placeholder.
|
|
37
40
|
*/
|
|
38
41
|
export function FileGalleryModal(props: FileGalleryModalProps) {
|
|
39
|
-
const { files, activeIndex, onIndexChange, captionHint } = props;
|
|
42
|
+
const { files, activeIndex, onIndexChange, captionHint, labels, onError } = props;
|
|
40
43
|
const visible = activeIndex !== null;
|
|
41
44
|
const overlayRef = useRef<View | null>(null);
|
|
42
45
|
|
|
@@ -81,22 +84,9 @@ export function FileGalleryModal(props: FileGalleryModalProps) {
|
|
|
81
84
|
style={StyleSheet.absoluteFill}
|
|
82
85
|
/>
|
|
83
86
|
|
|
84
|
-
{
|
|
85
|
-
<
|
|
86
|
-
|
|
87
|
-
style={styles.image}
|
|
88
|
-
resizeMode="contain"
|
|
89
|
-
/>
|
|
90
|
-
) : (
|
|
91
|
-
<View style={styles.unsupportedCard}>
|
|
92
|
-
<Text size="md" style={styles.unsupportedText}>
|
|
93
|
-
{file.filename}
|
|
94
|
-
</Text>
|
|
95
|
-
<Text size="sm" color="muted">
|
|
96
|
-
Cannot preview this file type in the gallery.
|
|
97
|
-
</Text>
|
|
98
|
-
</View>
|
|
99
|
-
)}
|
|
87
|
+
<View style={styles.previewPanel}>
|
|
88
|
+
<FilePreview file={file} labels={labels} onError={onError} />
|
|
89
|
+
</View>
|
|
100
90
|
|
|
101
91
|
{activeIndex > 0 ? (
|
|
102
92
|
<Pressable
|
|
@@ -137,20 +127,12 @@ const styles = StyleSheet.create({
|
|
|
137
127
|
justifyContent: "center",
|
|
138
128
|
alignItems: "center",
|
|
139
129
|
},
|
|
140
|
-
|
|
130
|
+
previewPanel: {
|
|
141
131
|
width: "92%",
|
|
142
|
-
height: "
|
|
143
|
-
|
|
144
|
-
unsupportedCard: {
|
|
145
|
-
backgroundColor: colors.white,
|
|
132
|
+
height: "86%",
|
|
133
|
+
backgroundColor: colors.background,
|
|
146
134
|
borderRadius: 8,
|
|
147
|
-
|
|
148
|
-
maxWidth: 480,
|
|
149
|
-
gap: 8,
|
|
150
|
-
alignItems: "center",
|
|
151
|
-
},
|
|
152
|
-
unsupportedText: {
|
|
153
|
-
fontWeight: "600",
|
|
135
|
+
overflow: "hidden",
|
|
154
136
|
},
|
|
155
137
|
navButton: {
|
|
156
138
|
position: "absolute",
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { View, Image, StyleSheet } from "react-native";
|
|
2
|
+
import { Text } from "./text";
|
|
3
|
+
import { DocumentCard } from "./file_thumbnail";
|
|
4
|
+
import { isImageMimeType } from "./mime";
|
|
5
|
+
import { type FilePreviewProps, resolveLabels } from "./file_preview_types";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Native (mobile) fallback: images render full-bleed; everything else shows a
|
|
9
|
+
* document placeholder. Rich document rendering (docx/xlsx/pdf) is web-only —
|
|
10
|
+
* the `.web.tsx` variant carries it; native consumers download instead.
|
|
11
|
+
*/
|
|
12
|
+
export function FilePreview({ file, labels }: FilePreviewProps) {
|
|
13
|
+
const l = resolveLabels(labels);
|
|
14
|
+
if (isImageMimeType(file.mimeType)) {
|
|
15
|
+
return (
|
|
16
|
+
<Image
|
|
17
|
+
source={{ uri: file.url }}
|
|
18
|
+
resizeMode="contain"
|
|
19
|
+
style={styles.image}
|
|
20
|
+
accessibilityIgnoresInvertColors
|
|
21
|
+
/>
|
|
22
|
+
);
|
|
23
|
+
}
|
|
24
|
+
return (
|
|
25
|
+
<View style={styles.placeholder}>
|
|
26
|
+
<DocumentCard mimeType={file.mimeType} filename={file.filename} size={120} />
|
|
27
|
+
<Text size="sm" color="muted" style={styles.placeholderText}>
|
|
28
|
+
{l.notAvailable}
|
|
29
|
+
</Text>
|
|
30
|
+
</View>
|
|
31
|
+
);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const styles = StyleSheet.create({
|
|
35
|
+
image: { flex: 1, width: "100%" },
|
|
36
|
+
placeholder: { flex: 1, justifyContent: "center", alignItems: "center", gap: 16 },
|
|
37
|
+
placeholderText: { marginTop: 8 },
|
|
38
|
+
});
|
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
import { useEffect, useRef, useState } from "react";
|
|
2
|
+
import { View, StyleSheet } from "react-native";
|
|
3
|
+
import { Text } from "./text";
|
|
4
|
+
import { Button } from "./button";
|
|
5
|
+
import { DocumentCard, MediaCard } from "./file_thumbnail";
|
|
6
|
+
import {
|
|
7
|
+
isImageMimeType,
|
|
8
|
+
isPdfMimeType,
|
|
9
|
+
isDocxMimeType,
|
|
10
|
+
isVideoMimeType,
|
|
11
|
+
isAudioMimeType,
|
|
12
|
+
isPreviewableMimeType,
|
|
13
|
+
} from "./mime";
|
|
14
|
+
import { type FilePreviewProps, resolveLabels, type PreviewLabels } from "./file_preview_types";
|
|
15
|
+
import { SpreadsheetView } from "./spreadsheet_view";
|
|
16
|
+
import { downloadFileFromUrl } from "./download";
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Universal inline file preview (web). Renders by MIME type: images, PDF, video
|
|
20
|
+
* and audio use browser-native elements; Word via the lazy-loaded `@lotics/docx`
|
|
21
|
+
* renderer; Excel/CSV via the read-only `@lotics/xlsx` canvas. Heavy engines are
|
|
22
|
+
* dynamic-imported so non-document files never pay their bundle cost.
|
|
23
|
+
*
|
|
24
|
+
* Pure: i18n via `labels` props, errors via `onError` — no lingui/analytics
|
|
25
|
+
* (purity test). Lifted from the frontend gallery so both share one renderer.
|
|
26
|
+
*/
|
|
27
|
+
export function FilePreview({ file, labels, onError }: FilePreviewProps) {
|
|
28
|
+
const l = resolveLabels(labels);
|
|
29
|
+
|
|
30
|
+
if (isImageMimeType(file.mimeType)) {
|
|
31
|
+
return <img alt={file.filename} src={file.url} style={imageStyle} />;
|
|
32
|
+
}
|
|
33
|
+
if (isPdfMimeType(file.mimeType)) {
|
|
34
|
+
return <iframe src={file.url} title={file.filename} style={fullFrameStyle} />;
|
|
35
|
+
}
|
|
36
|
+
if (isDocxMimeType(file.mimeType)) {
|
|
37
|
+
return <WordPreview file={file} labels={l} onError={onError} />;
|
|
38
|
+
}
|
|
39
|
+
if (isVideoMimeType(file.mimeType)) {
|
|
40
|
+
return <video controls preload="metadata" src={file.url} style={mediaElementStyle} />;
|
|
41
|
+
}
|
|
42
|
+
if (isAudioMimeType(file.mimeType)) {
|
|
43
|
+
return (
|
|
44
|
+
<div style={audioContainerStyle}>
|
|
45
|
+
<MediaCard mimeType={file.mimeType} filename={file.filename} icon="music" size={160} />
|
|
46
|
+
<audio controls preload="metadata" src={file.url} style={audioElementStyle} />
|
|
47
|
+
</div>
|
|
48
|
+
);
|
|
49
|
+
}
|
|
50
|
+
if (isPreviewableMimeType(file.mimeType)) {
|
|
51
|
+
return <SpreadsheetView file={file} labels={l} onError={onError} />;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
return (
|
|
55
|
+
<View style={styles.placeholder}>
|
|
56
|
+
<DocumentCard mimeType={file.mimeType} filename={file.filename} size={120} />
|
|
57
|
+
<Text size="sm" color="muted" style={styles.placeholderText}>
|
|
58
|
+
{l.notAvailable}
|
|
59
|
+
</Text>
|
|
60
|
+
</View>
|
|
61
|
+
);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** Lazy bridge — pulls jszip + prosemirror only when a .docx is actually opened. */
|
|
65
|
+
async function lazyLoadDocx(
|
|
66
|
+
...args: Parameters<typeof import("@lotics/docx/load").loadDocxIntoElement>
|
|
67
|
+
): ReturnType<typeof import("@lotics/docx/load").loadDocxIntoElement> {
|
|
68
|
+
const mod = await import("@lotics/docx/load");
|
|
69
|
+
return mod.loadDocxIntoElement(...args);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function WordPreview({
|
|
73
|
+
file,
|
|
74
|
+
labels,
|
|
75
|
+
onError,
|
|
76
|
+
}: {
|
|
77
|
+
file: FilePreviewProps["file"];
|
|
78
|
+
labels: PreviewLabels;
|
|
79
|
+
onError: FilePreviewProps["onError"];
|
|
80
|
+
}) {
|
|
81
|
+
const containerRef = useRef<HTMLDivElement>(null);
|
|
82
|
+
const [state, setState] = useState<{ loading: boolean; error: string | undefined }>({
|
|
83
|
+
loading: true,
|
|
84
|
+
error: undefined,
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
useEffect(() => {
|
|
88
|
+
const controller = new AbortController();
|
|
89
|
+
let loadedView: { destroy: () => void } | null = null;
|
|
90
|
+
setState({ loading: true, error: undefined });
|
|
91
|
+
|
|
92
|
+
const run = async () => {
|
|
93
|
+
const response = await fetch(file.url, { signal: controller.signal });
|
|
94
|
+
if (!response.ok) throw new Error(`Document fetch failed (${response.status})`);
|
|
95
|
+
const arrayBuffer = await response.arrayBuffer();
|
|
96
|
+
const bytes = new Uint8Array(arrayBuffer);
|
|
97
|
+
if (controller.signal.aborted || !containerRef.current) return;
|
|
98
|
+
containerRef.current.innerHTML = "";
|
|
99
|
+
const loaded = await lazyLoadDocx(containerRef.current, bytes);
|
|
100
|
+
if (controller.signal.aborted) {
|
|
101
|
+
loaded.view.destroy();
|
|
102
|
+
return;
|
|
103
|
+
}
|
|
104
|
+
loadedView = loaded.view;
|
|
105
|
+
};
|
|
106
|
+
|
|
107
|
+
run().then(
|
|
108
|
+
() => {
|
|
109
|
+
if (!controller.signal.aborted) setState({ loading: false, error: undefined });
|
|
110
|
+
},
|
|
111
|
+
(err: unknown) => {
|
|
112
|
+
if (controller.signal.aborted) return;
|
|
113
|
+
if (err instanceof DOMException && err.name === "AbortError") return;
|
|
114
|
+
onError?.(err, { fileId: file.id, mimeType: file.mimeType });
|
|
115
|
+
setState({ loading: false, error: labels.loadFailed });
|
|
116
|
+
},
|
|
117
|
+
);
|
|
118
|
+
|
|
119
|
+
return () => {
|
|
120
|
+
controller.abort();
|
|
121
|
+
loadedView?.destroy();
|
|
122
|
+
};
|
|
123
|
+
}, [file.url, file.id, file.mimeType, labels.loadFailed, onError]);
|
|
124
|
+
|
|
125
|
+
return (
|
|
126
|
+
<div style={wordContainerStyle}>
|
|
127
|
+
{state.loading && (
|
|
128
|
+
<div style={centerStyle}>
|
|
129
|
+
<Text size="sm" color="muted">
|
|
130
|
+
…
|
|
131
|
+
</Text>
|
|
132
|
+
</div>
|
|
133
|
+
)}
|
|
134
|
+
{state.error !== undefined && (
|
|
135
|
+
<div style={centerStyle}>
|
|
136
|
+
<Text size="sm" color="muted">
|
|
137
|
+
{state.error}
|
|
138
|
+
</Text>
|
|
139
|
+
<View style={{ marginTop: 12 }}>
|
|
140
|
+
<Button
|
|
141
|
+
icon="download"
|
|
142
|
+
title={labels.download}
|
|
143
|
+
color="secondary"
|
|
144
|
+
onPress={() => void downloadFileFromUrl(file.url, file.filename)}
|
|
145
|
+
/>
|
|
146
|
+
</View>
|
|
147
|
+
</div>
|
|
148
|
+
)}
|
|
149
|
+
<div ref={containerRef} style={state.loading ? hiddenStyle : wordContentStyle} />
|
|
150
|
+
</div>
|
|
151
|
+
);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
const styles = StyleSheet.create({
|
|
155
|
+
placeholder: { flex: 1, justifyContent: "center", alignItems: "center", gap: 16 },
|
|
156
|
+
placeholderText: { marginTop: 8 },
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
const imageStyle: React.CSSProperties = {
|
|
160
|
+
width: "100%",
|
|
161
|
+
height: "100%",
|
|
162
|
+
objectFit: "contain",
|
|
163
|
+
backgroundColor: "rgba(10, 10, 10, 0.8)",
|
|
164
|
+
};
|
|
165
|
+
const fullFrameStyle: React.CSSProperties = { width: "100%", height: "100%", border: "none" };
|
|
166
|
+
const mediaElementStyle: React.CSSProperties = {
|
|
167
|
+
width: "100%",
|
|
168
|
+
height: "100%",
|
|
169
|
+
backgroundColor: "rgba(10, 10, 10, 0.8)",
|
|
170
|
+
objectFit: "contain",
|
|
171
|
+
};
|
|
172
|
+
const audioContainerStyle: React.CSSProperties = {
|
|
173
|
+
display: "flex",
|
|
174
|
+
flexDirection: "column",
|
|
175
|
+
alignItems: "center",
|
|
176
|
+
justifyContent: "center",
|
|
177
|
+
flex: 1,
|
|
178
|
+
gap: 24,
|
|
179
|
+
padding: 48,
|
|
180
|
+
};
|
|
181
|
+
const audioElementStyle: React.CSSProperties = { width: "min(480px, 100%)" };
|
|
182
|
+
const wordContainerStyle: React.CSSProperties = {
|
|
183
|
+
display: "flex",
|
|
184
|
+
flexDirection: "column",
|
|
185
|
+
flex: 1,
|
|
186
|
+
overflow: "auto",
|
|
187
|
+
backgroundColor: "#f4f4f5",
|
|
188
|
+
};
|
|
189
|
+
const wordContentStyle: React.CSSProperties = { display: "block", minHeight: "100%" };
|
|
190
|
+
const hiddenStyle: React.CSSProperties = { display: "none" };
|
|
191
|
+
const centerStyle: React.CSSProperties = {
|
|
192
|
+
display: "flex",
|
|
193
|
+
flexDirection: "column",
|
|
194
|
+
alignItems: "center",
|
|
195
|
+
justifyContent: "center",
|
|
196
|
+
flex: 1,
|
|
197
|
+
padding: 48,
|
|
198
|
+
};
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import type { DisplayFile } from "./file_thumbnail";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Caption/error strings shown inside the preview. `@lotics/ui` is i18n-free
|
|
5
|
+
* (purity test forbids lingui), so the host passes translated strings; English
|
|
6
|
+
* is the fallback.
|
|
7
|
+
*/
|
|
8
|
+
export interface PreviewLabels {
|
|
9
|
+
notAvailable: string;
|
|
10
|
+
loadFailed: string;
|
|
11
|
+
download: string;
|
|
12
|
+
passwordProtected: string;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export const defaultPreviewLabels: PreviewLabels = {
|
|
16
|
+
notAvailable: "Preview not available",
|
|
17
|
+
loadFailed: "Failed to load preview",
|
|
18
|
+
download: "Download file",
|
|
19
|
+
passwordProtected: "This file is password-protected and cannot be previewed",
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
export interface FilePreviewProps {
|
|
23
|
+
file: DisplayFile;
|
|
24
|
+
/** Override any subset of the English default labels. */
|
|
25
|
+
labels?: Partial<PreviewLabels>;
|
|
26
|
+
/**
|
|
27
|
+
* Reported on a render/parse failure. Logging/analytics live in the host (the
|
|
28
|
+
* package stays analytics-free), so the host wires this to its logger.
|
|
29
|
+
*/
|
|
30
|
+
onError?: (error: unknown, meta: { fileId: string; mimeType: string }) => void;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function resolveLabels(labels: Partial<PreviewLabels> | undefined): PreviewLabels {
|
|
34
|
+
return labels ? { ...defaultPreviewLabels, ...labels } : defaultPreviewLabels;
|
|
35
|
+
}
|
package/src/mime.ts
CHANGED
|
@@ -13,3 +13,49 @@ export function isVideoMimeType(mimeType: string): boolean {
|
|
|
13
13
|
export function isAudioMimeType(mimeType: string): boolean {
|
|
14
14
|
return mimeType.toLowerCase().startsWith("audio/");
|
|
15
15
|
}
|
|
16
|
+
|
|
17
|
+
const EXCEL_MIME_TYPES = [
|
|
18
|
+
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
|
19
|
+
"application/wps-office.xlsx",
|
|
20
|
+
"application/wps-office.xls",
|
|
21
|
+
"application/vnd.ms-excel",
|
|
22
|
+
];
|
|
23
|
+
|
|
24
|
+
const LEGACY_EXCEL_MIME_TYPES = ["application/vnd.ms-excel", "application/wps-office.xls"];
|
|
25
|
+
|
|
26
|
+
const DOCX_MIME_TYPES = [
|
|
27
|
+
"application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
|
28
|
+
"application/wps-office.docx",
|
|
29
|
+
];
|
|
30
|
+
|
|
31
|
+
export function isPdfMimeType(mimeType: string): boolean {
|
|
32
|
+
return mimeType.toLowerCase() === "application/pdf";
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function isExcelMimeType(mimeType: string): boolean {
|
|
36
|
+
return EXCEL_MIME_TYPES.includes(mimeType.toLowerCase());
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** Legacy .xls (BIFF) — not parseable in-app; excluded from previewable. */
|
|
40
|
+
export function isLegacyExcelMimeType(mimeType: string): boolean {
|
|
41
|
+
return LEGACY_EXCEL_MIME_TYPES.includes(mimeType.toLowerCase());
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function isCsvMimeType(mimeType: string): boolean {
|
|
45
|
+
const mt = mimeType.toLowerCase();
|
|
46
|
+
return mt === "text/csv" || mt === "text/tab-separated-values";
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function isDocxMimeType(mimeType: string): boolean {
|
|
50
|
+
return DOCX_MIME_TYPES.includes(mimeType.toLowerCase());
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** PDF, modern Excel (.xlsx), Word (.docx), or CSV — types FilePreview renders inline. */
|
|
54
|
+
export function isPreviewableMimeType(mimeType: string): boolean {
|
|
55
|
+
return (
|
|
56
|
+
(isExcelMimeType(mimeType) && !isLegacyExcelMimeType(mimeType)) ||
|
|
57
|
+
isCsvMimeType(mimeType) ||
|
|
58
|
+
isPdfMimeType(mimeType) ||
|
|
59
|
+
isDocxMimeType(mimeType)
|
|
60
|
+
);
|
|
61
|
+
}
|
package/src/picker_menu.tsx
CHANGED
|
@@ -10,6 +10,7 @@ import { MenuButton } from "./menu_button";
|
|
|
10
10
|
import { ActivityIndicator } from "./activity_indicator";
|
|
11
11
|
import { PickerOption, PickerValue, PickerOnValueChange, PickerOnClose } from "./picker";
|
|
12
12
|
import { useScreenSize } from "./use_screen_size";
|
|
13
|
+
import { customOptionFor } from "./custom_option";
|
|
13
14
|
|
|
14
15
|
export interface PickerMenuProps<T extends string = string, MULTI extends boolean = false> {
|
|
15
16
|
testID?: string;
|
|
@@ -22,6 +23,14 @@ export interface PickerMenuProps<T extends string = string, MULTI extends boolea
|
|
|
22
23
|
renderOptionContent?: (option: PickerOption<T>) => React.ReactNode;
|
|
23
24
|
/** Enable search input to filter options */
|
|
24
25
|
enableSearch?: boolean;
|
|
26
|
+
/** Single-select + search only: when the typed query matches no option, append
|
|
27
|
+
* a trailing row that selects the raw query as a custom value (free entry).
|
|
28
|
+
* `onValueChange` then receives the typed string. */
|
|
29
|
+
allowCustom?: boolean;
|
|
30
|
+
/** Label for the free-entry row (default: the raw query). Return `null` to
|
|
31
|
+
* suppress the row for a given query — e.g. while it is still incomplete or
|
|
32
|
+
* invalid — so "add" only appears when it is a meaningful thing to add. */
|
|
33
|
+
customOptionLabel?: (query: string) => string | null;
|
|
25
34
|
enableSelectAll?: boolean;
|
|
26
35
|
includeEmptyOption?: boolean;
|
|
27
36
|
/** Called whenever the search text changes. Provide it to drive server-side
|
|
@@ -61,6 +70,8 @@ export function PickerMenu<T extends string, MULTI extends boolean = false>(
|
|
|
61
70
|
onRequestClose,
|
|
62
71
|
enableSelectAll,
|
|
63
72
|
enableSearch,
|
|
73
|
+
allowCustom,
|
|
74
|
+
customOptionLabel,
|
|
64
75
|
includeEmptyOption,
|
|
65
76
|
onSearchChange,
|
|
66
77
|
serverFiltered,
|
|
@@ -92,6 +103,13 @@ export function PickerMenu<T extends string, MULTI extends boolean = false>(
|
|
|
92
103
|
|
|
93
104
|
const [searchQuery, setSearchQuery] = useState("");
|
|
94
105
|
|
|
106
|
+
// Free entry: a trailing selectable row carrying the raw query as its value
|
|
107
|
+
// (recognised below for distinct styling). See `customOptionFor` for the rules.
|
|
108
|
+
const customOpt = useMemo(
|
|
109
|
+
() => customOptionFor({ allowCustom, multi, query: searchQuery, options, customOptionLabel }),
|
|
110
|
+
[allowCustom, multi, searchQuery, options, customOptionLabel],
|
|
111
|
+
);
|
|
112
|
+
|
|
95
113
|
const filteredOptions = useMemo(() => {
|
|
96
114
|
let result = options;
|
|
97
115
|
// Skip the local filter in server-driven mode — `options` already reflect
|
|
@@ -107,8 +125,11 @@ export function PickerMenu<T extends string, MULTI extends boolean = false>(
|
|
|
107
125
|
};
|
|
108
126
|
result = [emptyOption, ...result];
|
|
109
127
|
}
|
|
128
|
+
if (customOpt) {
|
|
129
|
+
result = [...result, customOpt];
|
|
130
|
+
}
|
|
110
131
|
return result;
|
|
111
|
-
}, [options, enableSearch, searchQuery, includeEmptyOption, serverFiltered]);
|
|
132
|
+
}, [options, enableSearch, searchQuery, includeEmptyOption, serverFiltered, customOpt]);
|
|
112
133
|
|
|
113
134
|
const [focusedIndex, setFocusedIndex] = useState<number>(() => {
|
|
114
135
|
const selectedIndex = filteredOptions.findIndex((opt) => {
|
|
@@ -297,23 +318,26 @@ export function PickerMenu<T extends string, MULTI extends boolean = false>(
|
|
|
297
318
|
? Array.isArray(multiValue) && multiValue.includes(item.value)
|
|
298
319
|
: item.value === singleValue;
|
|
299
320
|
const isFocused = index === focusedIndex;
|
|
321
|
+
const isCustom = !!customOpt && item.value === customOpt.value;
|
|
300
322
|
|
|
301
323
|
return (
|
|
302
324
|
<MenuButton
|
|
303
325
|
key={item.value}
|
|
304
|
-
testID={item.testID || `picker-option-${item.value}`}
|
|
326
|
+
testID={isCustom ? "picker-custom-option" : item.testID || `picker-option-${item.value}`}
|
|
305
327
|
icon={multi ? <Checkbox checked={isSelected} /> : undefined}
|
|
306
328
|
title={
|
|
307
|
-
renderOptionContent ? (
|
|
329
|
+
renderOptionContent && !isCustom ? (
|
|
308
330
|
renderOptionContent(item)
|
|
309
331
|
) : (
|
|
310
|
-
<Text userSelect="none" numberOfLines={1}>
|
|
332
|
+
<Text userSelect="none" numberOfLines={1} color={isCustom ? "zinc-500" : undefined}>
|
|
311
333
|
{item.label}
|
|
312
334
|
</Text>
|
|
313
335
|
)
|
|
314
336
|
}
|
|
315
337
|
right={
|
|
316
|
-
|
|
338
|
+
isCustom ? (
|
|
339
|
+
<Icon name="plus" size={16} color={colors.zinc["400"]} />
|
|
340
|
+
) : !multi && isSelected ? (
|
|
317
341
|
<Icon name="check" size={18} color={colors.zinc["950"]} />
|
|
318
342
|
) : undefined
|
|
319
343
|
}
|