@lotics/ui 1.8.0 → 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.
package/package.json CHANGED
@@ -1,10 +1,14 @@
1
1
  {
2
2
  "name": "@lotics/ui",
3
- "version": "1.8.0",
3
+ "version": "1.9.0",
4
4
  "type": "module",
5
5
  "exports": {
6
6
  "./tokens": "./src/tokens.ts",
7
7
  "./colors": "./src/colors.ts",
8
+ "./mime": "./src/mime.ts",
9
+ "./file_badge": "./src/file_badge.tsx",
10
+ "./file_thumbnail": "./src/file_thumbnail.tsx",
11
+ "./file_gallery_modal": "./src/file_gallery_modal.tsx",
8
12
  "./pagination": "./src/pagination.tsx",
9
13
  "./bar_chart": "./src/bar_chart.tsx",
10
14
  "./line_chart": "./src/line_chart.tsx",
@@ -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
+ });
@@ -0,0 +1,437 @@
1
+ import { FileBadge, resolveMime } from "./file_badge";
2
+ import { fontFamilySemiBold } from "./text_utils";
3
+ import { Text } from "./text";
4
+ import { Icon, type IconName } from "./icon";
5
+ import { Checkbox } from "./checkbox";
6
+ import { colors } from "./colors";
7
+ import { useCallback, useState } from "react";
8
+ import {
9
+ GestureResponderEvent,
10
+ Image,
11
+ Text as RNText,
12
+ View,
13
+ Pressable,
14
+ StyleSheet,
15
+ Linking,
16
+ } from "react-native";
17
+ import { isImageMimeType, isVideoMimeType, isAudioMimeType } from "./mime";
18
+
19
+ export const THUMBNAIL_SIZE = 96;
20
+ export const COMPACT_THUMBNAIL_SIZE = 32;
21
+
22
+ /**
23
+ * Returns the icon name for playable media (video/audio) so a MediaCard can
24
+ * render a play/music glyph instead of document placeholder lines. Returns
25
+ * undefined for non-media files, which should fall back to DocumentCard.
26
+ */
27
+ export function getMediaIcon(mimeType: string): IconName | undefined {
28
+ if (isVideoMimeType(mimeType)) return "play";
29
+ if (isAudioMimeType(mimeType)) return "music";
30
+ return undefined;
31
+ }
32
+
33
+ // =============================================================================
34
+ // Core Display Type
35
+ // =============================================================================
36
+
37
+ /**
38
+ * Normalized file format for display components.
39
+ * All file sources (database, chat messages, uploads) convert to this.
40
+ */
41
+ export interface DisplayFile {
42
+ id: string;
43
+ filename: string;
44
+ mimeType: string;
45
+ /** Direct URL to the file */
46
+ url: string;
47
+ /** Optional thumbnail URL for images */
48
+ thumbnailUrl?: string;
49
+ /** Preview URL for legacy Excel files (.xls converted to .xlsx server-side) */
50
+ previewUrl?: string;
51
+ }
52
+
53
+ // =============================================================================
54
+ // FileThumbnail - Main display component
55
+ // =============================================================================
56
+
57
+ interface FileThumbnailProps {
58
+ file: DisplayFile;
59
+ /** Explicit pixel size. When omitted, fills container (width: "100%", aspectRatio: 1). */
60
+ size?: number;
61
+ /** Called when thumbnail is pressed */
62
+ onPress?: () => void;
63
+ /** Called when thumbnail is long-pressed */
64
+ onLongPress?: () => void;
65
+ /** Called when the completed file is removed */
66
+ onRemove?: () => void;
67
+ /** When defined, renders a selection overlay with a checkbox. */
68
+ selected?: boolean;
69
+ }
70
+
71
+ /**
72
+ * Displays a file as a square thumbnail.
73
+ * Works with any file source via DisplayFile interface.
74
+ */
75
+ export function FileThumbnail(props: FileThumbnailProps) {
76
+ const { file, size, onPress, onLongPress, onRemove, selected } = props;
77
+
78
+ const rootStyle =
79
+ size !== undefined ? { width: size, height: size } : { width: "100%" as const, aspectRatio: 1 };
80
+
81
+ if (isImageMimeType(file.mimeType)) {
82
+ return (
83
+ <View style={rootStyle}>
84
+ <ImageThumbnail file={file} size={size} onPress={onPress} onLongPress={onLongPress} />
85
+ {onRemove && <RemoveButton onPress={onRemove} />}
86
+ {selected !== undefined && <SelectionOverlay selected={selected} />}
87
+ </View>
88
+ );
89
+ }
90
+
91
+ const mediaIcon = getMediaIcon(file.mimeType);
92
+
93
+ if (size !== undefined && size <= COMPACT_THUMBNAIL_SIZE && mediaIcon === undefined) {
94
+ return (
95
+ <View style={rootStyle}>
96
+ <DocumentBadge
97
+ mimeType={file.mimeType}
98
+ size={size}
99
+ onPress={onPress ?? (() => Linking.openURL(file.url))}
100
+ onLongPress={onLongPress}
101
+ />
102
+ {onRemove && <RemoveButton onPress={onRemove} />}
103
+ </View>
104
+ );
105
+ }
106
+
107
+ return (
108
+ <View style={rootStyle}>
109
+ {mediaIcon !== undefined ? (
110
+ <MediaCard
111
+ mimeType={file.mimeType}
112
+ filename={file.filename}
113
+ icon={mediaIcon}
114
+ size={size}
115
+ onPress={onPress ?? (() => Linking.openURL(file.url))}
116
+ onLongPress={onLongPress}
117
+ />
118
+ ) : (
119
+ <DocumentCard
120
+ mimeType={file.mimeType}
121
+ filename={file.filename}
122
+ size={size}
123
+ onPress={onPress ?? (() => Linking.openURL(file.url))}
124
+ onLongPress={onLongPress}
125
+ />
126
+ )}
127
+ {onRemove && <RemoveButton onPress={onRemove} />}
128
+ {selected !== undefined && <SelectionOverlay selected={selected} />}
129
+ </View>
130
+ );
131
+ }
132
+
133
+ // =============================================================================
134
+ // Base Components (exported for direct use when needed)
135
+ // =============================================================================
136
+
137
+ interface DocumentBadgeProps {
138
+ mimeType: string;
139
+ size?: number;
140
+ isTemplate?: boolean;
141
+ onPress?: () => void;
142
+ onLongPress?: () => void;
143
+ }
144
+
145
+ /**
146
+ * Compact two-tone file badge with document lines and colored label.
147
+ */
148
+ export function DocumentBadge(props: DocumentBadgeProps) {
149
+ const { mimeType, isTemplate, onPress, onLongPress } = props;
150
+
151
+ return (
152
+ <Pressable onPress={onPress} onLongPress={onLongPress}>
153
+ <FileBadge mimeType={mimeType} isTemplate={isTemplate} />
154
+ </Pressable>
155
+ );
156
+ }
157
+
158
+ interface DocumentCardProps {
159
+ mimeType: string;
160
+ filename: string;
161
+ size?: number;
162
+ onPress?: () => void;
163
+ onLongPress?: () => void;
164
+ overlay?: React.ReactNode;
165
+ }
166
+
167
+ /**
168
+ * Document preview card: placeholder lines (future: file preview) + footer with filename and type label.
169
+ */
170
+ export function DocumentCard(props: DocumentCardProps) {
171
+ const { mimeType, filename, size, onPress, onLongPress, overlay } = props;
172
+ const { label, color } = resolveMime(mimeType);
173
+
174
+ const sizeStyle =
175
+ size !== undefined
176
+ ? { width: size, height: size }
177
+ : { width: "100%" as const, height: "100%" as const };
178
+
179
+ return (
180
+ <Pressable style={[styles.documentCard, sizeStyle]} onPress={onPress} onLongPress={onLongPress}>
181
+ <View style={styles.documentCardBody}>
182
+ <View style={styles.placeholderLines}>
183
+ <View style={styles.placeholderLine} />
184
+ <View style={styles.placeholderLine} />
185
+ <View style={[styles.placeholderLine, { width: "60%" }]} />
186
+ <View style={styles.placeholderLine} />
187
+ <View style={[styles.placeholderLine, { width: "40%" }]} />
188
+ </View>
189
+ </View>
190
+ <View style={styles.documentCardFooter}>
191
+ <Text size="xs" numberOfLines={1} color="zinc-500" userSelect="none">
192
+ {filename}
193
+ </Text>
194
+ <View style={[styles.typeLabel, { backgroundColor: color }]}>
195
+ <RNText style={styles.typeLabelText}>{label}</RNText>
196
+ </View>
197
+ </View>
198
+ {overlay}
199
+ </Pressable>
200
+ );
201
+ }
202
+
203
+ interface MediaCardProps {
204
+ mimeType: string;
205
+ filename: string;
206
+ /** Icon rendered in the body — typically "play" for video or "music" for audio. */
207
+ icon: IconName;
208
+ size?: number;
209
+ onPress?: () => void;
210
+ onLongPress?: () => void;
211
+ overlay?: React.ReactNode;
212
+ }
213
+
214
+ /**
215
+ * Playable-media preview card: colored body with a large icon (play/music) +
216
+ * footer with filename and type label. Visual counterpart to DocumentCard
217
+ * for videos and audio files.
218
+ */
219
+ export function MediaCard(props: MediaCardProps) {
220
+ const { mimeType, filename, size, onPress, onLongPress, overlay, icon } = props;
221
+ const { label, color } = resolveMime(mimeType);
222
+
223
+ const sizeStyle =
224
+ size !== undefined
225
+ ? { width: size, height: size }
226
+ : { width: "100%" as const, height: "100%" as const };
227
+
228
+ const isCompact = size !== undefined && size <= COMPACT_THUMBNAIL_SIZE;
229
+
230
+ if (isCompact) {
231
+ const iconSize = Math.max(12, Math.round(size * 0.55));
232
+ return (
233
+ <Pressable
234
+ style={[styles.mediaCardCompact, sizeStyle, { backgroundColor: color }]}
235
+ onPress={onPress}
236
+ onLongPress={onLongPress}
237
+ >
238
+ <Icon name={icon} size={iconSize} color={colors.white} />
239
+ {overlay}
240
+ </Pressable>
241
+ );
242
+ }
243
+
244
+ const iconSize = size !== undefined ? Math.max(20, Math.min(Math.round(size * 0.4), 56)) : 44;
245
+ const backdrop = iconSize + 20;
246
+
247
+ return (
248
+ <Pressable style={[styles.documentCard, sizeStyle]} onPress={onPress} onLongPress={onLongPress}>
249
+ <View style={[styles.mediaCardBody, { backgroundColor: color }]}>
250
+ <View style={[styles.mediaIconBackdrop, { width: backdrop, height: backdrop, borderRadius: backdrop / 2 }]}>
251
+ <Icon name={icon} size={iconSize} color={colors.white} />
252
+ </View>
253
+ </View>
254
+ <View style={styles.documentCardFooter}>
255
+ <Text size="xs" numberOfLines={1} color="zinc-500" userSelect="none">
256
+ {filename}
257
+ </Text>
258
+ <View style={[styles.typeLabel, { backgroundColor: color }]}>
259
+ <RNText style={styles.typeLabelText}>{label}</RNText>
260
+ </View>
261
+ </View>
262
+ {overlay}
263
+ </Pressable>
264
+ );
265
+ }
266
+
267
+ // =============================================================================
268
+ // Internal Components (exported so upload-aware wrappers in @lotics/ui-internal
269
+ // can reuse them without duplication)
270
+ // =============================================================================
271
+
272
+ interface ImageThumbnailProps {
273
+ file: DisplayFile;
274
+ /** Explicit pixel size. When omitted, fills container. */
275
+ size?: number;
276
+ onPress?: () => void;
277
+ onLongPress?: () => void;
278
+ }
279
+
280
+ function ImageThumbnail(props: ImageThumbnailProps) {
281
+ const { file, size, onPress, onLongPress } = props;
282
+ const [useFallback, setUseFallback] = useState(false);
283
+
284
+ // Use thumbnail if available, fallback to full URL
285
+ const url = !useFallback && file.thumbnailUrl ? file.thumbnailUrl : file.url;
286
+
287
+ const sizeStyle =
288
+ size !== undefined
289
+ ? { width: size, height: size }
290
+ : { width: "100%" as const, height: "100%" as const };
291
+
292
+ return (
293
+ <Pressable
294
+ onPress={onPress}
295
+ onLongPress={onLongPress}
296
+ style={[styles.imageThumbnail, sizeStyle]}
297
+ >
298
+ <Image
299
+ source={{ uri: url }}
300
+ style={styles.image}
301
+ resizeMode="cover"
302
+ onError={() => setUseFallback(true)}
303
+ />
304
+ </Pressable>
305
+ );
306
+ }
307
+
308
+ export function RemoveButton({ onPress }: { onPress: () => void }) {
309
+ const handlePress = useCallback(
310
+ (e: GestureResponderEvent) => {
311
+ e.stopPropagation();
312
+ onPress();
313
+ },
314
+ [onPress],
315
+ );
316
+ return (
317
+ <Pressable onPress={handlePress} style={styles.removeButton}>
318
+ <Icon name="x" size={14} color={colors.zinc[700]} />
319
+ </Pressable>
320
+ );
321
+ }
322
+
323
+ function SelectionOverlay({ selected }: { selected: boolean }) {
324
+ return (
325
+ <View
326
+ style={[styles.selectionOverlay, selected && styles.selectionOverlaySelected]}
327
+ pointerEvents="none"
328
+ >
329
+ <View style={styles.selectionCheckbox}>
330
+ <Checkbox checked={selected} />
331
+ </View>
332
+ </View>
333
+ );
334
+ }
335
+
336
+ // =============================================================================
337
+ // Styles
338
+ // =============================================================================
339
+
340
+ const styles = StyleSheet.create({
341
+ documentCard: {
342
+ borderRadius: 8,
343
+ backgroundColor: colors.white,
344
+ borderWidth: 1,
345
+ borderColor: colors.zinc["200"],
346
+ overflow: "hidden",
347
+ },
348
+ documentCardBody: {
349
+ flex: 1,
350
+ justifyContent: "center",
351
+ padding: 10,
352
+ },
353
+ mediaCardBody: {
354
+ flex: 1,
355
+ justifyContent: "center",
356
+ alignItems: "center",
357
+ },
358
+ mediaCardCompact: {
359
+ borderRadius: 6,
360
+ alignItems: "center",
361
+ justifyContent: "center",
362
+ overflow: "hidden",
363
+ },
364
+ mediaIconBackdrop: {
365
+ alignItems: "center",
366
+ justifyContent: "center",
367
+ backgroundColor: "rgba(255, 255, 255, 0.22)",
368
+ },
369
+ placeholderLines: {
370
+ gap: 4,
371
+ },
372
+ placeholderLine: {
373
+ height: 2,
374
+ borderRadius: 1,
375
+ backgroundColor: colors.zinc["200"],
376
+ },
377
+ documentCardFooter: {
378
+ gap: 2,
379
+ paddingHorizontal: 8,
380
+ paddingVertical: 6,
381
+ borderTopWidth: 1,
382
+ backgroundColor: colors.zinc['50'],
383
+ borderTopColor: colors.zinc["200"],
384
+ alignItems: "flex-start",
385
+ },
386
+ typeLabel: {
387
+ borderRadius: 3,
388
+ paddingHorizontal: 4,
389
+ paddingVertical: 1,
390
+ },
391
+ typeLabelText: {
392
+ fontFamily: fontFamilySemiBold,
393
+ fontSize: 8,
394
+ fontWeight: "700",
395
+ color: "white",
396
+ letterSpacing: 0.3,
397
+ },
398
+ imageThumbnail: {
399
+ borderRadius: 8,
400
+ overflow: "hidden",
401
+ },
402
+ image: {
403
+ width: "100%",
404
+ height: "100%",
405
+ },
406
+ removeButton: {
407
+ position: "absolute",
408
+ top: -6,
409
+ right: -6,
410
+ width: 22,
411
+ height: 22,
412
+ alignItems: "center",
413
+ justifyContent: "center",
414
+ borderRadius: 999,
415
+ borderWidth: 1,
416
+ borderColor: colors.border,
417
+ backgroundColor: colors.zinc["50"],
418
+ zIndex: 1,
419
+ },
420
+ selectionOverlay: {
421
+ position: "absolute",
422
+ top: 0,
423
+ left: 0,
424
+ right: 0,
425
+ bottom: 0,
426
+ borderRadius: 8,
427
+ zIndex: 1,
428
+ },
429
+ selectionOverlaySelected: {
430
+ backgroundColor: "rgba(0, 0, 0, 0.15)",
431
+ },
432
+ selectionCheckbox: {
433
+ position: "absolute",
434
+ bottom: 4,
435
+ right: 4,
436
+ },
437
+ });
package/src/mime.ts ADDED
@@ -0,0 +1,15 @@
1
+ // Tiny MIME helpers inlined so `@lotics/ui` stays free of @lotics/shared
2
+ // (enforced by primitives_purity.test.ts). The richer MIME taxonomy lives
3
+ // in `@lotics/shared/file_type` for Lotics-coupled code.
4
+
5
+ export function isImageMimeType(mimeType: string): boolean {
6
+ return mimeType.toLowerCase().startsWith("image/");
7
+ }
8
+
9
+ export function isVideoMimeType(mimeType: string): boolean {
10
+ return mimeType.toLowerCase().startsWith("video/");
11
+ }
12
+
13
+ export function isAudioMimeType(mimeType: string): boolean {
14
+ return mimeType.toLowerCase().startsWith("audio/");
15
+ }