Initial commit: expo-popcore project
This commit is contained in:
138
components/bestai/category-tabs.tsx
Normal file
138
components/bestai/category-tabs.tsx
Normal file
@@ -0,0 +1,138 @@
|
||||
import { memo, useCallback, useEffect, useRef } from 'react';
|
||||
import { Pressable, ScrollView, StyleSheet, Text, View, useWindowDimensions } from 'react-native';
|
||||
|
||||
type Category = {
|
||||
id: string;
|
||||
label: string;
|
||||
};
|
||||
|
||||
type CategoryTabsProps = {
|
||||
categories: Category[];
|
||||
activeId: string;
|
||||
onChange: (id: string) => void;
|
||||
};
|
||||
|
||||
export function CategoryTabs({ categories, activeId, onChange }: CategoryTabsProps) {
|
||||
const scrollViewRef = useRef<ScrollView>(null);
|
||||
const tabPositionsRef = useRef<Map<string, { x: number; width: number }>>(new Map());
|
||||
const containerWidthRef = useRef<number>(0);
|
||||
const { width: screenWidth } = useWindowDimensions();
|
||||
|
||||
const scrollToActiveTab = useCallback(() => {
|
||||
if (!activeId) {
|
||||
return;
|
||||
}
|
||||
|
||||
const position = tabPositionsRef.current.get(activeId);
|
||||
if (!position || !scrollViewRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
const centerOffset = screenWidth / 2;
|
||||
const tabCenter = position.x + position.width / 2;
|
||||
const scrollX = tabCenter - centerOffset;
|
||||
|
||||
const maxScrollX = Math.max(0, containerWidthRef.current - screenWidth);
|
||||
const targetX = Math.max(0, Math.min(scrollX, maxScrollX));
|
||||
|
||||
scrollViewRef.current.scrollTo({
|
||||
x: targetX,
|
||||
animated: true,
|
||||
});
|
||||
}, [activeId, screenWidth]);
|
||||
|
||||
useEffect(() => {
|
||||
const timer = setTimeout(scrollToActiveTab, 150);
|
||||
return () => clearTimeout(timer);
|
||||
}, [scrollToActiveTab]);
|
||||
|
||||
const handleContentSizeChange = useCallback((width: number) => {
|
||||
containerWidthRef.current = width;
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<ScrollView
|
||||
ref={scrollViewRef}
|
||||
horizontal
|
||||
showsHorizontalScrollIndicator={false}
|
||||
contentContainerStyle={styles.container}
|
||||
style={styles.scrollView}
|
||||
onContentSizeChange={handleContentSizeChange}
|
||||
>
|
||||
{categories.map((category, index) => (
|
||||
<CategoryTabItem
|
||||
key={category.id}
|
||||
category={category}
|
||||
isActive={category.id === activeId}
|
||||
onPress={onChange}
|
||||
onLayout={(layout) => {
|
||||
tabPositionsRef.current.set(category.id, {
|
||||
x: layout.x,
|
||||
width: layout.width,
|
||||
});
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</ScrollView>
|
||||
);
|
||||
}
|
||||
|
||||
type CategoryTabItemProps = {
|
||||
category: Category;
|
||||
isActive: boolean;
|
||||
onPress: (id: string) => void;
|
||||
onLayout: (layout: { x: number; width: number }) => void;
|
||||
};
|
||||
|
||||
const CategoryTabItem = memo(({ category, isActive, onPress, onLayout }: CategoryTabItemProps) => {
|
||||
return (
|
||||
<Pressable
|
||||
onPress={() => onPress(category.id)}
|
||||
style={styles.tabButton}
|
||||
onLayout={(event) => {
|
||||
const { x, width } = event.nativeEvent.layout;
|
||||
onLayout({ x, width });
|
||||
}}
|
||||
>
|
||||
<Text style={[styles.label, isActive && styles.labelActive]}>{category.label}</Text>
|
||||
<View style={[styles.indicator, isActive && styles.indicatorActive]} />
|
||||
</Pressable>
|
||||
);
|
||||
});
|
||||
CategoryTabItem.displayName = 'CategoryTabItem';
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
scrollView: {
|
||||
flexGrow: 0,
|
||||
flexShrink: 0,
|
||||
},
|
||||
container: {
|
||||
paddingVertical: 0,
|
||||
paddingHorizontal: 6,
|
||||
paddingTop: 10
|
||||
},
|
||||
tabButton: {
|
||||
alignItems: 'center',
|
||||
marginRight: 20,
|
||||
paddingBottom: 0,
|
||||
},
|
||||
label: {
|
||||
color: '#7F8794',
|
||||
fontSize: 15,
|
||||
fontWeight: '600',
|
||||
letterSpacing: 0.3,
|
||||
},
|
||||
labelActive: {
|
||||
color: '#C7FF00',
|
||||
},
|
||||
indicator: {
|
||||
alignSelf: 'stretch',
|
||||
height: 3,
|
||||
marginTop: 8,
|
||||
backgroundColor: 'transparent',
|
||||
borderRadius: 999,
|
||||
},
|
||||
indicatorActive: {
|
||||
backgroundColor: '#C7FF00',
|
||||
},
|
||||
});
|
||||
210
components/bestai/community-grid.tsx
Normal file
210
components/bestai/community-grid.tsx
Normal file
@@ -0,0 +1,210 @@
|
||||
import { Ionicons } from '@expo/vector-icons';
|
||||
import { Image as ExpoImage } from 'expo-image';
|
||||
import { LinearGradient } from 'expo-linear-gradient';
|
||||
import { memo } from 'react';
|
||||
import {
|
||||
FlatList,
|
||||
ListRenderItemInfo,
|
||||
Pressable,
|
||||
Image as RNImage,
|
||||
StyleProp,
|
||||
StyleSheet,
|
||||
Text,
|
||||
View,
|
||||
ViewStyle,
|
||||
} from 'react-native';
|
||||
|
||||
export type CommunityItem = {
|
||||
id: string;
|
||||
title: string;
|
||||
image: string;
|
||||
chip: string;
|
||||
actionLabel: string;
|
||||
width?: number;
|
||||
height?: number;
|
||||
};
|
||||
|
||||
type CommunityGridProps = {
|
||||
items: CommunityItem[];
|
||||
onPressCard?: (item: CommunityItem) => void;
|
||||
onPressAction?: (item: CommunityItem) => void;
|
||||
};
|
||||
|
||||
const EMPTY_ITEM: CommunityItem = {
|
||||
id: 'empty',
|
||||
title: '',
|
||||
image: '',
|
||||
chip: '',
|
||||
actionLabel: '',
|
||||
};
|
||||
|
||||
export function CommunityGrid({ items, onPressCard, onPressAction }: CommunityGridProps) {
|
||||
const renderItem = ({ item, index }: ListRenderItemInfo<CommunityItem>) => {
|
||||
const isEmpty = item.id === 'empty';
|
||||
const isLeftColumn = index % 2 === 0;
|
||||
|
||||
return (
|
||||
<View style={[styles.cardWrapper, isLeftColumn ? styles.cardLeft : styles.cardRight]}>
|
||||
{isEmpty ? (
|
||||
<View style={styles.emptyCard} />
|
||||
) : (
|
||||
<CommunityCard
|
||||
item={item}
|
||||
onPressCard={onPressCard}
|
||||
onPressAction={onPressAction}
|
||||
/>
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
const data = items.length === 0
|
||||
? [EMPTY_ITEM, EMPTY_ITEM]
|
||||
: items.length % 2 === 1
|
||||
? [...items, EMPTY_ITEM]
|
||||
: items;
|
||||
|
||||
return (
|
||||
<FlatList
|
||||
data={data}
|
||||
renderItem={renderItem}
|
||||
keyExtractor={(item) => item.id}
|
||||
numColumns={2}
|
||||
scrollEnabled={false}
|
||||
columnWrapperStyle={styles.row}
|
||||
contentContainerStyle={styles.contentContainer}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
type CommunityCardProps = {
|
||||
item: CommunityItem;
|
||||
style?: StyleProp<ViewStyle>;
|
||||
onPressCard?: (item: CommunityItem) => void;
|
||||
onPressAction?: (item: CommunityItem) => void;
|
||||
};
|
||||
import VideoPlayer from '@/components/sker/video-player/video-player'
|
||||
|
||||
export const CommunityCard = memo(({ item, style, onPressCard, onPressAction }: CommunityCardProps) => {
|
||||
const isVideo = /\.(mp4|webm|ogg|mov|avi)$/i.test(item.image);
|
||||
|
||||
return (
|
||||
<View style={[styles.card, style]}>
|
||||
<Pressable onPress={() => onPressCard?.(item)} style={styles.imageWrapper}>
|
||||
{isVideo ? (
|
||||
<VideoPlayer
|
||||
source={{ uri: item.image }}
|
||||
originalImage={item.image}
|
||||
style={[styles.cardImage, item.height ? { height: item.height } : {}]}
|
||||
/>
|
||||
) : (
|
||||
<ExpoImage
|
||||
source={{ uri: item.image }}
|
||||
style={[styles.cardImage, item.height ? { height: item.height } : {}]}
|
||||
contentFit="cover"
|
||||
/>
|
||||
)}
|
||||
<LinearGradient
|
||||
colors={['rgba(0, 0, 0, 0)', 'rgba(0, 0, 0, 0.8)']}
|
||||
style={styles.imageGradient}
|
||||
/>
|
||||
<Text style={styles.cardTitle}>{item.chip}</Text>
|
||||
</Pressable>
|
||||
<ActionButton label={item.actionLabel} onPress={() => onPressAction?.(item)} />
|
||||
</View>
|
||||
);
|
||||
});
|
||||
CommunityCard.displayName = 'CommunityCard';
|
||||
|
||||
type ActionButtonProps = {
|
||||
label: string;
|
||||
onPress: () => void;
|
||||
};
|
||||
|
||||
const ActionButton = memo(({ label, onPress }: ActionButtonProps) => {
|
||||
return (
|
||||
<Pressable onPress={onPress} style={({ pressed }) => [styles.button, pressed && styles.buttonPressed]}>
|
||||
<RNImage
|
||||
source={require('@/assets/icons/start.svg')}
|
||||
style={styles.buttonIcon}
|
||||
resizeMode="contain"
|
||||
/>
|
||||
<Text style={styles.buttonText}>{label}</Text>
|
||||
</Pressable>
|
||||
);
|
||||
});
|
||||
ActionButton.displayName = 'CommunityActionButton';
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
contentContainer: {
|
||||
paddingBottom: 12,
|
||||
},
|
||||
row: {
|
||||
justifyContent: 'space-between',
|
||||
marginBottom: 18,
|
||||
},
|
||||
cardWrapper: {
|
||||
flex: 1,
|
||||
},
|
||||
cardLeft: {
|
||||
marginRight: 8,
|
||||
},
|
||||
cardRight: {
|
||||
marginLeft: 8,
|
||||
},
|
||||
emptyCard: {
|
||||
backgroundColor: 'transparent',
|
||||
borderRadius: 20,
|
||||
},
|
||||
card: {
|
||||
borderRadius: 20,
|
||||
padding: 0,
|
||||
},
|
||||
imageWrapper: {
|
||||
position: 'relative',
|
||||
borderTopLeftRadius: 16,
|
||||
borderTopRightRadius: 16,
|
||||
overflow: 'hidden',
|
||||
},
|
||||
cardImage: {
|
||||
width: '100%',
|
||||
height: undefined,
|
||||
},
|
||||
imageGradient: {
|
||||
position: 'absolute',
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
height: 80,
|
||||
},
|
||||
cardTitle: {
|
||||
position: 'absolute',
|
||||
left: 12,
|
||||
right: 12,
|
||||
bottom: 12,
|
||||
fontSize: 13,
|
||||
fontWeight: '600',
|
||||
color: '#FFFFFF',
|
||||
textAlign: 'center',
|
||||
},
|
||||
button: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
paddingVertical: 12,
|
||||
},
|
||||
buttonPressed: {
|
||||
opacity: 0.7,
|
||||
},
|
||||
buttonIcon: {
|
||||
width: 16,
|
||||
height: 16,
|
||||
marginRight: 6,
|
||||
tintColor: '#C7FF00',
|
||||
},
|
||||
buttonText: {
|
||||
fontSize: 14,
|
||||
fontWeight: '700',
|
||||
color: '#C7FF00',
|
||||
},
|
||||
});
|
||||
114
components/bestai/feature-carousel.tsx
Normal file
114
components/bestai/feature-carousel.tsx
Normal file
@@ -0,0 +1,114 @@
|
||||
import { Image } from 'expo-image';
|
||||
import { memo } from 'react';
|
||||
import { Dimensions, FlatList, ListRenderItem, Pressable, StyleSheet, Text, View } from 'react-native';
|
||||
|
||||
const WINDOW_WIDTH = Dimensions.get('window').width;
|
||||
const CARD_HORIZONTAL_GUTTER = 18;
|
||||
const CARD_WIDTH = Math.min(WINDOW_WIDTH - 124, 360);
|
||||
|
||||
export type FeatureItem = {
|
||||
id: string;
|
||||
title: string;
|
||||
subtitle: string;
|
||||
image: string;
|
||||
};
|
||||
|
||||
type FeatureCarouselProps = {
|
||||
items: FeatureItem[];
|
||||
onPress?: (item: FeatureItem) => void;
|
||||
};
|
||||
|
||||
export function FeatureCarousel({ items, onPress }: FeatureCarouselProps) {
|
||||
const cardInterval = CARD_WIDTH + CARD_HORIZONTAL_GUTTER;
|
||||
|
||||
const renderItem: ListRenderItem<FeatureItem> = ({ item }) => <FeatureCard item={item} onPress={onPress} />;
|
||||
|
||||
return (
|
||||
<FlatList
|
||||
data={items}
|
||||
renderItem={renderItem}
|
||||
keyExtractor={(item) => item.id}
|
||||
horizontal
|
||||
showsHorizontalScrollIndicator={false}
|
||||
contentContainerStyle={styles.contentContainer}
|
||||
snapToInterval={cardInterval}
|
||||
snapToAlignment="start"
|
||||
decelerationRate="fast"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
type FeatureCardProps = {
|
||||
item: FeatureItem;
|
||||
onPress?: (item: FeatureItem) => void;
|
||||
};
|
||||
|
||||
const FeatureCard = memo(({ item, onPress }: FeatureCardProps) => {
|
||||
return (
|
||||
<Pressable
|
||||
onPress={() => onPress?.(item)}
|
||||
style={({ pressed }) => [
|
||||
styles.card,
|
||||
pressed && {
|
||||
transform: [{ scale: 0.98 }],
|
||||
},
|
||||
]}
|
||||
>
|
||||
<View style={styles.imageContainer}>
|
||||
<Image source={{ uri: item.image }} style={styles.image} contentFit="cover" />
|
||||
</View>
|
||||
<View style={styles.cardContent}>
|
||||
<Text style={styles.cardTitle}>{item.title}</Text>
|
||||
<Text style={styles.cardSubtitle}>{item.subtitle}</Text>
|
||||
</View>
|
||||
</Pressable>
|
||||
);
|
||||
});
|
||||
FeatureCard.displayName = 'FeatureCard';
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
contentContainer: {
|
||||
paddingVertical: 12,
|
||||
paddingRight: 24,
|
||||
},
|
||||
card: {
|
||||
width: CARD_WIDTH,
|
||||
marginRight: CARD_HORIZONTAL_GUTTER,
|
||||
borderRadius: 24,
|
||||
padding: 0,
|
||||
paddingBottom: 16,
|
||||
borderWidth: 1,
|
||||
borderColor: '#1B1C20',
|
||||
backgroundColor: '#0F1013',
|
||||
shadowColor: '#000000',
|
||||
shadowOpacity: 0.28,
|
||||
shadowRadius: 18,
|
||||
shadowOffset: { width: 0, height: 12 },
|
||||
elevation: 12,
|
||||
},
|
||||
imageContainer: {
|
||||
borderRadius: 18,
|
||||
overflow: 'hidden',
|
||||
marginBottom: 16,
|
||||
},
|
||||
image: {
|
||||
width: '100%',
|
||||
height: 184,
|
||||
},
|
||||
cardContent: {
|
||||
paddingHorizontal: 2,
|
||||
},
|
||||
cardTitle: {
|
||||
fontSize: 16,
|
||||
fontWeight: '800',
|
||||
color: '#F4F8FF',
|
||||
textTransform: 'uppercase',
|
||||
marginBottom: 6,
|
||||
letterSpacing: 1.2,
|
||||
},
|
||||
cardSubtitle: {
|
||||
fontSize: 13,
|
||||
color: '#B4BBC5',
|
||||
lineHeight: 18,
|
||||
},
|
||||
});
|
||||
92
components/bestai/header.tsx
Normal file
92
components/bestai/header.tsx
Normal file
File diff suppressed because one or more lines are too long
10
components/bestai/index.ts
Normal file
10
components/bestai/index.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
export { PageLayout, StatusBarSpacer } from './layout';
|
||||
export { Header } from './header';
|
||||
export { CategoryTabs } from './category-tabs';
|
||||
export { FeatureCarousel } from './feature-carousel';
|
||||
export { SectionHeader } from './section-header';
|
||||
export { CommunityGrid, CommunityCard } from './community-grid';
|
||||
export { Waterfall } from './waterfall';
|
||||
|
||||
export type { FeatureItem } from './feature-carousel';
|
||||
export type { CommunityItem } from './community-grid';
|
||||
54
components/bestai/layout.tsx
Normal file
54
components/bestai/layout.tsx
Normal file
@@ -0,0 +1,54 @@
|
||||
import { memo, PropsWithChildren } from 'react';
|
||||
import { StyleSheet, View } from 'react-native';
|
||||
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
||||
|
||||
type PageLayoutProps = PropsWithChildren<{
|
||||
backgroundColor?: string;
|
||||
topInset?: 'auto' | 'none' | number;
|
||||
horizontalPadding?: number | false;
|
||||
}>;
|
||||
|
||||
export function PageLayout({
|
||||
children,
|
||||
backgroundColor = '#050505',
|
||||
topInset = 'auto',
|
||||
horizontalPadding = 4,
|
||||
}: PageLayoutProps) {
|
||||
const insets = useSafeAreaInsets();
|
||||
|
||||
const spacingTop = topInset === 'auto' ? insets.top : topInset === 'none' ? 0 : topInset;
|
||||
const spacingBottom = Math.max(insets.bottom, 16);
|
||||
const spacingHorizontal = horizontalPadding === false ? 0 : horizontalPadding;
|
||||
|
||||
return (
|
||||
<View
|
||||
style={[
|
||||
styles.container,
|
||||
{
|
||||
paddingTop: spacingTop,
|
||||
paddingBottom: spacingBottom,
|
||||
paddingHorizontal: spacingHorizontal,
|
||||
backgroundColor
|
||||
}
|
||||
]}
|
||||
>
|
||||
{children}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
export const StatusBarSpacer = memo(function StatusBarSpacer() {
|
||||
const insets = useSafeAreaInsets();
|
||||
|
||||
if (!insets.top) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return <View style={{ height: insets.top }} />;
|
||||
});
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
},
|
||||
});
|
||||
35
components/bestai/section-header.tsx
Normal file
35
components/bestai/section-header.tsx
Normal file
@@ -0,0 +1,35 @@
|
||||
import { Pressable, StyleSheet, Text, View } from 'react-native';
|
||||
|
||||
type SectionHeaderProps = {
|
||||
title: string;
|
||||
onPress?: () => void;
|
||||
};
|
||||
|
||||
export function SectionHeader({ title, onPress }: SectionHeaderProps) {
|
||||
if (onPress) {
|
||||
return (
|
||||
<Pressable onPress={onPress} style={styles.container}>
|
||||
<Text style={styles.title}>{title}</Text>
|
||||
</Pressable>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<Text style={styles.title}>{title}</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
marginTop: 28,
|
||||
marginBottom: 18,
|
||||
},
|
||||
title: {
|
||||
fontSize: 18,
|
||||
fontWeight: '800',
|
||||
letterSpacing: 1.2,
|
||||
color: '#F5F8FF',
|
||||
},
|
||||
});
|
||||
86
components/bestai/waterfall.tsx
Normal file
86
components/bestai/waterfall.tsx
Normal file
@@ -0,0 +1,86 @@
|
||||
import { memo, useMemo } from 'react';
|
||||
import { View, StyleSheet, type ViewStyle } from 'react-native';
|
||||
|
||||
type WaterfallItem = {
|
||||
w: number;
|
||||
h: number;
|
||||
data: any;
|
||||
};
|
||||
|
||||
type WaterfallProps<T> = {
|
||||
items: WaterfallItem[];
|
||||
column?: number;
|
||||
itemPadding?: number;
|
||||
renderItem: (item: T, width: number, height: number) => React.ReactNode;
|
||||
containerWidth: number;
|
||||
};
|
||||
|
||||
function WaterfallComponent<T>({
|
||||
items,
|
||||
column = 2,
|
||||
itemPadding = 8,
|
||||
renderItem,
|
||||
containerWidth,
|
||||
}: WaterfallProps<T>) {
|
||||
const layout = useMemo(() => {
|
||||
const columns: { h: number; items: Array<WaterfallItem & { x: number; y: number }> }[] =
|
||||
Array.from({ length: column }, () => ({ h: 0, items: [] }));
|
||||
|
||||
const itemWidth = (containerWidth - itemPadding * (column - 1)) / column;
|
||||
|
||||
items.forEach((item) => {
|
||||
const minColumn = columns.reduce((min, col, idx) =>
|
||||
col.h < columns[min].h ? idx : min, 0);
|
||||
|
||||
const col = columns[minColumn];
|
||||
const layoutItem = {
|
||||
...item,
|
||||
x: minColumn * (itemWidth + itemPadding),
|
||||
y: col.h > 0 ? col.h + itemPadding : 0,
|
||||
w: itemWidth,
|
||||
};
|
||||
|
||||
col.h = layoutItem.y + item.h + 40;
|
||||
col.items.push(layoutItem);
|
||||
});
|
||||
|
||||
const maxHeight = Math.max(...columns.map(col => col.h));
|
||||
const allItems = columns.flatMap(col => col.items);
|
||||
|
||||
return { items: allItems, height: maxHeight };
|
||||
}, [items, column, itemPadding, containerWidth]);
|
||||
|
||||
return (
|
||||
<View style={[styles.container, { height: layout.height }]}>
|
||||
{layout.items.map((item, index) => (
|
||||
<View
|
||||
key={item.data.id || index}
|
||||
style={[
|
||||
styles.item,
|
||||
{
|
||||
position: 'absolute',
|
||||
left: item.x,
|
||||
top: item.y,
|
||||
width: item.w,
|
||||
height: item.h + 48
|
||||
},
|
||||
]}
|
||||
>
|
||||
{renderItem(item.data, item.w, item.h)}
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
position: 'relative',
|
||||
width: '100%',
|
||||
},
|
||||
item: {
|
||||
overflow: 'hidden',
|
||||
},
|
||||
});
|
||||
|
||||
export const Waterfall = memo(WaterfallComponent) as typeof WaterfallComponent;
|
||||
Reference in New Issue
Block a user