@lotics/ui 1.6.1 → 1.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.
@@ -0,0 +1,160 @@
1
+ import { Text, View } from "react-native";
2
+ import { fontFamilySemiBold } from "./text_utils";
3
+ import { Icon, type IconName } from "./icon";
4
+ import { isVideoMimeType, isAudioMimeType } from "./mime";
5
+
6
+ const VIDEO_COLOR = "#ea580c";
7
+ const AUDIO_COLOR = "#db2777";
8
+
9
+ const MIME_MAP: Record<string, { label: string; color: string }> = {
10
+ "application/pdf": { label: "PDF", color: "#ef4444" },
11
+ "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet": { label: "XLSX", color: "#16a34a" },
12
+ "application/vnd.ms-excel": { label: "XLS", color: "#16a34a" },
13
+ "text/csv": { label: "CSV", color: "#16a34a" },
14
+ "application/vnd.openxmlformats-officedocument.wordprocessingml.document": { label: "DOCX", color: "#2563eb" },
15
+ "application/msword": { label: "DOC", color: "#2563eb" },
16
+ "image/png": { label: "PNG", color: "#7c3aed" },
17
+ "image/jpeg": { label: "JPG", color: "#7c3aed" },
18
+ "video/mp4": { label: "MP4", color: VIDEO_COLOR },
19
+ "video/webm": { label: "WEBM", color: VIDEO_COLOR },
20
+ "video/quicktime": { label: "MOV", color: VIDEO_COLOR },
21
+ "audio/mpeg": { label: "MP3", color: AUDIO_COLOR },
22
+ "audio/wav": { label: "WAV", color: AUDIO_COLOR },
23
+ "audio/x-wav": { label: "WAV", color: AUDIO_COLOR },
24
+ "audio/mp4": { label: "M4A", color: AUDIO_COLOR },
25
+ "audio/x-m4a": { label: "M4A", color: AUDIO_COLOR },
26
+ "audio/ogg": { label: "OGG", color: AUDIO_COLOR },
27
+ };
28
+
29
+ const DEFAULT_BADGE = { label: "FILE", color: "#71717a" };
30
+
31
+ export function resolveMime(mimeType: string): { label: string; color: string } {
32
+ return MIME_MAP[mimeType] ?? DEFAULT_BADGE;
33
+ }
34
+
35
+ const DEFAULT_SIZE = 26;
36
+
37
+ interface FileBadgeProps {
38
+ mimeType: string;
39
+ /** Base width in pixels. Height, radii, font, and padding scale proportionally. Default: 26 */
40
+ size?: number;
41
+ /** Show a "TMPL" overlay to distinguish templates from regular files. */
42
+ isTemplate?: boolean;
43
+ }
44
+
45
+ /**
46
+ * Two-tone file badge: white top, colored bottom with label.
47
+ * Derives label and color from mime type.
48
+ */
49
+ function getMediaIcon(mimeType: string): IconName | undefined {
50
+ if (isVideoMimeType(mimeType)) return "play";
51
+ if (isAudioMimeType(mimeType)) return "music";
52
+ return undefined;
53
+ }
54
+
55
+ export function FileBadge({ mimeType, size = DEFAULT_SIZE, isTemplate }: FileBadgeProps) {
56
+ const { label, color } = resolveMime(mimeType);
57
+ const scale = size / DEFAULT_SIZE;
58
+ const height = Math.round(32 * scale);
59
+ const radius = Math.round(4 * scale);
60
+ const bottomHeight = Math.round(14 * scale);
61
+ const fontSize = Math.round(7 * scale);
62
+ const lineHeight = Math.round(10 * scale);
63
+ const paddingTop = Math.round(4 * scale);
64
+ const paddingH = Math.round(3 * scale);
65
+ const lineGap = Math.round(3 * scale);
66
+ const lineThickness = Math.max(1, Math.round(1.5 * scale));
67
+ const tmplFontSize = Math.max(5, Math.round(5.5 * scale));
68
+ const tmplPadH = Math.round(2 * scale);
69
+ const tmplPadV = Math.max(1, Math.round(1 * scale));
70
+ const tmplRadius = Math.round(2 * scale);
71
+ const mediaIcon = getMediaIcon(mimeType);
72
+ const mediaIconSize = Math.max(10, Math.round(14 * scale));
73
+
74
+ return (
75
+ <View style={{
76
+ width: size,
77
+ height,
78
+ borderRadius: radius,
79
+ overflow: "hidden",
80
+ shadowColor: "#000",
81
+ shadowOffset: { width: 0, height: 1 },
82
+ shadowOpacity: 0.08,
83
+ shadowRadius: 3,
84
+ elevation: 2,
85
+ }}>
86
+ <View style={{
87
+ flex: 1,
88
+ backgroundColor: mediaIcon !== undefined ? color : "white",
89
+ borderWidth: 1,
90
+ borderBottomWidth: 0,
91
+ borderColor: "rgba(0,0,0,0.08)",
92
+ borderTopLeftRadius: radius,
93
+ borderTopRightRadius: radius,
94
+ paddingTop: mediaIcon !== undefined ? 0 : paddingTop,
95
+ paddingHorizontal: mediaIcon !== undefined ? 0 : paddingH,
96
+ gap: mediaIcon !== undefined ? 0 : lineGap,
97
+ alignItems: mediaIcon !== undefined ? "center" : undefined,
98
+ justifyContent: mediaIcon !== undefined ? "center" : undefined,
99
+ }}>
100
+ {mediaIcon !== undefined ? (
101
+ <Icon name={mediaIcon} size={mediaIconSize} color="white" />
102
+ ) : (
103
+ <>
104
+ <View style={{ height: lineThickness, borderRadius: 1, backgroundColor: "rgba(0,0,0,0.08)" }} />
105
+ <View style={{ height: lineThickness, borderRadius: 1, backgroundColor: "rgba(0,0,0,0.08)" }} />
106
+ <View style={{ height: lineThickness, borderRadius: 1, backgroundColor: "rgba(0,0,0,0.08)", width: "60%" }} />
107
+ </>
108
+ )}
109
+ {isTemplate && (
110
+ <View style={{
111
+ position: "absolute",
112
+ top: 0,
113
+ left: 0,
114
+ right: 0,
115
+ bottom: 0,
116
+ alignItems: "center",
117
+ justifyContent: "center",
118
+ }}>
119
+ <View style={{
120
+ backgroundColor: "white",
121
+ borderRadius: tmplRadius,
122
+ paddingHorizontal: tmplPadH,
123
+ paddingVertical: tmplPadV,
124
+ shadowColor: "#000",
125
+ shadowOffset: { width: 0, height: 0.5 },
126
+ shadowOpacity: 0.1,
127
+ shadowRadius: 1,
128
+ elevation: 1,
129
+ }}>
130
+ <Text style={{
131
+ fontFamily: fontFamilySemiBold,
132
+ fontSize: tmplFontSize,
133
+ fontWeight: "700",
134
+ color: "#374151",
135
+ letterSpacing: 0.2,
136
+ }}>TMPL</Text>
137
+ </View>
138
+ </View>
139
+ )}
140
+ </View>
141
+ <View style={{
142
+ height: bottomHeight,
143
+ alignItems: "center",
144
+ justifyContent: "center",
145
+ backgroundColor: color,
146
+ borderBottomLeftRadius: radius,
147
+ borderBottomRightRadius: radius,
148
+ }}>
149
+ <Text style={{
150
+ fontFamily: fontFamilySemiBold,
151
+ fontSize,
152
+ fontWeight: "700",
153
+ color: "white",
154
+ letterSpacing: 0.3,
155
+ lineHeight,
156
+ }}>{label}</Text>
157
+ </View>
158
+ </View>
159
+ );
160
+ }
@@ -0,0 +1,188 @@
1
+ // FileGalleryModal — pure fullscreen image preview with prev/next nav + ESC close.
2
+ // Pure primitive: takes DisplayFile[] only, no Lotics domain coupling. Frontend
3
+ // has a richer modal in `frontend/components/file_gallery.tsx` that handles
4
+ // xlsx/docx/pdf inline rendering — leave that one for Lotics-specific previews
5
+ // and use this primitive when only images need previewing.
6
+
7
+ import { useCallback, useEffect, useRef } from "react";
8
+ import {
9
+ Image,
10
+ Modal,
11
+ Pressable,
12
+ StyleSheet,
13
+ View,
14
+ } from "react-native";
15
+ import { Text } from "./text";
16
+ import { Icon } from "./icon";
17
+ import { colors } from "./colors";
18
+ import { isImageMimeType } from "./mime";
19
+ import type { DisplayFile } from "./file_thumbnail";
20
+
21
+ export interface FileGalleryModalProps {
22
+ files: DisplayFile[];
23
+ /** Index of the file currently shown. When null, the modal is closed. */
24
+ activeIndex: number | null;
25
+ onIndexChange: (next: number | null) => void;
26
+ /**
27
+ * Optional caption suffix shown under the image. Useful for hint text like
28
+ * "ESC để đóng · ←/→ để chuyển". When omitted, just shows filename + count.
29
+ */
30
+ captionHint?: string;
31
+ }
32
+
33
+ /**
34
+ * Image-focused fullscreen preview. Non-image MIME types render as a simple
35
+ * "{filename} · cannot preview" card — callers needing rich preview should use
36
+ * a Lotics-specific gallery instead.
37
+ */
38
+ export function FileGalleryModal(props: FileGalleryModalProps) {
39
+ const { files, activeIndex, onIndexChange, captionHint } = props;
40
+ const visible = activeIndex !== null;
41
+ const overlayRef = useRef<View | null>(null);
42
+
43
+ const close = useCallback(() => onIndexChange(null), [onIndexChange]);
44
+ const next = useCallback(() => {
45
+ if (activeIndex === null) return;
46
+ onIndexChange(Math.min(activeIndex + 1, files.length - 1));
47
+ }, [activeIndex, files.length, onIndexChange]);
48
+ const prev = useCallback(() => {
49
+ if (activeIndex === null) return;
50
+ onIndexChange(Math.max(activeIndex - 1, 0));
51
+ }, [activeIndex, onIndexChange]);
52
+
53
+ // Keyboard navigation (web only — RN native handles via Modal accessibility).
54
+ useEffect(() => {
55
+ if (!visible || typeof window === "undefined") return;
56
+ function onKey(e: KeyboardEvent) {
57
+ if (e.key === "Escape") close();
58
+ else if (e.key === "ArrowRight") next();
59
+ else if (e.key === "ArrowLeft") prev();
60
+ }
61
+ window.addEventListener("keydown", onKey);
62
+ return () => window.removeEventListener("keydown", onKey);
63
+ }, [visible, close, next, prev]);
64
+
65
+ if (!visible || activeIndex === null) return null;
66
+ const file = files[activeIndex];
67
+ if (!file) return null;
68
+
69
+ const total = files.length;
70
+ const caption = captionHint
71
+ ? `${file.filename} · ${activeIndex + 1} / ${total} · ${captionHint}`
72
+ : `${file.filename} · ${activeIndex + 1} / ${total}`;
73
+
74
+ return (
75
+ <Modal visible transparent onRequestClose={close} animationType="fade">
76
+ <View style={styles.overlay} ref={overlayRef}>
77
+ <Pressable
78
+ accessibilityRole="button"
79
+ accessibilityLabel="Close"
80
+ onPress={close}
81
+ style={StyleSheet.absoluteFill}
82
+ />
83
+
84
+ {isImageMimeType(file.mimeType) ? (
85
+ <Image
86
+ source={{ uri: file.url }}
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
+ )}
100
+
101
+ {activeIndex > 0 ? (
102
+ <Pressable
103
+ onPress={prev}
104
+ accessibilityRole="button"
105
+ accessibilityLabel="Previous"
106
+ style={[styles.navButton, styles.navLeft]}
107
+ >
108
+ <Icon name="chevron-left" size={28} color={colors.white} />
109
+ </Pressable>
110
+ ) : null}
111
+
112
+ {activeIndex < total - 1 ? (
113
+ <Pressable
114
+ onPress={next}
115
+ accessibilityRole="button"
116
+ accessibilityLabel="Next"
117
+ style={[styles.navButton, styles.navRight]}
118
+ >
119
+ <Icon name="chevron-right" size={28} color={colors.white} />
120
+ </Pressable>
121
+ ) : null}
122
+
123
+ <View style={styles.captionWrap} pointerEvents="none">
124
+ <View style={styles.captionPill}>
125
+ <Text size="sm" style={styles.captionText}>{caption}</Text>
126
+ </View>
127
+ </View>
128
+ </View>
129
+ </Modal>
130
+ );
131
+ }
132
+
133
+ const styles = StyleSheet.create({
134
+ overlay: {
135
+ flex: 1,
136
+ backgroundColor: "rgba(0, 0, 0, 0.88)",
137
+ justifyContent: "center",
138
+ alignItems: "center",
139
+ },
140
+ image: {
141
+ width: "92%",
142
+ height: "85%",
143
+ },
144
+ unsupportedCard: {
145
+ backgroundColor: colors.white,
146
+ borderRadius: 8,
147
+ padding: 24,
148
+ maxWidth: 480,
149
+ gap: 8,
150
+ alignItems: "center",
151
+ },
152
+ unsupportedText: {
153
+ fontWeight: "600",
154
+ },
155
+ navButton: {
156
+ position: "absolute",
157
+ top: "50%",
158
+ transform: [{ translateY: -28 }],
159
+ width: 56,
160
+ height: 56,
161
+ borderRadius: 28,
162
+ backgroundColor: "rgba(0, 0, 0, 0.4)",
163
+ justifyContent: "center",
164
+ alignItems: "center",
165
+ },
166
+ navLeft: {
167
+ left: 16,
168
+ },
169
+ navRight: {
170
+ right: 16,
171
+ },
172
+ captionWrap: {
173
+ position: "absolute",
174
+ bottom: 24,
175
+ left: 0,
176
+ right: 0,
177
+ alignItems: "center",
178
+ },
179
+ captionPill: {
180
+ backgroundColor: "rgba(0, 0, 0, 0.7)",
181
+ paddingHorizontal: 12,
182
+ paddingVertical: 6,
183
+ borderRadius: 6,
184
+ },
185
+ captionText: {
186
+ color: colors.white,
187
+ },
188
+ });