@lotics/ui 3.3.0 → 3.5.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,6 +1,6 @@
1
1
  {
2
2
  "name": "@lotics/ui",
3
- "version": "3.3.0",
3
+ "version": "3.5.0",
4
4
  "type": "module",
5
5
  "exports": {
6
6
  "./tokens": "./src/tokens.ts",
@@ -19,6 +19,8 @@
19
19
  "./file_preview_types": "./src/file_preview_types.ts",
20
20
  "./file_gallery_modal": "./src/file_gallery_modal.tsx",
21
21
  "./image_gallery": "./src/image_gallery.tsx",
22
+ "./rotatable_image": "./src/rotatable_image.tsx",
23
+ "./use_image_rotation": "./src/use_image_rotation.ts",
22
24
  "./file_thumbnail_grid": "./src/file_thumbnail_grid.tsx",
23
25
  "./pagination": "./src/pagination.tsx",
24
26
  "./bar_chart": "./src/bar_chart.tsx",
@@ -79,6 +81,7 @@
79
81
  "./action_menu": "./src/action_menu.tsx",
80
82
  "./card_select_item": "./src/card_select_item.tsx",
81
83
  "./badge": "./src/badge.tsx",
84
+ "./callout": "./src/callout.tsx",
82
85
  "./divider": "./src/divider.tsx",
83
86
  "./spacer": "./src/spacer.tsx",
84
87
  "./index.css": "./src/index.css",
package/src/alert.tsx CHANGED
@@ -135,14 +135,20 @@ class Alert {
135
135
  }, []);
136
136
 
137
137
  useEffect(() => {
138
+ // Close on KEYUP + capture + stopImmediatePropagation — the same event
139
+ // RN-web Modal closes on, run first and stopped — so an alert opened over
140
+ // a drawer/dialog closes only the alert, not the modal behind it. Closing
141
+ // on keydown would unmount the alert before the keyup and let RN's keyup
142
+ // close the modal underneath too. (Matches the Popover's ESC handling.)
138
143
  const handleEscape = (e: KeyboardEvent) => {
139
144
  if (e.key === "Escape" && alertOptions.cancelable !== false) {
145
+ e.stopImmediatePropagation();
140
146
  handleDismiss();
141
147
  }
142
148
  };
143
149
 
144
- document.addEventListener("keydown", handleEscape);
145
- return () => document.removeEventListener("keydown", handleEscape);
150
+ document.addEventListener("keyup", handleEscape, true);
151
+ return () => document.removeEventListener("keyup", handleEscape, true);
146
152
  }, [handleDismiss]);
147
153
 
148
154
  if (!visible) {
@@ -0,0 +1,75 @@
1
+ import { View, StyleSheet } from "react-native";
2
+ import { Text } from "./text";
3
+ import { Icon, type IconName } from "./icon";
4
+ import { colors } from "./colors";
5
+
6
+ export type CalloutTone = "info" | "success" | "warning" | "error" | "neutral";
7
+
8
+ interface CalloutProps {
9
+ /** Sets the accent (icon + tint + border) and the default icon. Default "info". */
10
+ tone?: CalloutTone;
11
+ /** Optional bold heading above the message. */
12
+ title?: string;
13
+ /** The message body — kept in the default text color for legibility on the tint. */
14
+ message: string;
15
+ /** Override the per-tone default icon (any kit icon), or `null` to drop it. */
16
+ icon?: IconName | null;
17
+ }
18
+
19
+ interface ToneStyle {
20
+ bg: string;
21
+ border: string;
22
+ icon: string;
23
+ glyph: IconName;
24
+ }
25
+
26
+ const TONES: Record<CalloutTone, ToneStyle> = {
27
+ info: { bg: colors.blue[50], border: colors.blue[200], icon: colors.blue[600], glyph: "info" },
28
+ success: { bg: colors.emerald[50], border: colors.emerald[200], icon: colors.emerald[600], glyph: "circle-check" },
29
+ warning: { bg: colors.amber[50], border: colors.amber[200], icon: colors.amber[600], glyph: "triangle-alert" },
30
+ error: { bg: colors.red[50], border: colors.red[200], icon: colors.red[600], glyph: "circle-alert" },
31
+ neutral: { bg: colors.zinc[50], border: colors.zinc[200], icon: colors.zinc[500], glyph: "info" },
32
+ };
33
+
34
+ /**
35
+ * An inline callout — a tinted, bordered box carrying a short status message
36
+ * (info / success / warning / error / neutral). Use it inline in a flow: form
37
+ * feedback, a heads-up, an actionable empty state. For a blocking, dismissible
38
+ * prompt use `Alert`; for a one-word status use `Badge`. The tone is carried by
39
+ * the icon + tint + border (never color alone — the icon and text stay legible),
40
+ * so it reads on a glance and meets contrast on the light tint.
41
+ */
42
+ export function Callout({ tone = "info", title, message, icon }: CalloutProps) {
43
+ const t = TONES[tone];
44
+ const glyph = icon === null ? null : (icon ?? t.glyph);
45
+ return (
46
+ <View
47
+ accessibilityRole={tone === "error" || tone === "warning" ? "alert" : undefined}
48
+ style={[styles.container, { backgroundColor: t.bg, borderColor: t.border }]}
49
+ >
50
+ {glyph ? <Icon name={glyph} size={18} color={t.icon} /> : null}
51
+ <View style={styles.body}>
52
+ {title ? (
53
+ <Text size="sm" weight="semibold">
54
+ {title}
55
+ </Text>
56
+ ) : null}
57
+ <Text size="sm">{message}</Text>
58
+ </View>
59
+ </View>
60
+ );
61
+ }
62
+
63
+ export type { CalloutProps };
64
+
65
+ const styles = StyleSheet.create({
66
+ container: {
67
+ flexDirection: "row",
68
+ alignItems: "flex-start",
69
+ gap: 10,
70
+ padding: 12,
71
+ borderRadius: 10,
72
+ borderWidth: 1,
73
+ },
74
+ body: { flex: 1, gap: 2, paddingTop: 1 },
75
+ });
@@ -1,21 +1,36 @@
1
+ import type { ReactNode } from "react";
1
2
  import { View, StyleSheet } from "react-native";
2
3
  import { Text } from "./text";
4
+ import { Icon, type IconName } from "./icon";
5
+ import { colors } from "./colors";
3
6
 
4
7
  interface EmptyStateProps {
5
8
  /** What's empty, in plain language ("Không có nhân viên nào khớp"). */
6
9
  message: string;
7
10
  /** What the user can do about it ("Thử từ khóa khác"). */
8
11
  hint?: string;
12
+ /** Optional Lucide glyph centered above the message — a visual anchor so the
13
+ * empty region reads as a deliberate state, not two lone lines of muted text. */
14
+ icon?: IconName;
15
+ /** Optional call-to-action below the text (e.g. a Button) for an actionable
16
+ * empty state ("no records yet → create one"). */
17
+ action?: ReactNode;
9
18
  }
10
19
 
11
20
  /**
12
21
  * Centered placeholder for an empty list/filter result. Generous vertical
13
22
  * space on purpose — an empty region that collapses to nothing reads as a
14
- * rendering bug, not a state.
23
+ * rendering bug, not a state. Pass `icon` for a visual anchor and `action`
24
+ * for a CTA when the empty state is actionable.
15
25
  */
16
26
  export function EmptyState(props: EmptyStateProps) {
17
27
  return (
18
28
  <View style={styles.container}>
29
+ {props.icon ? (
30
+ <View style={styles.icon}>
31
+ <Icon name={props.icon} size={28} color={colors.zinc[400]} />
32
+ </View>
33
+ ) : null}
19
34
  <Text size="sm" color="muted">
20
35
  {props.message}
21
36
  </Text>
@@ -24,6 +39,7 @@ export function EmptyState(props: EmptyStateProps) {
24
39
  {props.hint}
25
40
  </Text>
26
41
  ) : null}
42
+ {props.action ? <View style={styles.action}>{props.action}</View> : null}
27
43
  </View>
28
44
  );
29
45
  }
@@ -34,4 +50,10 @@ const styles = StyleSheet.create({
34
50
  alignItems: "center",
35
51
  gap: 4,
36
52
  },
53
+ icon: {
54
+ marginBottom: 4,
55
+ },
56
+ action: {
57
+ marginTop: 8,
58
+ },
37
59
  });
@@ -16,6 +16,8 @@ import { Icon } from "./icon";
16
16
  import { colors } from "./colors";
17
17
  import type { DisplayFile } from "./file_thumbnail";
18
18
  import { FilePreview } from "./file_preview";
19
+ import { isImageMimeType } from "./mime";
20
+ import { useImageRotation, type ImageRotation } from "./use_image_rotation";
19
21
  import type { PreviewLabels } from "./file_preview_types";
20
22
 
21
23
  export interface FileGalleryModalProps {
@@ -32,6 +34,14 @@ export interface FileGalleryModalProps {
32
34
  labels?: Partial<PreviewLabels>;
33
35
  /** Reported when a file fails to render (host wires to its logger). */
34
36
  onError?: (error: unknown, meta: { fileId: string; mimeType: string }) => void;
37
+ /**
38
+ * Optional shared rotation state (from `useImageRotation`) — pass it to keep an
39
+ * inline gallery and this modal in sync. Omitted ⇒ the modal keeps its own
40
+ * in-memory rotation. View-only; image files only.
41
+ */
42
+ rotation?: ImageRotation;
43
+ /** Show 90° rotate controls for image files. Default true. */
44
+ rotatable?: boolean;
35
45
  }
36
46
 
37
47
  /**
@@ -39,9 +49,12 @@ export interface FileGalleryModalProps {
39
49
  * via FilePreview; unknown types show a download placeholder.
40
50
  */
41
51
  export function FileGalleryModal(props: FileGalleryModalProps) {
42
- const { files, activeIndex, onIndexChange, captionHint, labels, onError } = props;
52
+ const { files, activeIndex, onIndexChange, captionHint, labels, onError, rotation, rotatable = true } = props;
43
53
  const visible = activeIndex !== null;
44
54
  const overlayRef = useRef<View | null>(null);
55
+ // Own the rotation when the host doesn't share one (standalone consumers).
56
+ const internalRotation = useImageRotation();
57
+ const rot = rotation ?? internalRotation;
45
58
 
46
59
  const close = useCallback(() => onIndexChange(null), [onIndexChange]);
47
60
  const next = useCallback(() => {
@@ -53,22 +66,35 @@ export function FileGalleryModal(props: FileGalleryModalProps) {
53
66
  onIndexChange(Math.max(activeIndex - 1, 0));
54
67
  }, [activeIndex, onIndexChange]);
55
68
 
56
- // Keyboard navigation (web only — RN native handles via Modal accessibility).
69
+ // Arrow nav (web only — RN native handles via Modal accessibility). Capture +
70
+ // stopImmediatePropagation so ←/→ drive THIS gallery and never also reach a
71
+ // record-nav arrow handler on a drawer behind it.
72
+ //
73
+ // Escape is deliberately NOT handled here — it closes via the Modal's
74
+ // `onRequestClose`, which RN-web fires on KEYUP and scopes to the topmost
75
+ // modal (its `activeModalStack`). Closing here on KEYDOWN would unmount this
76
+ // modal first, so by the keyup RN's active modal is the drawer underneath and
77
+ // it closes that too — the "ESC closes the preview AND the record" bug.
57
78
  useEffect(() => {
58
79
  if (!visible || typeof window === "undefined") return;
59
80
  function onKey(e: KeyboardEvent) {
60
- if (e.key === "Escape") close();
61
- else if (e.key === "ArrowRight") next();
62
- else if (e.key === "ArrowLeft") prev();
81
+ if (e.key === "ArrowRight") {
82
+ e.stopImmediatePropagation();
83
+ next();
84
+ } else if (e.key === "ArrowLeft") {
85
+ e.stopImmediatePropagation();
86
+ prev();
87
+ }
63
88
  }
64
- window.addEventListener("keydown", onKey);
65
- return () => window.removeEventListener("keydown", onKey);
66
- }, [visible, close, next, prev]);
89
+ window.addEventListener("keydown", onKey, true);
90
+ return () => window.removeEventListener("keydown", onKey, true);
91
+ }, [visible, next, prev]);
67
92
 
68
93
  if (!visible || activeIndex === null) return null;
69
94
  const file = files[activeIndex];
70
95
  if (!file) return null;
71
96
 
97
+ const isImage = isImageMimeType(file.mimeType);
72
98
  const total = files.length;
73
99
  const caption = captionHint
74
100
  ? `${file.filename} · ${activeIndex + 1} / ${total} · ${captionHint}`
@@ -85,9 +111,30 @@ export function FileGalleryModal(props: FileGalleryModalProps) {
85
111
  />
86
112
 
87
113
  <View style={styles.previewPanel}>
88
- <FilePreview file={file} labels={labels} onError={onError} />
114
+ <FilePreview file={file} labels={labels} onError={onError} rotation={rot.rotationFor(file.id)} />
89
115
  </View>
90
116
 
117
+ {isImage && rotatable ? (
118
+ <View style={styles.controls}>
119
+ <Pressable
120
+ onPress={() => rot.rotate(file.id, -1)}
121
+ accessibilityRole="button"
122
+ accessibilityLabel="Rotate left"
123
+ style={styles.controlButton}
124
+ >
125
+ <Icon name="rotate-ccw" size={20} color={colors.white} />
126
+ </Pressable>
127
+ <Pressable
128
+ onPress={() => rot.rotate(file.id, 1)}
129
+ accessibilityRole="button"
130
+ accessibilityLabel="Rotate right"
131
+ style={styles.controlButton}
132
+ >
133
+ <Icon name="rotate-cw" size={20} color={colors.white} />
134
+ </Pressable>
135
+ </View>
136
+ ) : null}
137
+
91
138
  {activeIndex > 0 ? (
92
139
  <Pressable
93
140
  onPress={prev}
@@ -151,6 +198,21 @@ const styles = StyleSheet.create({
151
198
  navRight: {
152
199
  right: 16,
153
200
  },
201
+ controls: {
202
+ position: "absolute",
203
+ top: 16,
204
+ right: 16,
205
+ flexDirection: "row",
206
+ gap: 8,
207
+ },
208
+ controlButton: {
209
+ width: 44,
210
+ height: 44,
211
+ borderRadius: 22,
212
+ backgroundColor: "rgba(0, 0, 0, 0.4)",
213
+ justifyContent: "center",
214
+ alignItems: "center",
215
+ },
154
216
  captionWrap: {
155
217
  position: "absolute",
156
218
  bottom: 24,
@@ -1,7 +1,8 @@
1
- import { View, Image, StyleSheet } from "react-native";
1
+ import { View, StyleSheet } from "react-native";
2
2
  import { Text } from "./text";
3
3
  import { DocumentCard } from "./file_thumbnail";
4
4
  import { isImageMimeType } from "./mime";
5
+ import { RotatableImage } from "./rotatable_image";
5
6
  import { type FilePreviewProps, resolveLabels } from "./file_preview_types";
6
7
 
7
8
  /**
@@ -9,17 +10,10 @@ import { type FilePreviewProps, resolveLabels } from "./file_preview_types";
9
10
  * document placeholder. Rich document rendering (docx/xlsx/pdf) is web-only —
10
11
  * the `.web.tsx` variant carries it; native consumers download instead.
11
12
  */
12
- export function FilePreview({ file, labels }: FilePreviewProps) {
13
+ export function FilePreview({ file, labels, rotation }: FilePreviewProps) {
13
14
  const l = resolveLabels(labels);
14
15
  if (isImageMimeType(file.mimeType)) {
15
- return (
16
- <Image
17
- source={{ uri: file.url }}
18
- resizeMode="contain"
19
- style={styles.image}
20
- accessibilityIgnoresInvertColors
21
- />
22
- );
16
+ return <RotatableImage uri={file.url} alt={file.filename} rotation={rotation} />;
23
17
  }
24
18
  return (
25
19
  <View style={styles.placeholder}>
@@ -32,7 +26,6 @@ export function FilePreview({ file, labels }: FilePreviewProps) {
32
26
  }
33
27
 
34
28
  const styles = StyleSheet.create({
35
- image: { flex: 1, width: "100%" },
36
29
  placeholder: { flex: 1, justifyContent: "center", alignItems: "center", gap: 16 },
37
30
  placeholderText: { marginTop: 8 },
38
31
  });
@@ -13,6 +13,7 @@ import {
13
13
  } from "./mime";
14
14
  import { type FilePreviewProps, resolveLabels, type PreviewLabels } from "./file_preview_types";
15
15
  import { SpreadsheetView } from "./spreadsheet_view";
16
+ import { RotatableImage } from "./rotatable_image";
16
17
  import { downloadFileFromUrl } from "./download";
17
18
 
18
19
  /**
@@ -24,11 +25,18 @@ import { downloadFileFromUrl } from "./download";
24
25
  * Pure: i18n via `labels` props, errors via `onError` — no lingui/analytics
25
26
  * (purity test). Lifted from the frontend gallery so both share one renderer.
26
27
  */
27
- export function FilePreview({ file, labels, onError }: FilePreviewProps) {
28
+ export function FilePreview({ file, labels, onError, rotation }: FilePreviewProps) {
28
29
  const l = resolveLabels(labels);
29
30
 
30
31
  if (isImageMimeType(file.mimeType)) {
31
- return <img alt={file.filename} src={file.url} style={imageStyle} />;
32
+ return (
33
+ <RotatableImage
34
+ uri={file.url}
35
+ alt={file.filename}
36
+ rotation={rotation}
37
+ backgroundColor="rgba(10, 10, 10, 0.8)"
38
+ />
39
+ );
32
40
  }
33
41
  if (isPdfMimeType(file.mimeType)) {
34
42
  return <iframe src={file.url} title={file.filename} style={fullFrameStyle} />;
@@ -156,12 +164,6 @@ const styles = StyleSheet.create({
156
164
  placeholderText: { marginTop: 8 },
157
165
  });
158
166
 
159
- const imageStyle: React.CSSProperties = {
160
- width: "100%",
161
- height: "100%",
162
- objectFit: "contain",
163
- backgroundColor: "rgba(10, 10, 10, 0.8)",
164
- };
165
167
  const fullFrameStyle: React.CSSProperties = { width: "100%", height: "100%", border: "none" };
166
168
  const mediaElementStyle: React.CSSProperties = {
167
169
  width: "100%",
@@ -28,6 +28,8 @@ export interface FilePreviewProps {
28
28
  * package stays analytics-free), so the host wires this to its logger.
29
29
  */
30
30
  onError?: (error: unknown, meta: { fileId: string; mimeType: string }) => void;
31
+ /** View rotation in degrees (0/90/180/270) — applied to image previews only. */
32
+ rotation?: number;
31
33
  }
32
34
 
33
35
  export function resolveLabels(labels: Partial<PreviewLabels> | undefined): PreviewLabels {
@@ -1,4 +1,4 @@
1
- import { type ReactNode } from "react";
1
+ import { type ReactNode, useState } from "react";
2
2
  import { StyleSheet } from "react-native";
3
3
  import { Text } from "./text";
4
4
  import { Icon } from "./icon";
@@ -23,8 +23,11 @@ export interface FilterPillProps {
23
23
  clearLabel?: string;
24
24
  /** The editor revealed on press — a premium primitive (`RangeSlider`,
25
25
  * `Counter`, `PickerMenu` multi) or any composed control. `FilterPill` owns
26
- * the pill + popover chrome; the consumer owns the value and applies it. */
27
- children: ReactNode;
26
+ * the pill + popover chrome; the consumer owns the value and applies it.
27
+ * Pass a render function to receive `close` — the one editor that closes on
28
+ * action is single-select (`<PickerMenu>` → close on pick); range/counter/
29
+ * multi stay open and dismiss on outside-press, so they pass a plain node. */
30
+ children: ReactNode | ((api: { close: () => void }) => ReactNode);
28
31
  side?: PopoverSide;
29
32
  align?: PopoverAlign;
30
33
  /** Optional controlled popover state — for an editor that closes on Save. */
@@ -71,8 +74,18 @@ export function FilterPill(props: FilterPillProps) {
71
74
  // custom footer — a valued, non-clearable pill ("Target: 20") keeps its chevron.
72
75
  const showClear = active && !!onClear && !footer;
73
76
 
77
+ // Own the open state so the editor can close itself via the render-prop
78
+ // `close`, while still honoring a controlled `open`/`onOpenChange` from the
79
+ // parent (the Save-footer VALUE-pill case).
80
+ const [internalOpen, setInternalOpen] = useState(false);
81
+ const isOpen = open ?? internalOpen;
82
+ const setOpen = (next: boolean) => {
83
+ if (open === undefined) setInternalOpen(next);
84
+ onOpenChange?.(next);
85
+ };
86
+
74
87
  return (
75
- <Popover side={side} align={align} open={open} onOpenChange={onOpenChange}>
88
+ <Popover side={side} align={align} open={isOpen} onOpenChange={setOpen}>
76
89
  <PopoverTrigger>
77
90
  <PillButton onDismiss={showClear ? onClear : undefined} dismissTooltip={clearLabel}>
78
91
  <Text size="sm" weight="medium" color={active ? "zinc-900" : "zinc-700"} numberOfLines={1}>
@@ -82,7 +95,7 @@ export function FilterPill(props: FilterPillProps) {
82
95
  </PillButton>
83
96
  </PopoverTrigger>
84
97
  <PopoverContent style={styles.body} disableBodyScroll>
85
- {children}
98
+ {typeof children === "function" ? children({ close: () => setOpen(false) }) : children}
86
99
  {footer ? (
87
100
  <PopoverFooter>{footer}</PopoverFooter>
88
101
  ) : showClear ? (
package/src/icon.tsx CHANGED
@@ -51,6 +51,7 @@ import ChevronsDownUp from "lucide-react-native/dist/esm/icons/chevrons-down-up"
51
51
  import ChevronsUpDown from "lucide-react-native/dist/esm/icons/chevrons-up-down";
52
52
  import CircleAlert from "lucide-react-native/dist/esm/icons/circle-alert";
53
53
  import CircleCheck from "lucide-react-native/dist/esm/icons/circle-check";
54
+ import TriangleAlert from "lucide-react-native/dist/esm/icons/triangle-alert";
54
55
  import Clock from "lucide-react-native/dist/esm/icons/clock";
55
56
  import Code from "lucide-react-native/dist/esm/icons/code";
56
57
  import CodeXml from "lucide-react-native/dist/esm/icons/code-xml";
@@ -153,6 +154,7 @@ import RectangleEllipsis from "lucide-react-native/dist/esm/icons/rectangle-elli
153
154
  import Redo from "lucide-react-native/dist/esm/icons/redo";
154
155
  import RefreshCw from "lucide-react-native/dist/esm/icons/refresh-cw";
155
156
  import Repeat from "lucide-react-native/dist/esm/icons/repeat";
157
+ import RotateCcw from "lucide-react-native/dist/esm/icons/rotate-ccw";
156
158
  import RotateCw from "lucide-react-native/dist/esm/icons/rotate-cw";
157
159
  import Scan from "lucide-react-native/dist/esm/icons/scan";
158
160
  import Search from "lucide-react-native/dist/esm/icons/search";
@@ -241,6 +243,7 @@ const iconComponents = {
241
243
  "chevrons-up-down": ChevronsUpDown,
242
244
  "circle-alert": CircleAlert,
243
245
  "circle-check": CircleCheck,
246
+ "triangle-alert": TriangleAlert,
244
247
  clock: Clock,
245
248
  code: Code,
246
249
  "code-xml": CodeXml,
@@ -344,6 +347,7 @@ const iconComponents = {
344
347
  redo: Redo,
345
348
  "refresh-cw": RefreshCw,
346
349
  repeat: Repeat,
350
+ "rotate-ccw": RotateCcw,
347
351
  "rotate-cw": RotateCw,
348
352
  scan: Scan,
349
353
  search: Search,
@@ -8,15 +8,27 @@
8
8
  // relying on percentage/flex-basis resolution (which collapses inside an
9
9
  // auto-width parent).
10
10
  import { useState } from "react";
11
- import { View, Image, Pressable } from "react-native";
11
+ import { View, Pressable, type ViewStyle } from "react-native";
12
12
  import { Text } from "./text";
13
+ import { Icon } from "./icon";
13
14
  import { colors } from "./colors";
14
15
  import { ActivityIndicator } from "./activity_indicator";
15
16
  import { FileThumbnail, type DisplayFile } from "./file_thumbnail";
16
17
  import { FileGalleryModal } from "./file_gallery_modal";
18
+ import { RotatableImage } from "./rotatable_image";
19
+ import { useImageRotation } from "./use_image_rotation";
17
20
 
18
21
  const GAP = 12;
19
22
 
23
+ const rotateButtonStyle: ViewStyle = {
24
+ width: 32,
25
+ height: 32,
26
+ borderRadius: 16,
27
+ backgroundColor: "rgba(0, 0, 0, 0.45)",
28
+ alignItems: "center",
29
+ justifyContent: "center",
30
+ };
31
+
20
32
  export interface ImageGalleryProps {
21
33
  images: DisplayFile[];
22
34
  loading?: boolean;
@@ -34,6 +46,8 @@ export interface ImageGalleryProps {
34
46
  mainAspectRatio?: number;
35
47
  /** Main image's fraction of the width in "right" mode (0–1). Default 0.6. */
36
48
  mainFraction?: number;
49
+ /** Show 90° rotate controls on images — inline and in the zoom modal. Default true. */
50
+ rotatable?: boolean;
37
51
  }
38
52
 
39
53
  export function ImageGallery({
@@ -45,10 +59,13 @@ export function ImageGallery({
45
59
  thumbnailPosition = "bottom",
46
60
  mainAspectRatio = 4 / 3,
47
61
  mainFraction = 0.6,
62
+ rotatable = true,
48
63
  }: ImageGalleryProps) {
49
64
  const [selected, setSelected] = useState(0);
50
65
  const [zoomIdx, setZoomIdx] = useState<number | null>(null);
51
66
  const [containerWidth, setContainerWidth] = useState(0);
67
+ // Per-image rotation, shared with the zoom modal so a turn survives opening it.
68
+ const rotation = useImageRotation();
52
69
 
53
70
  if (loading) return <ActivityIndicator />;
54
71
  if (images.length === 0) return <Text size="sm" color="muted">{emptyText}</Text>;
@@ -59,23 +76,47 @@ export function ImageGallery({
59
76
  const width = maxWidth ? Math.min(containerWidth, maxWidth) : containerWidth;
60
77
 
61
78
  const mainImage = (
62
- <Pressable
63
- onPress={() => setZoomIdx(idx)}
64
- accessibilityRole="button"
65
- accessibilityLabel="Zoom image"
66
- style={{
67
- width: "100%",
68
- aspectRatio: mainAspectRatio,
69
- borderRadius: 8,
70
- borderWidth: 1,
71
- borderColor: colors.border,
72
- backgroundColor: colors.zinc["100"],
73
- overflow: "hidden",
74
- cursor: "pointer",
75
- }}
76
- >
77
- <Image source={{ uri: main.url }} resizeMode="contain" style={{ width: "100%", height: "100%" }} />
78
- </Pressable>
79
+ // The rotate controls sit OUTSIDE the zoom Pressable (an absolute sibling on
80
+ // top) so pressing them turns the image instead of opening the modal.
81
+ <View style={{ width: "100%" }}>
82
+ <Pressable
83
+ onPress={() => setZoomIdx(idx)}
84
+ accessibilityRole="button"
85
+ accessibilityLabel="Zoom image"
86
+ style={{
87
+ width: "100%",
88
+ aspectRatio: mainAspectRatio,
89
+ borderRadius: 8,
90
+ borderWidth: 1,
91
+ borderColor: colors.border,
92
+ backgroundColor: colors.zinc["100"],
93
+ overflow: "hidden",
94
+ cursor: "pointer",
95
+ }}
96
+ >
97
+ <RotatableImage uri={main.url} alt={main.filename} rotation={rotation.rotationFor(main.id)} />
98
+ </Pressable>
99
+ {rotatable ? (
100
+ <View style={{ position: "absolute", top: 8, right: 8, flexDirection: "row", gap: 6 }}>
101
+ <Pressable
102
+ onPress={() => rotation.rotate(main.id, -1)}
103
+ accessibilityRole="button"
104
+ accessibilityLabel="Rotate left"
105
+ style={rotateButtonStyle}
106
+ >
107
+ <Icon name="rotate-ccw" size={16} color={colors.white} />
108
+ </Pressable>
109
+ <Pressable
110
+ onPress={() => rotation.rotate(main.id, 1)}
111
+ accessibilityRole="button"
112
+ accessibilityLabel="Rotate right"
113
+ style={rotateButtonStyle}
114
+ >
115
+ <Icon name="rotate-cw" size={16} color={colors.white} />
116
+ </Pressable>
117
+ </View>
118
+ ) : null}
119
+ </View>
79
120
  );
80
121
 
81
122
  const thumbnails = images.length > 1 ? (
@@ -117,6 +158,8 @@ export function ImageGallery({
117
158
  activeIndex={zoomIdx}
118
159
  onIndexChange={setZoomIdx}
119
160
  captionHint={captionHint}
161
+ rotation={rotation}
162
+ rotatable={rotatable}
120
163
  />
121
164
  </View>
122
165
  );
@@ -0,0 +1,56 @@
1
+ import { useState } from "react";
2
+ import { View, Image, StyleSheet, type StyleProp, type ViewStyle } from "react-native";
3
+
4
+ export interface RotatableImageProps {
5
+ uri: string;
6
+ /** Accessible name for the image. */
7
+ alt?: string;
8
+ /** Degrees clockwise — normalized to 0/90/180/270. */
9
+ rotation?: number;
10
+ /** Fill behind the contained image. */
11
+ backgroundColor?: string;
12
+ style?: StyleProp<ViewStyle>;
13
+ }
14
+
15
+ /**
16
+ * An image that rotates in 90° steps and REFITS: at a quarter turn the image box
17
+ * is laid out in the container's SWAPPED dimensions, then rotated into place — so
18
+ * a rotated landscape photo fills the frame instead of shrinking into a corner.
19
+ * `resizeMode="contain"` preserves aspect ratio; the container measures itself
20
+ * via `onLayout`. Works on web (react-native-web) and native.
21
+ */
22
+ export function RotatableImage({ uri, alt, rotation = 0, backgroundColor, style }: RotatableImageProps) {
23
+ const [size, setSize] = useState({ width: 0, height: 0 });
24
+ const deg = (((rotation % 360) + 360) % 360);
25
+ const quarter = deg === 90 || deg === 270;
26
+ const boxWidth = quarter ? size.height : size.width;
27
+ const boxHeight = quarter ? size.width : size.height;
28
+
29
+ return (
30
+ <View
31
+ onLayout={(e) =>
32
+ setSize({ width: e.nativeEvent.layout.width, height: e.nativeEvent.layout.height })
33
+ }
34
+ style={[styles.container, backgroundColor ? { backgroundColor } : null, style]}
35
+ >
36
+ {size.width > 0 && size.height > 0 ? (
37
+ <Image
38
+ source={{ uri }}
39
+ resizeMode="contain"
40
+ accessibilityLabel={alt}
41
+ style={{ width: boxWidth, height: boxHeight, transform: [{ rotate: `${deg}deg` }] }}
42
+ />
43
+ ) : null}
44
+ </View>
45
+ );
46
+ }
47
+
48
+ const styles = StyleSheet.create({
49
+ container: {
50
+ width: "100%",
51
+ height: "100%",
52
+ alignItems: "center",
53
+ justifyContent: "center",
54
+ overflow: "hidden",
55
+ },
56
+ });
@@ -0,0 +1,30 @@
1
+ import { useCallback, useMemo, useState } from "react";
2
+
3
+ /**
4
+ * Per-file view rotation for image viewers — degrees (0/90/180/270) keyed by
5
+ * file id, held in memory. Shared between an inline gallery and its fullscreen
6
+ * modal so a rotation survives prev/next navigation and open/close while the
7
+ * gallery is mounted. View-only: it is a render transform, never a mutation of
8
+ * the stored file.
9
+ */
10
+ export interface ImageRotation {
11
+ rotations: Record<string, number>;
12
+ rotationFor: (id: string) => number;
13
+ /** Advance a quarter-turn; `step` 1 = clockwise, -1 = counter-clockwise. */
14
+ rotate: (id: string, step?: 1 | -1) => void;
15
+ }
16
+
17
+ export function useImageRotation(): ImageRotation {
18
+ const [rotations, setRotations] = useState<Record<string, number>>({});
19
+
20
+ const rotate = useCallback((id: string, step: 1 | -1 = 1) => {
21
+ setRotations((prev) => {
22
+ const next = ((((prev[id] ?? 0) + step * 90) % 360) + 360) % 360;
23
+ return { ...prev, [id]: next };
24
+ });
25
+ }, []);
26
+
27
+ const rotationFor = useCallback((id: string) => rotations[id] ?? 0, [rotations]);
28
+
29
+ return useMemo(() => ({ rotations, rotationFor, rotate }), [rotations, rotationFor, rotate]);
30
+ }