Initial commit: expo-popcore project
This commit is contained in:
329
components/profile/content-gallery.tsx
Normal file
329
components/profile/content-gallery.tsx
Normal file
@@ -0,0 +1,329 @@
|
||||
import React, { memo } from 'react';
|
||||
import {
|
||||
View,
|
||||
FlatList,
|
||||
ActivityIndicator,
|
||||
RefreshControl,
|
||||
StyleSheet,
|
||||
Image,
|
||||
TouchableOpacity,
|
||||
Platform
|
||||
} from 'react-native';
|
||||
import { VideoPlayer } from '@/components/video/video-player';
|
||||
import { ThemedText } from '@/components/themed-text';
|
||||
import MaterialIcons from '@expo/vector-icons/MaterialIcons';
|
||||
import { router } from 'expo-router';
|
||||
import type { TemplateGeneration } from '@/lib/api/template-generations';
|
||||
|
||||
/**
|
||||
* 根据文件 URL 后缀名判断媒体类型
|
||||
*/
|
||||
function getMediaType(url: string): 'image' | 'video' | 'unknown' {
|
||||
if (!url) return 'unknown';
|
||||
|
||||
const extension = url.split('.').pop()?.toLowerCase() || '';
|
||||
|
||||
const imageExts = ['jpg', 'jpeg', 'png', 'gif', 'webp', 'bmp', 'svg'];
|
||||
const videoExts = ['mp4', 'webm', 'avi', 'mov', 'mkv', 'flv', 'm4v'];
|
||||
|
||||
if (imageExts.includes(extension)) return 'image';
|
||||
if (videoExts.includes(extension)) return 'video';
|
||||
return 'unknown';
|
||||
}
|
||||
|
||||
export function ContentGallery({
|
||||
generations,
|
||||
isRefreshing,
|
||||
onRefresh,
|
||||
isLoadingMore,
|
||||
hasMore,
|
||||
onLoadMore,
|
||||
ListHeaderComponent,
|
||||
}: {
|
||||
generations: TemplateGeneration[];
|
||||
isRefreshing: boolean;
|
||||
onRefresh: () => void;
|
||||
isLoadingMore: boolean;
|
||||
hasMore: boolean;
|
||||
onLoadMore: () => void;
|
||||
ListHeaderComponent?: React.ComponentType<any> | React.ReactElement | null;
|
||||
}) {
|
||||
const palette = darkPalette;
|
||||
|
||||
const renderItem = ({ item }: { item: TemplateGeneration }) => (
|
||||
<ContentItem palette={palette} generation={item} />
|
||||
);
|
||||
|
||||
const renderFooter = () => {
|
||||
if (isLoadingMore) {
|
||||
return (
|
||||
<View style={[styles.loadingMoreContainer, { backgroundColor: palette.background }]}>
|
||||
<ActivityIndicator size="small" color={palette.accent} />
|
||||
<ThemedText style={[styles.loadingMoreText, { color: palette.textSecondary }]}>
|
||||
加载更多...
|
||||
</ThemedText>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
if (!hasMore && generations.length > 0) {
|
||||
return (
|
||||
<View style={[styles.noMoreContainer, { backgroundColor: palette.background }]}>
|
||||
<ThemedText style={[styles.noMoreText, { color: palette.textSecondary }]}>
|
||||
没有更多内容了
|
||||
</ThemedText>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
return (
|
||||
<FlatList
|
||||
data={generations}
|
||||
renderItem={renderItem}
|
||||
ListHeaderComponent={ListHeaderComponent}
|
||||
ListFooterComponent={renderFooter}
|
||||
numColumns={2}
|
||||
keyExtractor={(item) => item.id}
|
||||
columnWrapperStyle={styles.columnWrapper}
|
||||
showsVerticalScrollIndicator={false}
|
||||
refreshControl={
|
||||
<RefreshControl
|
||||
refreshing={isRefreshing}
|
||||
onRefresh={onRefresh}
|
||||
tintColor={palette.accent}
|
||||
colors={[palette.accent]}
|
||||
/>
|
||||
}
|
||||
onEndReached={onLoadMore}
|
||||
onEndReachedThreshold={0.5}
|
||||
contentContainerStyle={styles.contentContainer}
|
||||
removeClippedSubviews={true}
|
||||
maxToRenderPerBatch={6}
|
||||
updateCellsBatchingPeriod={50}
|
||||
windowSize={10}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const ContentItem = memo(function ContentItem({
|
||||
palette,
|
||||
generation,
|
||||
}: {
|
||||
palette: any;
|
||||
generation: TemplateGeneration;
|
||||
}) {
|
||||
const formatDate = (dateString: string) => {
|
||||
const date = new Date(dateString);
|
||||
return date.toLocaleDateString('en-US', {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
});
|
||||
};
|
||||
|
||||
const renderMedia = () => {
|
||||
const mediaUrl = generation.resultUrl[0];
|
||||
|
||||
if (!mediaUrl) {
|
||||
return (
|
||||
<View style={[styles.mediaContainer, styles.placeholderContainer]}>
|
||||
<ThemedText style={styles.placeholderText}>
|
||||
{generation.type === 'VIDEO' ? '🎬' : generation.type === 'IMAGE' ? '🖼️' : '📝'}
|
||||
</ThemedText>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const mediaType = getMediaType(mediaUrl);
|
||||
|
||||
if (mediaType === 'video') {
|
||||
if (Platform.OS === 'web') {
|
||||
return (
|
||||
<View style={styles.mediaContainer}>
|
||||
<video
|
||||
src={mediaUrl}
|
||||
style={{
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
objectFit: 'cover' as any,
|
||||
}}
|
||||
muted
|
||||
playsInline
|
||||
preload="metadata"
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
} else {
|
||||
return (
|
||||
<View style={styles.mediaContainer}>
|
||||
<VideoPlayer
|
||||
source={{ uri: mediaUrl }}
|
||||
style={styles.mediaVideo}
|
||||
shouldPlay={false}
|
||||
isLooping={true}
|
||||
isMuted={true}
|
||||
useNativeControls={false}
|
||||
showPoster={true}
|
||||
maxHeight={300}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<View style={styles.mediaContainer}>
|
||||
<Image
|
||||
source={{ uri: mediaUrl }}
|
||||
style={styles.mediaImage}
|
||||
resizeMode="cover"
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<TouchableOpacity
|
||||
style={[
|
||||
styles.contentItem,
|
||||
{
|
||||
backgroundColor: palette.surface,
|
||||
},
|
||||
]}
|
||||
onPress={() => router.push(`/result?generationId=${generation.id}`)}
|
||||
activeOpacity={0.9}
|
||||
>
|
||||
{renderMedia()}
|
||||
<View style={styles.contentInfo}>
|
||||
<ThemedText
|
||||
style={[styles.contentTitle, { color: palette.textPrimary }]}
|
||||
numberOfLines={1}
|
||||
>
|
||||
{generation.template?.title || '未知模板'}
|
||||
</ThemedText>
|
||||
<View style={styles.contentMeta}>
|
||||
<View style={styles.metaItem}>
|
||||
<MaterialIcons name="schedule" size={14} color={palette.textSecondary} />
|
||||
<ThemedText style={[styles.metaText, { color: palette.textSecondary }]}>
|
||||
{formatDate(generation.createdAt)}
|
||||
</ThemedText>
|
||||
</View>
|
||||
{generation.status !== 'completed' && (
|
||||
<View style={styles.statusBadge}>
|
||||
<ThemedText style={[styles.statusText, { color: palette.textSecondary }]}>
|
||||
{generation.status}
|
||||
</ThemedText>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
);
|
||||
});
|
||||
|
||||
const darkPalette = {
|
||||
background: '#050505',
|
||||
surface: '#121216',
|
||||
border: '#1D1E24',
|
||||
textPrimary: '#F6F7FA',
|
||||
textSecondary: '#8E9098',
|
||||
accent: '#B7FF2F',
|
||||
};
|
||||
|
||||
const lightPalette = {
|
||||
background: '#F7F8FB',
|
||||
surface: '#FFFFFF',
|
||||
border: '#E2E5ED',
|
||||
textPrimary: '#0F1320',
|
||||
textSecondary: '#5E6474',
|
||||
accent: '#405CFF',
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
contentContainer: {
|
||||
paddingBottom: 120,
|
||||
paddingHorizontal: 12,
|
||||
},
|
||||
columnWrapper: {
|
||||
justifyContent: 'space-between',
|
||||
},
|
||||
contentItem: {
|
||||
flex: 1,
|
||||
borderRadius: 12,
|
||||
marginBottom: 12,
|
||||
overflow: 'hidden',
|
||||
marginHorizontal: 6,
|
||||
},
|
||||
mediaContainer: {
|
||||
width: '100%',
|
||||
aspectRatio: 1,
|
||||
backgroundColor: '#000',
|
||||
},
|
||||
placeholderContainer: {
|
||||
backgroundColor: '#1A1A1A',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
},
|
||||
placeholderText: {
|
||||
fontSize: 48,
|
||||
},
|
||||
mediaImage: {
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
},
|
||||
mediaVideo: {
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
},
|
||||
contentInfo: {
|
||||
padding: 12,
|
||||
},
|
||||
contentTitle: {
|
||||
fontSize: 15,
|
||||
fontWeight: '600',
|
||||
marginBottom: 8,
|
||||
},
|
||||
contentMeta: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
},
|
||||
metaItem: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
marginRight: 12,
|
||||
},
|
||||
metaText: {
|
||||
fontSize: 12,
|
||||
marginLeft: 4,
|
||||
},
|
||||
statusBadge: {
|
||||
paddingHorizontal: 8,
|
||||
paddingVertical: 4,
|
||||
borderRadius: 4,
|
||||
backgroundColor: 'rgba(0, 0, 0, 0.1)',
|
||||
},
|
||||
statusText: {
|
||||
fontSize: 11,
|
||||
fontWeight: '600',
|
||||
textTransform: 'capitalize' as const,
|
||||
},
|
||||
loadingMoreContainer: {
|
||||
paddingVertical: 20,
|
||||
alignItems: 'center',
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'center',
|
||||
gap: 12,
|
||||
},
|
||||
loadingMoreText: {
|
||||
fontSize: 14,
|
||||
},
|
||||
noMoreContainer: {
|
||||
paddingVertical: 20,
|
||||
alignItems: 'center',
|
||||
},
|
||||
noMoreText: {
|
||||
fontSize: 14,
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user