261 lines
7.5 KiB
TypeScript
261 lines
7.5 KiB
TypeScript
import React, { memo, useCallback, useMemo } from 'react'
|
||
import { Pressable, StyleSheet, Text, View, ViewStyle, ImageStyle } from 'react-native'
|
||
import { Image } from 'expo-image'
|
||
import { LinearGradient } from 'expo-linear-gradient'
|
||
import { Ionicons } from '@expo/vector-icons'
|
||
import { useTranslation } from 'react-i18next'
|
||
import { useTemplateSocialStore } from '@/stores/templateSocialStore'
|
||
|
||
export interface TemplateCardProps {
|
||
id?: string
|
||
title: string
|
||
titleEn?: string
|
||
previewUrl?: string
|
||
webpPreviewUrl?: string
|
||
coverImageUrl?: string
|
||
aspectRatio?: string
|
||
cardWidth: number
|
||
onPress: (id: string) => void
|
||
liked?: boolean
|
||
favorited?: boolean
|
||
onLike?: (id: string) => void
|
||
onUnlike?: (id: string) => void
|
||
onFavorite?: (id: string) => void
|
||
onUnfavorite?: (id: string) => void
|
||
testID?: string
|
||
}
|
||
|
||
/**
|
||
* 解析 aspectRatio 字符串为数字
|
||
* 例如: "128:128" -> 1, "16:9" -> 1.777..., "1.5" -> 1.5
|
||
*/
|
||
export function parseAspectRatio(aspectRatioString?: string): number | undefined {
|
||
if (!aspectRatioString) return undefined
|
||
|
||
if (aspectRatioString.includes(':')) {
|
||
const [w, h] = aspectRatioString.split(':').map(Number)
|
||
return w / h
|
||
}
|
||
|
||
return Number(aspectRatioString)
|
||
}
|
||
|
||
/**
|
||
* 获取图片源 URI,按优先级: webpPreviewUrl > previewUrl > coverImageUrl
|
||
*/
|
||
export function getImageUri(
|
||
webpPreviewUrl?: string,
|
||
previewUrl?: string,
|
||
coverImageUrl?: string
|
||
): string | undefined {
|
||
return webpPreviewUrl || previewUrl || coverImageUrl
|
||
}
|
||
|
||
// 渐变颜色常量,避免每次渲染创建新数组
|
||
const GRADIENT_COLORS = ['rgba(17, 17, 17, 0)', 'rgba(17, 17, 17, 0.9)'] as const
|
||
const GRADIENT_START = { x: 0, y: 0 }
|
||
const GRADIENT_END = { x: 0, y: 1 }
|
||
|
||
const TemplateCardComponent: React.FC<TemplateCardProps> = ({
|
||
id,
|
||
title,
|
||
titleEn,
|
||
previewUrl,
|
||
webpPreviewUrl,
|
||
coverImageUrl,
|
||
aspectRatio: aspectRatioString,
|
||
cardWidth,
|
||
onPress,
|
||
liked: likedProp,
|
||
favorited: favoritedProp,
|
||
onLike,
|
||
onUnlike,
|
||
onFavorite,
|
||
onUnfavorite,
|
||
testID,
|
||
}) => {
|
||
const { i18n } = useTranslation()
|
||
|
||
// 获取 Store 中的状态(用于本地状态覆盖)
|
||
const { isLiked: isLikedInStore, isFavorited: isFavoritedInStore } = useTemplateSocialStore()
|
||
|
||
// 合并 props 状态和 store 状态:store 优先(乐观更新)
|
||
const liked = id !== undefined ? isLikedInStore(id) ?? likedProp : likedProp
|
||
const favorited = id !== undefined ? isFavoritedInStore(id) ?? favoritedProp : favoritedProp
|
||
|
||
const aspectRatio = useMemo(() => parseAspectRatio(aspectRatioString), [aspectRatioString])
|
||
const imageUri = useMemo(() => getImageUri(webpPreviewUrl, previewUrl, coverImageUrl), [webpPreviewUrl, previewUrl, coverImageUrl])
|
||
|
||
// Select display title based on current language
|
||
const displayTitle = useMemo(() => {
|
||
const isEnglish = i18n.language === 'en-US'
|
||
return isEnglish && titleEn ? titleEn : title
|
||
}, [i18n.language, title, titleEn])
|
||
|
||
// 计算图标状态
|
||
const likeIconName = useMemo(() => (liked ? 'heart' : 'heart-outline'), [liked])
|
||
const likeIconColor = useMemo(() => (liked ? '#FF3B30' : 'rgba(142, 142, 147, 0.7)'), [liked])
|
||
const favoriteIconName = useMemo(() => (favorited ? 'star' : 'star-outline'), [favorited])
|
||
const favoriteIconColor = useMemo(() => (favorited ? '#FFD700' : 'rgba(142, 142, 147, 0.7)'), [favorited])
|
||
|
||
// 使用 useCallback 缓存 onPress 回调
|
||
const handlePress = useCallback(() => {
|
||
if (id) {
|
||
onPress(id)
|
||
}
|
||
}, [id, onPress])
|
||
|
||
// 点赞按钮点击处理 - 阻止事件冒泡
|
||
const handleLikePress = useCallback(() => {
|
||
if (id) {
|
||
if (liked && onUnlike) {
|
||
onUnlike(id)
|
||
} else if (!liked && onLike) {
|
||
onLike(id)
|
||
}
|
||
}
|
||
}, [id, liked, onLike, onUnlike])
|
||
|
||
// 收藏按钮点击处理 - 阻止事件冒泡
|
||
const handleFavoritePress = useCallback(() => {
|
||
if (id) {
|
||
if (favorited && onUnfavorite) {
|
||
onUnfavorite(id)
|
||
} else if (!favorited && onFavorite) {
|
||
onFavorite(id)
|
||
}
|
||
}
|
||
}, [id, favorited, onFavorite, onUnfavorite])
|
||
|
||
// 缓存样式计算
|
||
const cardStyle = useMemo(() => [styles.card, { width: cardWidth }], [cardWidth])
|
||
const containerStyle = useMemo(() =>
|
||
[styles.cardImageContainer, aspectRatio ? { aspectRatio } : undefined].filter(Boolean) as ViewStyle[],
|
||
[aspectRatio]
|
||
)
|
||
const imageStyle = useMemo(() =>
|
||
[styles.cardImage, aspectRatio ? { aspectRatio } : undefined].filter(Boolean) as ImageStyle[],
|
||
[aspectRatio]
|
||
)
|
||
|
||
// 如果没有 id,则不渲染卡片
|
||
if (!id) {
|
||
return null
|
||
}
|
||
|
||
// 判断是否有点赞/收藏回调
|
||
const hasSocialActions = !!(onLike || onUnlike || onFavorite || onUnfavorite)
|
||
|
||
return (
|
||
<Pressable
|
||
style={cardStyle}
|
||
onPress={handlePress}
|
||
testID={testID}
|
||
>
|
||
<View style={containerStyle}>
|
||
<Image
|
||
source={{ uri: imageUri }}
|
||
style={imageStyle}
|
||
contentFit="cover"
|
||
cachePolicy="memory-disk"
|
||
recyclingKey={id}
|
||
transition={200}
|
||
/>
|
||
<LinearGradient
|
||
colors={GRADIENT_COLORS}
|
||
start={GRADIENT_START}
|
||
end={GRADIENT_END}
|
||
style={styles.cardImageGradient}
|
||
/>
|
||
{/* 点赞和收藏图标 - 只在有回调时显示为可点击 */}
|
||
{hasSocialActions ? (
|
||
<View style={styles.iconsContainer}>
|
||
<Pressable
|
||
onPress={(e) => {
|
||
e.stopPropagation()
|
||
handleLikePress()
|
||
}}
|
||
style={styles.iconButton}
|
||
hitSlop={{ top: 8, bottom: 8, left: 8, right: 8 }}
|
||
testID={`${testID}-like-button`}
|
||
>
|
||
<Ionicons name={likeIconName} size={16} color={likeIconColor} style={styles.icon} />
|
||
</Pressable>
|
||
<Pressable
|
||
onPress={(e) => {
|
||
e.stopPropagation()
|
||
handleFavoritePress()
|
||
}}
|
||
style={styles.iconButton}
|
||
hitSlop={{ top: 8, bottom: 8, left: 8, right: 8 }}
|
||
testID={`${testID}-favorite-button`}
|
||
>
|
||
<Ionicons name={favoriteIconName} size={16} color={favoriteIconColor} style={styles.icon} />
|
||
</Pressable>
|
||
</View>
|
||
) : (
|
||
// 没有回调时显示为静态图标
|
||
<View style={styles.iconsContainer}>
|
||
<Ionicons name={likeIconName} size={16} color={likeIconColor} style={styles.icon} />
|
||
<Ionicons name={favoriteIconName} size={16} color={favoriteIconColor} style={styles.icon} />
|
||
</View>
|
||
)}
|
||
<Text style={styles.cardTitle} numberOfLines={1}>
|
||
{displayTitle}
|
||
</Text>
|
||
</View>
|
||
</Pressable>
|
||
)
|
||
}
|
||
|
||
export const TemplateCard = memo(TemplateCardComponent)
|
||
|
||
const styles = StyleSheet.create({
|
||
card: {
|
||
marginBottom: 5,
|
||
},
|
||
cardImageContainer: {
|
||
width: '100%',
|
||
borderRadius: 8,
|
||
overflow: 'hidden',
|
||
position: 'relative',
|
||
},
|
||
cardImage: {
|
||
width: '100%',
|
||
borderRadius: 8,
|
||
},
|
||
cardImageGradient: {
|
||
position: 'absolute',
|
||
bottom: 0,
|
||
left: 0,
|
||
right: 0,
|
||
height: '50%',
|
||
},
|
||
iconsContainer: {
|
||
position: 'absolute',
|
||
top: 8,
|
||
right: 8,
|
||
flexDirection: 'row',
|
||
gap: 4,
|
||
},
|
||
iconButton: {
|
||
padding: 4,
|
||
},
|
||
icon: {
|
||
shadowColor: '#000',
|
||
shadowOffset: { width: 0, height: 1 },
|
||
shadowOpacity: 0.3,
|
||
shadowRadius: 2,
|
||
elevation: 2,
|
||
},
|
||
cardTitle: {
|
||
position: 'absolute',
|
||
bottom: 8,
|
||
left: 8,
|
||
right: 8,
|
||
color: '#F5F5F5',
|
||
fontSize: 12,
|
||
fontWeight: '500',
|
||
},
|
||
})
|