expo-popcore-app/components/blocks/home/TemplateCard.tsx

261 lines
7.5 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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