Initial commit: expo-popcore project

This commit is contained in:
imeepos
2025-12-25 16:31:17 +08:00
commit 0cbb74e03a
188 changed files with 20796 additions and 0 deletions

View 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',
},
});

View 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',
},
});

View 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,
},
});

File diff suppressed because one or more lines are too long

View 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';

View 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,
},
});

View 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',
},
});

View 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;