@metacells/mcellui-mcp-server 0.1.0 → 0.1.2
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/dist/index.js +70 -7
- package/package.json +7 -5
- package/registry/registry.json +717 -0
- package/registry/ui/accordion.tsx +416 -0
- package/registry/ui/action-sheet.tsx +396 -0
- package/registry/ui/alert-dialog.tsx +355 -0
- package/registry/ui/avatar-stack.tsx +278 -0
- package/registry/ui/avatar.tsx +116 -0
- package/registry/ui/badge.tsx +125 -0
- package/registry/ui/button.tsx +240 -0
- package/registry/ui/card.tsx +675 -0
- package/registry/ui/carousel.tsx +431 -0
- package/registry/ui/checkbox.tsx +252 -0
- package/registry/ui/chip.tsx +271 -0
- package/registry/ui/column.tsx +133 -0
- package/registry/ui/datetime-picker.tsx +578 -0
- package/registry/ui/dialog.tsx +292 -0
- package/registry/ui/fab.tsx +225 -0
- package/registry/ui/form.tsx +323 -0
- package/registry/ui/horizontal-list.tsx +200 -0
- package/registry/ui/icon-button.tsx +244 -0
- package/registry/ui/image-gallery.tsx +455 -0
- package/registry/ui/image.tsx +283 -0
- package/registry/ui/input.tsx +242 -0
- package/registry/ui/label.tsx +99 -0
- package/registry/ui/list.tsx +519 -0
- package/registry/ui/progress.tsx +168 -0
- package/registry/ui/pull-to-refresh.tsx +231 -0
- package/registry/ui/radio-group.tsx +294 -0
- package/registry/ui/rating.tsx +311 -0
- package/registry/ui/row.tsx +136 -0
- package/registry/ui/screen.tsx +153 -0
- package/registry/ui/search-input.tsx +281 -0
- package/registry/ui/section-header.tsx +258 -0
- package/registry/ui/segmented-control.tsx +229 -0
- package/registry/ui/select.tsx +311 -0
- package/registry/ui/separator.tsx +74 -0
- package/registry/ui/sheet.tsx +362 -0
- package/registry/ui/skeleton.tsx +156 -0
- package/registry/ui/slider.tsx +307 -0
- package/registry/ui/spinner.tsx +100 -0
- package/registry/ui/stepper.tsx +314 -0
- package/registry/ui/stories.tsx +463 -0
- package/registry/ui/swipeable-row.tsx +362 -0
- package/registry/ui/switch.tsx +246 -0
- package/registry/ui/tabs.tsx +348 -0
- package/registry/ui/textarea.tsx +265 -0
- package/registry/ui/toast.tsx +316 -0
- package/registry/ui/tooltip.tsx +369 -0
|
@@ -0,0 +1,244 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* IconButton
|
|
3
|
+
*
|
|
4
|
+
* A pressable button that displays only an icon.
|
|
5
|
+
* Square or circular shape with multiple variants and sizes.
|
|
6
|
+
*
|
|
7
|
+
* @example
|
|
8
|
+
* ```tsx
|
|
9
|
+
* // Basic usage
|
|
10
|
+
* <IconButton icon={<PlusIcon />} onPress={handleAdd} />
|
|
11
|
+
*
|
|
12
|
+
* // Variants
|
|
13
|
+
* <IconButton icon={<HeartIcon />} variant="ghost" />
|
|
14
|
+
* <IconButton icon={<TrashIcon />} variant="destructive" />
|
|
15
|
+
*
|
|
16
|
+
* // Sizes
|
|
17
|
+
* <IconButton icon={<MenuIcon />} size="sm" />
|
|
18
|
+
* <IconButton icon={<MenuIcon />} size="lg" rounded />
|
|
19
|
+
* ```
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
import React, { useCallback, forwardRef } from 'react';
|
|
23
|
+
import {
|
|
24
|
+
Pressable,
|
|
25
|
+
StyleSheet,
|
|
26
|
+
ViewStyle,
|
|
27
|
+
View,
|
|
28
|
+
PressableProps,
|
|
29
|
+
ActivityIndicator,
|
|
30
|
+
} from 'react-native';
|
|
31
|
+
import Animated, {
|
|
32
|
+
useSharedValue,
|
|
33
|
+
useAnimatedStyle,
|
|
34
|
+
withSpring,
|
|
35
|
+
} from 'react-native-reanimated';
|
|
36
|
+
import { useTheme } from '@nativeui/core';
|
|
37
|
+
import { haptic } from '@nativeui/core';
|
|
38
|
+
|
|
39
|
+
const AnimatedPressable = Animated.createAnimatedComponent(Pressable);
|
|
40
|
+
|
|
41
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
42
|
+
// Types
|
|
43
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
44
|
+
|
|
45
|
+
export type IconButtonVariant = 'default' | 'secondary' | 'outline' | 'ghost' | 'destructive';
|
|
46
|
+
export type IconButtonSize = 'sm' | 'md' | 'lg' | 'xl';
|
|
47
|
+
|
|
48
|
+
export interface IconButtonProps extends Omit<PressableProps, 'style'> {
|
|
49
|
+
/** Icon element to display */
|
|
50
|
+
icon: React.ReactNode;
|
|
51
|
+
/** Visual style variant */
|
|
52
|
+
variant?: IconButtonVariant;
|
|
53
|
+
/** Size preset */
|
|
54
|
+
size?: IconButtonSize;
|
|
55
|
+
/** Circular shape instead of rounded square */
|
|
56
|
+
rounded?: boolean;
|
|
57
|
+
/** Show loading spinner instead of icon */
|
|
58
|
+
loading?: boolean;
|
|
59
|
+
/** Disabled state */
|
|
60
|
+
disabled?: boolean;
|
|
61
|
+
/** Container style override */
|
|
62
|
+
style?: ViewStyle;
|
|
63
|
+
/** Accessibility label (required for icon-only buttons) */
|
|
64
|
+
accessibilityLabel: string;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
68
|
+
// Size Configuration
|
|
69
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
70
|
+
|
|
71
|
+
const sizeConfig = {
|
|
72
|
+
sm: { size: 32, iconSize: 16 },
|
|
73
|
+
md: { size: 40, iconSize: 20 },
|
|
74
|
+
lg: { size: 48, iconSize: 24 },
|
|
75
|
+
xl: { size: 56, iconSize: 28 },
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
79
|
+
// Component
|
|
80
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
81
|
+
|
|
82
|
+
export const IconButton = forwardRef(function IconButton(
|
|
83
|
+
{
|
|
84
|
+
icon,
|
|
85
|
+
variant = 'default',
|
|
86
|
+
size = 'md',
|
|
87
|
+
rounded = false,
|
|
88
|
+
loading = false,
|
|
89
|
+
disabled,
|
|
90
|
+
style,
|
|
91
|
+
accessibilityLabel,
|
|
92
|
+
onPressIn,
|
|
93
|
+
onPressOut,
|
|
94
|
+
onPress,
|
|
95
|
+
...props
|
|
96
|
+
}: IconButtonProps,
|
|
97
|
+
ref: React.ForwardedRef<View>
|
|
98
|
+
) {
|
|
99
|
+
const { colors, radius, springs, platformShadow } = useTheme();
|
|
100
|
+
const config = sizeConfig[size];
|
|
101
|
+
const isDisabled = disabled || loading;
|
|
102
|
+
|
|
103
|
+
const scale = useSharedValue(1);
|
|
104
|
+
|
|
105
|
+
const handlePressIn = useCallback(
|
|
106
|
+
(e: any) => {
|
|
107
|
+
scale.value = withSpring(0.9, springs.snappy);
|
|
108
|
+
onPressIn?.(e);
|
|
109
|
+
},
|
|
110
|
+
[onPressIn, springs.snappy]
|
|
111
|
+
);
|
|
112
|
+
|
|
113
|
+
const handlePressOut = useCallback(
|
|
114
|
+
(e: any) => {
|
|
115
|
+
scale.value = withSpring(1, springs.snappy);
|
|
116
|
+
onPressOut?.(e);
|
|
117
|
+
},
|
|
118
|
+
[onPressOut, springs.snappy]
|
|
119
|
+
);
|
|
120
|
+
|
|
121
|
+
const handlePress = useCallback(
|
|
122
|
+
(e: any) => {
|
|
123
|
+
haptic('light');
|
|
124
|
+
onPress?.(e);
|
|
125
|
+
},
|
|
126
|
+
[onPress]
|
|
127
|
+
);
|
|
128
|
+
|
|
129
|
+
const animatedStyle = useAnimatedStyle(() => ({
|
|
130
|
+
transform: [{ scale: scale.value }],
|
|
131
|
+
}));
|
|
132
|
+
|
|
133
|
+
// Get variant-specific styles
|
|
134
|
+
const variantStyles = getVariantStyles(variant, colors);
|
|
135
|
+
|
|
136
|
+
// Determine border radius
|
|
137
|
+
const borderRadiusValue = rounded ? config.size / 2 : radius.md;
|
|
138
|
+
|
|
139
|
+
// Add shadow for solid buttons
|
|
140
|
+
const shadowStyle =
|
|
141
|
+
variant !== 'ghost' && variant !== 'outline' && !isDisabled
|
|
142
|
+
? platformShadow('sm')
|
|
143
|
+
: {};
|
|
144
|
+
|
|
145
|
+
return (
|
|
146
|
+
<AnimatedPressable
|
|
147
|
+
ref={ref}
|
|
148
|
+
style={[
|
|
149
|
+
styles.base,
|
|
150
|
+
{
|
|
151
|
+
width: config.size,
|
|
152
|
+
height: config.size,
|
|
153
|
+
borderRadius: borderRadiusValue,
|
|
154
|
+
},
|
|
155
|
+
variantStyles.container,
|
|
156
|
+
shadowStyle,
|
|
157
|
+
isDisabled && styles.disabled,
|
|
158
|
+
animatedStyle,
|
|
159
|
+
style,
|
|
160
|
+
]}
|
|
161
|
+
disabled={isDisabled}
|
|
162
|
+
onPressIn={handlePressIn}
|
|
163
|
+
onPressOut={handlePressOut}
|
|
164
|
+
onPress={handlePress}
|
|
165
|
+
accessibilityRole="button"
|
|
166
|
+
accessibilityLabel={accessibilityLabel}
|
|
167
|
+
accessibilityState={{ disabled: isDisabled }}
|
|
168
|
+
{...props}
|
|
169
|
+
>
|
|
170
|
+
{loading ? (
|
|
171
|
+
<ActivityIndicator
|
|
172
|
+
size={size === 'sm' ? 'small' : 'small'}
|
|
173
|
+
color={variantStyles.iconColor}
|
|
174
|
+
/>
|
|
175
|
+
) : (
|
|
176
|
+
React.cloneElement(icon as React.ReactElement<{ width?: number; height?: number; color?: string }>, {
|
|
177
|
+
width: config.iconSize,
|
|
178
|
+
height: config.iconSize,
|
|
179
|
+
color: variantStyles.iconColor,
|
|
180
|
+
})
|
|
181
|
+
)}
|
|
182
|
+
</AnimatedPressable>
|
|
183
|
+
);
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
187
|
+
// Helpers
|
|
188
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
189
|
+
|
|
190
|
+
function getVariantStyles(
|
|
191
|
+
variant: IconButtonVariant,
|
|
192
|
+
colors: ReturnType<typeof useTheme>['colors']
|
|
193
|
+
) {
|
|
194
|
+
switch (variant) {
|
|
195
|
+
case 'default':
|
|
196
|
+
return {
|
|
197
|
+
container: { backgroundColor: colors.primary } as ViewStyle,
|
|
198
|
+
iconColor: colors.primaryForeground,
|
|
199
|
+
};
|
|
200
|
+
case 'secondary':
|
|
201
|
+
return {
|
|
202
|
+
container: { backgroundColor: colors.secondary } as ViewStyle,
|
|
203
|
+
iconColor: colors.secondaryForeground,
|
|
204
|
+
};
|
|
205
|
+
case 'outline':
|
|
206
|
+
return {
|
|
207
|
+
container: {
|
|
208
|
+
backgroundColor: 'transparent',
|
|
209
|
+
borderWidth: 1,
|
|
210
|
+
borderColor: colors.border,
|
|
211
|
+
} as ViewStyle,
|
|
212
|
+
iconColor: colors.foreground,
|
|
213
|
+
};
|
|
214
|
+
case 'ghost':
|
|
215
|
+
return {
|
|
216
|
+
container: { backgroundColor: 'transparent' } as ViewStyle,
|
|
217
|
+
iconColor: colors.foreground,
|
|
218
|
+
};
|
|
219
|
+
case 'destructive':
|
|
220
|
+
return {
|
|
221
|
+
container: { backgroundColor: colors.destructive } as ViewStyle,
|
|
222
|
+
iconColor: colors.destructiveForeground,
|
|
223
|
+
};
|
|
224
|
+
default:
|
|
225
|
+
return {
|
|
226
|
+
container: { backgroundColor: colors.primary } as ViewStyle,
|
|
227
|
+
iconColor: colors.primaryForeground,
|
|
228
|
+
};
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
233
|
+
// Styles
|
|
234
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
235
|
+
|
|
236
|
+
const styles = StyleSheet.create({
|
|
237
|
+
base: {
|
|
238
|
+
alignItems: 'center',
|
|
239
|
+
justifyContent: 'center',
|
|
240
|
+
},
|
|
241
|
+
disabled: {
|
|
242
|
+
opacity: 0.5,
|
|
243
|
+
},
|
|
244
|
+
});
|
|
@@ -0,0 +1,455 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ImageGallery
|
|
3
|
+
*
|
|
4
|
+
* Grid of images with fullscreen viewer on tap.
|
|
5
|
+
* Supports pinch-to-zoom and swipe navigation in fullscreen mode.
|
|
6
|
+
*
|
|
7
|
+
* @example
|
|
8
|
+
* ```tsx
|
|
9
|
+
* // Basic gallery
|
|
10
|
+
* <ImageGallery
|
|
11
|
+
* images={[
|
|
12
|
+
* { uri: 'https://...' },
|
|
13
|
+
* { uri: 'https://...' },
|
|
14
|
+
* { uri: 'https://...' },
|
|
15
|
+
* ]}
|
|
16
|
+
* />
|
|
17
|
+
*
|
|
18
|
+
* // Custom columns
|
|
19
|
+
* <ImageGallery
|
|
20
|
+
* images={photos}
|
|
21
|
+
* columns={2}
|
|
22
|
+
* spacing={8}
|
|
23
|
+
* />
|
|
24
|
+
*
|
|
25
|
+
* // With aspect ratio
|
|
26
|
+
* <ImageGallery
|
|
27
|
+
* images={photos}
|
|
28
|
+
* aspectRatio={1} // Square
|
|
29
|
+
* />
|
|
30
|
+
*
|
|
31
|
+
* // With onImagePress callback
|
|
32
|
+
* <ImageGallery
|
|
33
|
+
* images={photos}
|
|
34
|
+
* onImagePress={(index) => console.log('Pressed image:', index)}
|
|
35
|
+
* />
|
|
36
|
+
* ```
|
|
37
|
+
*/
|
|
38
|
+
|
|
39
|
+
import React, { useState, useCallback } from 'react';
|
|
40
|
+
import {
|
|
41
|
+
View,
|
|
42
|
+
Image,
|
|
43
|
+
Pressable,
|
|
44
|
+
StyleSheet,
|
|
45
|
+
Modal,
|
|
46
|
+
Dimensions,
|
|
47
|
+
FlatList,
|
|
48
|
+
ImageSourcePropType,
|
|
49
|
+
StatusBar,
|
|
50
|
+
Platform,
|
|
51
|
+
} from 'react-native';
|
|
52
|
+
import Animated, {
|
|
53
|
+
useAnimatedStyle,
|
|
54
|
+
useSharedValue,
|
|
55
|
+
withTiming,
|
|
56
|
+
withSpring,
|
|
57
|
+
runOnJS,
|
|
58
|
+
Easing,
|
|
59
|
+
} from 'react-native-reanimated';
|
|
60
|
+
import {
|
|
61
|
+
Gesture,
|
|
62
|
+
GestureDetector,
|
|
63
|
+
GestureHandlerRootView,
|
|
64
|
+
} from 'react-native-gesture-handler';
|
|
65
|
+
import { useTheme, haptic } from '@nativeui/core';
|
|
66
|
+
|
|
67
|
+
const { width: SCREEN_WIDTH, height: SCREEN_HEIGHT } = Dimensions.get('window');
|
|
68
|
+
|
|
69
|
+
const AnimatedImage = Animated.createAnimatedComponent(Image);
|
|
70
|
+
|
|
71
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
72
|
+
// Types
|
|
73
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
74
|
+
|
|
75
|
+
export interface GalleryImage {
|
|
76
|
+
/** Image URI */
|
|
77
|
+
uri: string;
|
|
78
|
+
/** Optional thumbnail URI (for grid) */
|
|
79
|
+
thumbnail?: string;
|
|
80
|
+
/** Optional alt text */
|
|
81
|
+
alt?: string;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export interface ImageGalleryProps {
|
|
85
|
+
/** Array of images */
|
|
86
|
+
images: GalleryImage[];
|
|
87
|
+
/** Number of columns in grid */
|
|
88
|
+
columns?: number;
|
|
89
|
+
/** Spacing between images */
|
|
90
|
+
spacing?: number;
|
|
91
|
+
/** Aspect ratio of grid images (width/height) */
|
|
92
|
+
aspectRatio?: number;
|
|
93
|
+
/** Border radius for grid images */
|
|
94
|
+
borderRadius?: number;
|
|
95
|
+
/** Called when an image is pressed */
|
|
96
|
+
onImagePress?: (index: number) => void;
|
|
97
|
+
/** Disable fullscreen viewer */
|
|
98
|
+
disableViewer?: boolean;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
102
|
+
// FullscreenViewer Component
|
|
103
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
104
|
+
|
|
105
|
+
interface FullscreenViewerProps {
|
|
106
|
+
images: GalleryImage[];
|
|
107
|
+
initialIndex: number;
|
|
108
|
+
visible: boolean;
|
|
109
|
+
onClose: () => void;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function FullscreenViewer({
|
|
113
|
+
images,
|
|
114
|
+
initialIndex,
|
|
115
|
+
visible,
|
|
116
|
+
onClose,
|
|
117
|
+
}: FullscreenViewerProps) {
|
|
118
|
+
const { colors } = useTheme();
|
|
119
|
+
const [currentIndex, setCurrentIndex] = useState(initialIndex);
|
|
120
|
+
|
|
121
|
+
const opacity = useSharedValue(0);
|
|
122
|
+
const scale = useSharedValue(1);
|
|
123
|
+
const translateX = useSharedValue(0);
|
|
124
|
+
const translateY = useSharedValue(0);
|
|
125
|
+
|
|
126
|
+
React.useEffect(() => {
|
|
127
|
+
if (visible) {
|
|
128
|
+
opacity.value = withTiming(1, { duration: 200 });
|
|
129
|
+
setCurrentIndex(initialIndex);
|
|
130
|
+
} else {
|
|
131
|
+
opacity.value = withTiming(0, { duration: 150 });
|
|
132
|
+
}
|
|
133
|
+
}, [visible, initialIndex, opacity]);
|
|
134
|
+
|
|
135
|
+
const close = useCallback(() => {
|
|
136
|
+
opacity.value = withTiming(0, { duration: 150 });
|
|
137
|
+
setTimeout(onClose, 150);
|
|
138
|
+
}, [opacity, onClose]);
|
|
139
|
+
|
|
140
|
+
// Pinch to zoom gesture
|
|
141
|
+
const pinchGesture = Gesture.Pinch()
|
|
142
|
+
.onUpdate((event) => {
|
|
143
|
+
scale.value = Math.max(1, Math.min(event.scale, 3));
|
|
144
|
+
})
|
|
145
|
+
.onEnd(() => {
|
|
146
|
+
if (scale.value < 1.1) {
|
|
147
|
+
scale.value = withSpring(1);
|
|
148
|
+
translateX.value = withSpring(0);
|
|
149
|
+
translateY.value = withSpring(0);
|
|
150
|
+
}
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
// Pan gesture (when zoomed)
|
|
154
|
+
const panGesture = Gesture.Pan()
|
|
155
|
+
.onUpdate((event) => {
|
|
156
|
+
if (scale.value > 1) {
|
|
157
|
+
translateX.value = event.translationX;
|
|
158
|
+
translateY.value = event.translationY;
|
|
159
|
+
} else if (Math.abs(event.translationY) > 50) {
|
|
160
|
+
// Swipe down to close
|
|
161
|
+
opacity.value = withTiming(0, { duration: 150 });
|
|
162
|
+
runOnJS(close)();
|
|
163
|
+
}
|
|
164
|
+
})
|
|
165
|
+
.onEnd(() => {
|
|
166
|
+
if (scale.value <= 1) {
|
|
167
|
+
translateX.value = withSpring(0);
|
|
168
|
+
translateY.value = withSpring(0);
|
|
169
|
+
}
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
// Double tap to zoom
|
|
173
|
+
const doubleTapGesture = Gesture.Tap()
|
|
174
|
+
.numberOfTaps(2)
|
|
175
|
+
.onEnd(() => {
|
|
176
|
+
if (scale.value > 1) {
|
|
177
|
+
scale.value = withSpring(1);
|
|
178
|
+
translateX.value = withSpring(0);
|
|
179
|
+
translateY.value = withSpring(0);
|
|
180
|
+
} else {
|
|
181
|
+
scale.value = withSpring(2);
|
|
182
|
+
}
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
const composedGesture = Gesture.Simultaneous(
|
|
186
|
+
pinchGesture,
|
|
187
|
+
Gesture.Race(doubleTapGesture, panGesture)
|
|
188
|
+
);
|
|
189
|
+
|
|
190
|
+
const backdropStyle = useAnimatedStyle(() => ({
|
|
191
|
+
opacity: opacity.value,
|
|
192
|
+
}));
|
|
193
|
+
|
|
194
|
+
const imageStyle = useAnimatedStyle(() => ({
|
|
195
|
+
transform: [
|
|
196
|
+
{ scale: scale.value },
|
|
197
|
+
{ translateX: translateX.value },
|
|
198
|
+
{ translateY: translateY.value },
|
|
199
|
+
],
|
|
200
|
+
}));
|
|
201
|
+
|
|
202
|
+
const handleScroll = useCallback(
|
|
203
|
+
(event: { nativeEvent: { contentOffset: { x: number } } }) => {
|
|
204
|
+
const index = Math.round(event.nativeEvent.contentOffset.x / SCREEN_WIDTH);
|
|
205
|
+
if (index !== currentIndex && index >= 0 && index < images.length) {
|
|
206
|
+
setCurrentIndex(index);
|
|
207
|
+
// Reset zoom when changing images
|
|
208
|
+
scale.value = withTiming(1, { duration: 100 });
|
|
209
|
+
translateX.value = withTiming(0, { duration: 100 });
|
|
210
|
+
translateY.value = withTiming(0, { duration: 100 });
|
|
211
|
+
}
|
|
212
|
+
},
|
|
213
|
+
[currentIndex, images.length, scale, translateX, translateY]
|
|
214
|
+
);
|
|
215
|
+
|
|
216
|
+
const renderImage = useCallback(
|
|
217
|
+
({ item }: { item: GalleryImage }) => (
|
|
218
|
+
<GestureDetector gesture={composedGesture}>
|
|
219
|
+
<View style={styles.fullscreenImageContainer}>
|
|
220
|
+
<AnimatedImage
|
|
221
|
+
source={{ uri: item.uri }}
|
|
222
|
+
style={[styles.fullscreenImage, imageStyle]}
|
|
223
|
+
resizeMode="contain"
|
|
224
|
+
accessibilityLabel={item.alt}
|
|
225
|
+
/>
|
|
226
|
+
</View>
|
|
227
|
+
</GestureDetector>
|
|
228
|
+
),
|
|
229
|
+
[composedGesture, imageStyle]
|
|
230
|
+
);
|
|
231
|
+
|
|
232
|
+
if (!visible) return null;
|
|
233
|
+
|
|
234
|
+
return (
|
|
235
|
+
<Modal visible={visible} transparent animationType="none" statusBarTranslucent>
|
|
236
|
+
<GestureHandlerRootView style={styles.fullscreenContainer}>
|
|
237
|
+
<Animated.View style={[styles.fullscreenBackdrop, backdropStyle]}>
|
|
238
|
+
{/* Close button */}
|
|
239
|
+
<Pressable
|
|
240
|
+
style={styles.closeButton}
|
|
241
|
+
onPress={() => {
|
|
242
|
+
haptic('light');
|
|
243
|
+
close();
|
|
244
|
+
}}
|
|
245
|
+
accessibilityRole="button"
|
|
246
|
+
accessibilityLabel="Close"
|
|
247
|
+
>
|
|
248
|
+
<View style={[styles.closeIcon, { backgroundColor: colors.background }]}>
|
|
249
|
+
<View style={[styles.closeLine, styles.closeLine1, { backgroundColor: colors.foreground }]} />
|
|
250
|
+
<View style={[styles.closeLine, styles.closeLine2, { backgroundColor: colors.foreground }]} />
|
|
251
|
+
</View>
|
|
252
|
+
</Pressable>
|
|
253
|
+
|
|
254
|
+
{/* Images */}
|
|
255
|
+
<FlatList
|
|
256
|
+
data={images}
|
|
257
|
+
renderItem={renderImage}
|
|
258
|
+
keyExtractor={(_, index) => index.toString()}
|
|
259
|
+
horizontal
|
|
260
|
+
pagingEnabled
|
|
261
|
+
showsHorizontalScrollIndicator={false}
|
|
262
|
+
initialScrollIndex={initialIndex}
|
|
263
|
+
getItemLayout={(_, index) => ({
|
|
264
|
+
length: SCREEN_WIDTH,
|
|
265
|
+
offset: SCREEN_WIDTH * index,
|
|
266
|
+
index,
|
|
267
|
+
})}
|
|
268
|
+
onMomentumScrollEnd={handleScroll}
|
|
269
|
+
/>
|
|
270
|
+
|
|
271
|
+
{/* Page indicator */}
|
|
272
|
+
{images.length > 1 && (
|
|
273
|
+
<View style={styles.pageIndicator}>
|
|
274
|
+
{images.map((_, index) => (
|
|
275
|
+
<View
|
|
276
|
+
key={index}
|
|
277
|
+
style={[
|
|
278
|
+
styles.pageDot,
|
|
279
|
+
{
|
|
280
|
+
backgroundColor:
|
|
281
|
+
index === currentIndex
|
|
282
|
+
? colors.background
|
|
283
|
+
: 'rgba(255,255,255,0.4)',
|
|
284
|
+
},
|
|
285
|
+
]}
|
|
286
|
+
/>
|
|
287
|
+
))}
|
|
288
|
+
</View>
|
|
289
|
+
)}
|
|
290
|
+
</Animated.View>
|
|
291
|
+
</GestureHandlerRootView>
|
|
292
|
+
</Modal>
|
|
293
|
+
);
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
297
|
+
// ImageGallery Component
|
|
298
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
299
|
+
|
|
300
|
+
export function ImageGallery({
|
|
301
|
+
images,
|
|
302
|
+
columns = 3,
|
|
303
|
+
spacing = 2,
|
|
304
|
+
aspectRatio = 1,
|
|
305
|
+
borderRadius = 0,
|
|
306
|
+
onImagePress,
|
|
307
|
+
disableViewer = false,
|
|
308
|
+
}: ImageGalleryProps) {
|
|
309
|
+
const { colors, radius } = useTheme();
|
|
310
|
+
const [viewerVisible, setViewerVisible] = useState(false);
|
|
311
|
+
const [selectedIndex, setSelectedIndex] = useState(0);
|
|
312
|
+
|
|
313
|
+
const imageSize = (SCREEN_WIDTH - spacing * (columns + 1)) / columns;
|
|
314
|
+
|
|
315
|
+
const handleImagePress = useCallback(
|
|
316
|
+
(index: number) => {
|
|
317
|
+
haptic('light');
|
|
318
|
+
onImagePress?.(index);
|
|
319
|
+
|
|
320
|
+
if (!disableViewer) {
|
|
321
|
+
setSelectedIndex(index);
|
|
322
|
+
setViewerVisible(true);
|
|
323
|
+
}
|
|
324
|
+
},
|
|
325
|
+
[onImagePress, disableViewer]
|
|
326
|
+
);
|
|
327
|
+
|
|
328
|
+
const handleCloseViewer = useCallback(() => {
|
|
329
|
+
setViewerVisible(false);
|
|
330
|
+
}, []);
|
|
331
|
+
|
|
332
|
+
const renderItem = useCallback(
|
|
333
|
+
({ item, index }: { item: GalleryImage; index: number }) => (
|
|
334
|
+
<Pressable
|
|
335
|
+
onPress={() => handleImagePress(index)}
|
|
336
|
+
style={({ pressed }) => [
|
|
337
|
+
styles.gridItem,
|
|
338
|
+
{
|
|
339
|
+
width: imageSize,
|
|
340
|
+
height: imageSize / aspectRatio,
|
|
341
|
+
marginLeft: spacing,
|
|
342
|
+
marginTop: spacing,
|
|
343
|
+
borderRadius: borderRadius || radius.sm,
|
|
344
|
+
opacity: pressed ? 0.8 : 1,
|
|
345
|
+
},
|
|
346
|
+
]}
|
|
347
|
+
accessibilityRole="button"
|
|
348
|
+
accessibilityLabel={item.alt || `Image ${index + 1}`}
|
|
349
|
+
>
|
|
350
|
+
<Image
|
|
351
|
+
source={{ uri: item.thumbnail || item.uri }}
|
|
352
|
+
style={[
|
|
353
|
+
styles.gridImage,
|
|
354
|
+
{ borderRadius: borderRadius || radius.sm },
|
|
355
|
+
]}
|
|
356
|
+
/>
|
|
357
|
+
</Pressable>
|
|
358
|
+
),
|
|
359
|
+
[handleImagePress, imageSize, aspectRatio, spacing, borderRadius, radius.sm]
|
|
360
|
+
);
|
|
361
|
+
|
|
362
|
+
return (
|
|
363
|
+
<View style={styles.container}>
|
|
364
|
+
<FlatList
|
|
365
|
+
data={images}
|
|
366
|
+
renderItem={renderItem}
|
|
367
|
+
keyExtractor={(_, index) => index.toString()}
|
|
368
|
+
numColumns={columns}
|
|
369
|
+
scrollEnabled={false}
|
|
370
|
+
style={styles.grid}
|
|
371
|
+
contentContainerStyle={{ paddingBottom: spacing }}
|
|
372
|
+
/>
|
|
373
|
+
|
|
374
|
+
<FullscreenViewer
|
|
375
|
+
images={images}
|
|
376
|
+
initialIndex={selectedIndex}
|
|
377
|
+
visible={viewerVisible}
|
|
378
|
+
onClose={handleCloseViewer}
|
|
379
|
+
/>
|
|
380
|
+
</View>
|
|
381
|
+
);
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
385
|
+
// Styles
|
|
386
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
387
|
+
|
|
388
|
+
const styles = StyleSheet.create({
|
|
389
|
+
container: {},
|
|
390
|
+
grid: {},
|
|
391
|
+
gridItem: {
|
|
392
|
+
overflow: 'hidden',
|
|
393
|
+
},
|
|
394
|
+
gridImage: {
|
|
395
|
+
width: '100%',
|
|
396
|
+
height: '100%',
|
|
397
|
+
resizeMode: 'cover',
|
|
398
|
+
},
|
|
399
|
+
fullscreenContainer: {
|
|
400
|
+
flex: 1,
|
|
401
|
+
},
|
|
402
|
+
fullscreenBackdrop: {
|
|
403
|
+
flex: 1,
|
|
404
|
+
backgroundColor: 'rgba(0, 0, 0, 0.95)',
|
|
405
|
+
},
|
|
406
|
+
fullscreenImageContainer: {
|
|
407
|
+
width: SCREEN_WIDTH,
|
|
408
|
+
height: SCREEN_HEIGHT,
|
|
409
|
+
justifyContent: 'center',
|
|
410
|
+
alignItems: 'center',
|
|
411
|
+
},
|
|
412
|
+
fullscreenImage: {
|
|
413
|
+
width: SCREEN_WIDTH,
|
|
414
|
+
height: SCREEN_HEIGHT * 0.8,
|
|
415
|
+
},
|
|
416
|
+
closeButton: {
|
|
417
|
+
position: 'absolute',
|
|
418
|
+
top: Platform.OS === 'ios' ? 50 : 20,
|
|
419
|
+
right: 20,
|
|
420
|
+
zIndex: 100,
|
|
421
|
+
},
|
|
422
|
+
closeIcon: {
|
|
423
|
+
width: 36,
|
|
424
|
+
height: 36,
|
|
425
|
+
borderRadius: 18,
|
|
426
|
+
alignItems: 'center',
|
|
427
|
+
justifyContent: 'center',
|
|
428
|
+
},
|
|
429
|
+
closeLine: {
|
|
430
|
+
position: 'absolute',
|
|
431
|
+
width: 18,
|
|
432
|
+
height: 2,
|
|
433
|
+
borderRadius: 1,
|
|
434
|
+
},
|
|
435
|
+
closeLine1: {
|
|
436
|
+
transform: [{ rotate: '45deg' }],
|
|
437
|
+
},
|
|
438
|
+
closeLine2: {
|
|
439
|
+
transform: [{ rotate: '-45deg' }],
|
|
440
|
+
},
|
|
441
|
+
pageIndicator: {
|
|
442
|
+
position: 'absolute',
|
|
443
|
+
bottom: 50,
|
|
444
|
+
left: 0,
|
|
445
|
+
right: 0,
|
|
446
|
+
flexDirection: 'row',
|
|
447
|
+
justifyContent: 'center',
|
|
448
|
+
gap: 6,
|
|
449
|
+
},
|
|
450
|
+
pageDot: {
|
|
451
|
+
width: 6,
|
|
452
|
+
height: 6,
|
|
453
|
+
borderRadius: 3,
|
|
454
|
+
},
|
|
455
|
+
});
|