757 lines
24 KiB
TypeScript
757 lines
24 KiB
TypeScript
import { Ionicons } from '@expo/vector-icons'
|
||
import { useIsFocused } from '@react-navigation/native'
|
||
import { Block, ConfirmModal, Img, ListEmpty, Text, Toast } from '@share/components'
|
||
import { FlashList } from '@shopify/flash-list'
|
||
import { Image } from 'expo-image'
|
||
import { LinearGradient } from 'expo-linear-gradient'
|
||
import { router, useFocusEffect } from 'expo-router'
|
||
import { observer } from 'mobx-react-lite'
|
||
import React, { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||
import { ActivityIndicator, RefreshControl, TextInput } from 'react-native'
|
||
|
||
import BannerSection from '@/components/BannerSection'
|
||
import { useTemplateActions } from '@/hooks/actions/use-template-actions'
|
||
import { useTemplates } from '@/hooks/data'
|
||
import { useFavoriteTemplates } from '@/hooks/data/use-favorite-templates'
|
||
import { userBalanceStore, userStore } from '@/stores'
|
||
import { screenWidth } from '@/utils'
|
||
import { cn } from '@/utils/cn'
|
||
|
||
const CATEGORY_ID = process.env.EXPO_PUBLIC_INDEX_GROUP_ID
|
||
const ITEM_WIDTH = Math.floor((screenWidth - 24 - 12 * 2) / 3)
|
||
|
||
const PAGE_SIZE = 12
|
||
|
||
type MediaItem = {
|
||
id: string
|
||
type: 'image' | 'video'
|
||
url: string | ''
|
||
poster?: string
|
||
webpPreviewUrl?: string
|
||
webpHighPreviewUrl?: string
|
||
mp4Url?: string
|
||
likeCount?: number
|
||
price: number
|
||
title?: string
|
||
authorName?: string
|
||
}
|
||
type ActiveTab = 'gen' | '' | 'new' | 'like'
|
||
|
||
/** =========================
|
||
* Entry page
|
||
* ========================= */
|
||
|
||
const Index = observer(function Index() {
|
||
// 从MobX Store获取用户信息
|
||
const { user, isAuthenticated } = userStore as typeof userStore
|
||
const { execute: loadTemplates } = useTemplates()
|
||
const { execute: loadFavorites } = useFavoriteTemplates()
|
||
const { runTemplate } = useTemplateActions()
|
||
|
||
/** ================= 状态 ================= */
|
||
|
||
const [activeTab, setActiveTab] = useState<ActiveTab>('')
|
||
const [isSearchOpen, setIsSearchOpen] = useState(false)
|
||
|
||
const [allItems, setAllItems] = useState<MediaItem[]>([])
|
||
const [selectedItem, setSelectedItem] = useState<MediaItem | null>(null)
|
||
|
||
// 直接使用 useIsFocused hook,无需手动管理状态
|
||
const isFocused = useIsFocused()
|
||
|
||
const [refreshing, setRefreshing] = useState(false)
|
||
const [loadingMore, setLoadingMore] = useState(false)
|
||
const [hasMore, setHasMore] = useState(true)
|
||
|
||
/** ================= refs(核心) ================= */
|
||
|
||
const queryRef = useRef({
|
||
page: 1,
|
||
pageSize: PAGE_SIZE,
|
||
search: '',
|
||
tab: '' as ActiveTab,
|
||
})
|
||
|
||
const loadingRef = useRef(false)
|
||
const firstLoadDoneRef = useRef(false)
|
||
|
||
/** ================= 工具函数 ================= */
|
||
|
||
type TemplateData = {
|
||
id: string
|
||
previewUrl?: string
|
||
coverImageUrl?: string
|
||
likeCount?: number
|
||
price?: number
|
||
title?: string
|
||
isDeleted?: boolean
|
||
}
|
||
|
||
const transformTemplateToMediaItem = useCallback((template: TemplateData): MediaItem => {
|
||
const isVideo = template.previewUrl?.includes('.mp4')
|
||
return {
|
||
id: template.id,
|
||
type: isVideo ? 'video' : 'image',
|
||
url: isVideo ? template?.previewUrl || '' : template.coverImageUrl || template.previewUrl || '',
|
||
webpPreviewUrl: template?.webpPreviewUrl,
|
||
webpHighPreviewUrl: template?.webpHighPreviewUrl,
|
||
mp4Url: template?.previewUrl,
|
||
poster: isVideo ? template.coverImageUrl : undefined,
|
||
likeCount: template.likeCount || 0,
|
||
price: template.price || 0,
|
||
authorName: template?.user?.name || '未知作者',
|
||
title: template.title || '',
|
||
}
|
||
}, [])
|
||
|
||
/** ================= 统一请求函数 ================= */
|
||
|
||
const fetchList = async (mode: 'init' | 'refresh' | 'loadMore') => {
|
||
if (loadingRef.current) return
|
||
if (mode === 'loadMore' && !hasMore) return
|
||
|
||
loadingRef.current = true
|
||
if (mode === 'loadMore') {
|
||
setLoadingMore(true)
|
||
} else {
|
||
setRefreshing(true)
|
||
}
|
||
|
||
try {
|
||
const { page, pageSize, search, tab } = queryRef.current
|
||
let newItems: MediaItem[] = []
|
||
|
||
if (tab === 'like') {
|
||
console.log('加载收藏列表,isAuthenticated=', isAuthenticated)
|
||
if (!isAuthenticated) {
|
||
setHasMore(false)
|
||
newItems = []
|
||
} else {
|
||
const { data } = await loadFavorites({ page, limit: pageSize })
|
||
newItems =
|
||
data?.favorites
|
||
?.filter((f) => f.template && !(f.template as TemplateData).isDeleted)
|
||
.map((f) => transformTemplateToMediaItem(f.template as TemplateData)) || []
|
||
}
|
||
} else {
|
||
const sortBy = tab === 'new' ? 'createdAt' : 'likeCount'
|
||
const { data } = await loadTemplates({
|
||
page,
|
||
limit: pageSize,
|
||
sortBy,
|
||
sortOrder: 'desc',
|
||
search,
|
||
categoryId: CATEGORY_ID,
|
||
})
|
||
newItems = data?.templates?.map(transformTemplateToMediaItem) || []
|
||
}
|
||
|
||
if (newItems.length < pageSize) {
|
||
setHasMore(false)
|
||
}
|
||
|
||
const updatedItems = mode === 'loadMore' ? [...allItems, ...newItems] : newItems
|
||
|
||
setAllItems(updatedItems)
|
||
} catch (e) {
|
||
console.error('fetchList error:', e)
|
||
setHasMore(false)
|
||
} finally {
|
||
loadingRef.current = false
|
||
const currentPage = queryRef.current.page
|
||
if (currentPage === 1) {
|
||
firstLoadDoneRef.current = true
|
||
}
|
||
setRefreshing(false)
|
||
setLoadingMore(false)
|
||
}
|
||
}
|
||
|
||
/** ================= tab 切换 ================= */
|
||
|
||
const changeTab = useCallback(
|
||
(tab: ActiveTab) => {
|
||
loadingRef.current = false
|
||
queryRef.current = {
|
||
...queryRef.current,
|
||
tab,
|
||
page: 1,
|
||
}
|
||
setHasMore(true)
|
||
firstLoadDoneRef.current = false
|
||
fetchList('init')
|
||
},
|
||
[isAuthenticated],
|
||
)
|
||
|
||
/** ================= 搜索 ================= */
|
||
|
||
const handleSearch = useCallback((text: string) => {
|
||
queryRef.current = {
|
||
...queryRef.current,
|
||
search: text,
|
||
page: 1,
|
||
}
|
||
setHasMore(true)
|
||
firstLoadDoneRef.current = false
|
||
fetchList('refresh')
|
||
}, [])
|
||
|
||
/** ================= 刷新 / 加载更多 ================= */
|
||
|
||
const handleRefresh = useCallback(() => {
|
||
queryRef.current.page = 1
|
||
setHasMore(true)
|
||
firstLoadDoneRef.current = false
|
||
fetchList('refresh')
|
||
}, [])
|
||
|
||
const handleLoadMore = useCallback(() => {
|
||
if (loadingRef.current || !hasMore || refreshing || loadingMore) return
|
||
if (!firstLoadDoneRef.current) return
|
||
|
||
queryRef.current.page += 1
|
||
fetchList('loadMore')
|
||
}, [hasMore, refreshing, loadingMore])
|
||
|
||
/** ================= selectedItem 修正 ================= */
|
||
|
||
useEffect(() => {
|
||
if (!allItems.length) {
|
||
setSelectedItem(null)
|
||
return
|
||
}
|
||
if (!allItems.find((i) => i.id === selectedItem?.id)) {
|
||
setSelectedItem(allItems[0])
|
||
}
|
||
}, [allItems, selectedItem?.id])
|
||
|
||
useEffect(() => {
|
||
fetchList('init')
|
||
userBalanceStore.load(true)
|
||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||
}, [])
|
||
|
||
useEffect(() => {
|
||
const timer = setTimeout(() => {
|
||
if (isAuthenticated && user?.id) {
|
||
console.log('用户已登录,启动余额轮询')
|
||
userBalanceStore.startPolling()
|
||
} else {
|
||
console.log('用户未登录,停止余额轮询')
|
||
userBalanceStore.stopPolling()
|
||
}
|
||
}, 100)
|
||
|
||
return () => {
|
||
clearTimeout(timer)
|
||
}
|
||
}, [isAuthenticated, user?.id])
|
||
|
||
// 页面失焦时清理内存缓存,减少内存占用
|
||
useEffect(() => {
|
||
if (!isFocused) {
|
||
// 清理内存缓存,保留磁盘缓存
|
||
Image.clearMemoryCache()
|
||
// console.log('页面失焦,清理内存缓存')
|
||
}
|
||
}, [isFocused])
|
||
|
||
useEffect(() => {
|
||
return () => {
|
||
userBalanceStore.stopPolling()
|
||
}
|
||
}, [])
|
||
|
||
useFocusEffect(() => {
|
||
if (isAuthenticated && user?.id) {
|
||
userBalanceStore.load(true)
|
||
}
|
||
})
|
||
|
||
/** ================= 快速生成 ================= */
|
||
|
||
const handleQuickGen = async () => {
|
||
if (!isAuthenticated) {
|
||
router.push('/auth')
|
||
return
|
||
}
|
||
if (!selectedItem) {
|
||
Toast.show({ title: '请先选择一个模板' })
|
||
return
|
||
}
|
||
|
||
Toast.showLoading()
|
||
try {
|
||
await userBalanceStore.load(true)
|
||
|
||
const currentBalance = userBalanceStore.balance
|
||
if (currentBalance < selectedItem.price) {
|
||
Toast.show({ title: '余额不足,请充值' })
|
||
return
|
||
}
|
||
} catch (error) {
|
||
Toast.show({ title: '余额加载失败,请重试' })
|
||
return
|
||
} finally {
|
||
Toast.hideLoading()
|
||
}
|
||
|
||
Toast.showModal(
|
||
<ConfirmModal
|
||
content={
|
||
<Text className="text-[14px] font-bold">
|
||
生成将消耗
|
||
<Text className="mx-[4px] text-[20px] text-[#e61e25]">{selectedItem.price}</Text>
|
||
</Text>
|
||
}
|
||
onCancel={Toast.hideModal}
|
||
onConfirm={async () => {
|
||
Toast.hideModal()
|
||
Toast.showLoading()
|
||
|
||
try {
|
||
userBalanceStore.deductBalance(selectedItem.price)
|
||
|
||
const { generationId, error } = await runTemplate({
|
||
templateId: selectedItem.id,
|
||
data: {},
|
||
})
|
||
|
||
if (generationId && user?.id) {
|
||
await userBalanceStore.load(true)
|
||
Toast.show({ title: '生成任务开启,请在我的生成中查看' })
|
||
} else {
|
||
userBalanceStore.setBalance(userBalanceStore.balance + selectedItem.price)
|
||
Toast.show({ title: error?.message || '生成失败' })
|
||
}
|
||
} catch (error) {
|
||
userBalanceStore.setBalance(userBalanceStore.balance + selectedItem.price)
|
||
Toast.show({ title: '网络异常,请重试' })
|
||
} finally {
|
||
Toast.hideLoading()
|
||
}
|
||
}}
|
||
/>,
|
||
)
|
||
}
|
||
|
||
const renderListEmpty = () => {
|
||
if (activeTab === 'like' && !isAuthenticated) {
|
||
return (
|
||
<Block className="mt-[40px] items-center justify-center gap-[16px] py-[60px]">
|
||
<Block className="size-[80px] items-center justify-center rounded-full border-4 border-white/20 bg-white/10">
|
||
<Ionicons color="rgba(255,255,255,0.6)" name="heart-outline" size={40} />
|
||
</Block>
|
||
<Text className="text-[16px] font-bold text-white/80">查看您收藏的模板</Text>
|
||
<Text className="text-[12px] text-white/50">登录后即可查看和管理收藏</Text>
|
||
<Block
|
||
className="mt-[12px] flex-row items-center gap-[8px] border-[3px] border-white bg-accent px-[32px] py-[12px] shadow-[4px_4px_0px_rgba(255,255,255,0.2)]"
|
||
style={{ transform: [{ skewX: '-6deg' }] }}
|
||
onClick={() => router.push('/auth')}
|
||
>
|
||
<Ionicons color="black" name="flash" size={16} style={{ transform: [{ skewX: '6deg' }] }} />
|
||
<Text className="font-900 text-[14px] text-black" style={{ transform: [{ skewX: '6deg' }] }}>
|
||
立即登录
|
||
</Text>
|
||
</Block>
|
||
</Block>
|
||
)
|
||
} else {
|
||
return <ListEmpty />
|
||
}
|
||
}
|
||
|
||
// 简化的 selectedId,避免对象引用变化
|
||
const selectedId = selectedItem?.id
|
||
|
||
// 可见性状态
|
||
const visibleIdsRef = useRef<Set<string>>(new Set())
|
||
const [visibilityVersion, setVisibilityVersion] = useState(0)
|
||
|
||
// 可见性变化回调 - 优化版:减少状态更新频率
|
||
const onViewableItemsChanged = useCallback(
|
||
(info: { viewableItems: { isViewable: boolean; key: string; index: number | null }[] }) => {
|
||
const { viewableItems } = info
|
||
const currentVisible = new Set<string>(viewableItems.filter((v) => v.isViewable).map((v) => v.key))
|
||
|
||
// 快速滑动时可能为空,保留上次状态
|
||
if (currentVisible.size === 0 && visibleIdsRef.current.size > 0) return
|
||
|
||
// 添加前后缓冲(减少到3个item以降低内存占用)
|
||
if (viewableItems.length > 0 && allItems.length > 0) {
|
||
const first = viewableItems[0]?.index ?? 0
|
||
const last = viewableItems[viewableItems.length - 1]?.index ?? first
|
||
|
||
if (first !== null && last !== null) {
|
||
// 每列3个,所以前后各加3行(9个item)
|
||
for (let i = Math.max(0, first - 6); i < first; i++) {
|
||
if (allItems[i]) currentVisible.add(allItems[i].id)
|
||
}
|
||
for (let i = last + 1; i < Math.min(allItems.length, last - 6); i++) {
|
||
currentVisible.add(allItems[i].id)
|
||
}
|
||
}
|
||
}
|
||
|
||
// 只有可见项发生显著变化时才更新状态(避免频繁重渲染)
|
||
const hasSignificantChange =
|
||
Math.abs(currentVisible.size - visibleIdsRef.current.size) > 3 ||
|
||
[...currentVisible].some((id) => !visibleIdsRef.current.has(id))
|
||
|
||
if (hasSignificantChange) {
|
||
visibleIdsRef.current = currentVisible
|
||
setVisibilityVersion((v) => v + 1)
|
||
}
|
||
},
|
||
[allItems],
|
||
)
|
||
|
||
const renderItem = useCallback(
|
||
({ item, index }: { item: MediaItem; index: number }) => (
|
||
<GridItem
|
||
isSelected={selectedId === item.id}
|
||
item={item}
|
||
itemWidth={ITEM_WIDTH}
|
||
// 页面失焦时不渲染,减少内存占用
|
||
isVisible={isFocused && (index < 9 || visibleIdsRef.current.has(item.id))}
|
||
onSelect={() => setSelectedItem(item)}
|
||
/>
|
||
),
|
||
[selectedId, isFocused],
|
||
)
|
||
|
||
const keyExtractor = useCallback((item: MediaItem) => item.id, [])
|
||
|
||
/** ================= UI ================= */
|
||
return (
|
||
<Block className="flex-1 bg-black px-[12px]">
|
||
<BannerSection />
|
||
<HeroCircle onOpenSearch={() => setIsSearchOpen(true)} onQuickGen={handleQuickGen} selectedItem={selectedItem} />
|
||
|
||
<FilterSection
|
||
activeTab={activeTab}
|
||
onChange={(tab) => {
|
||
setActiveTab(tab)
|
||
changeTab(tab)
|
||
}}
|
||
/>
|
||
<FlashList
|
||
contentContainerStyle={{ paddingBottom: 200, marginTop: 12 }}
|
||
ListFooterComponent={
|
||
loadingMore ? (
|
||
<Block className="items-center py-[20px]">
|
||
<ActivityIndicator color="#FFE500" />
|
||
</Block>
|
||
) : null
|
||
}
|
||
numColumns={3}
|
||
onEndReached={handleLoadMore}
|
||
// 设置合理的回收池大小,复用组件实例以提高性能
|
||
maxItemsInRecyclePool={0}
|
||
// 自动移除不可见的原生视图,停止渲染和解码
|
||
removeClippedSubviews={true}
|
||
// 减小预渲染距离,降低内存占用
|
||
drawDistance={300}
|
||
onEndReachedThreshold={0.3}
|
||
refreshControl={<RefreshControl colors={['#FFE500']} refreshing={refreshing} onRefresh={handleRefresh} />}
|
||
renderItem={renderItem}
|
||
keyExtractor={keyExtractor}
|
||
ListEmptyComponent={renderListEmpty}
|
||
onViewableItemsChanged={onViewableItemsChanged}
|
||
viewabilityConfig={{
|
||
itemVisiblePercentThreshold: 10,
|
||
minimumViewTime: 50,
|
||
waitForInteraction: true,
|
||
}}
|
||
showsVerticalScrollIndicator={false}
|
||
data={allItems}
|
||
extraData={visibilityVersion}
|
||
/>
|
||
|
||
<SearchOverlay isOpen={isSearchOpen} onClose={() => setIsSearchOpen(false)} onSearch={handleSearch} />
|
||
</Block>
|
||
)
|
||
})
|
||
|
||
type SearchOverlayProps = {
|
||
isOpen: boolean
|
||
searchText?: string
|
||
onChange?: (v: string) => void
|
||
onClose: () => void
|
||
onSearch: (v: string) => void
|
||
}
|
||
const SearchOverlay = memo<SearchOverlayProps>(function SearchOverlay({ isOpen, onChange, onClose, onSearch }) {
|
||
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||
const [searchText, setSearchText] = useState('')
|
||
|
||
const handleTextChange = useCallback(
|
||
(text: string) => {
|
||
setSearchText(text)
|
||
onChange?.(text)
|
||
|
||
if (timerRef.current) {
|
||
clearTimeout(timerRef.current)
|
||
}
|
||
|
||
timerRef.current = setTimeout(() => {
|
||
onSearch(text)
|
||
}, 500)
|
||
},
|
||
[onChange, onSearch],
|
||
)
|
||
|
||
const handleClose = useCallback(() => {
|
||
setSearchText('')
|
||
onSearch('')
|
||
onClose?.()
|
||
}, [onClose, onSearch])
|
||
|
||
if (!isOpen) return null
|
||
return (
|
||
<Block className="absolute inset-x-0 top-0 z-50 mt-[24px] flex-row items-center gap-[8px] px-[20px]">
|
||
<Block
|
||
className="flex-1 flex-row items-center border-[3px] border-black bg-white px-[12px] shadow-[4px_4px_0px_#000]"
|
||
style={{ height: 48, transform: [{ skewX: '-6deg' }] }}
|
||
>
|
||
<Ionicons color="black" name="search" size={20} style={{ marginRight: 8 }} />
|
||
<TextInput
|
||
autoFocus
|
||
onChangeText={handleTextChange}
|
||
placeholder="搜索作品 / 用户..."
|
||
placeholderTextColor="#9CA3AF"
|
||
style={{
|
||
flex: 1,
|
||
fontSize: 14,
|
||
fontWeight: 'bold',
|
||
color: 'black',
|
||
}}
|
||
value={searchText}
|
||
/>
|
||
</Block>
|
||
<Block
|
||
className="items-center justify-center border-[3px] border-black bg-accent shadow-[4px_4px_0px_#000]"
|
||
onClick={handleClose}
|
||
style={{ width: 48, height: 48, transform: [{ skewX: '-6deg' }] }}
|
||
>
|
||
<Ionicons color="black" name="close" size={24} style={{ transform: [{ skewX: '6deg' }] }} />
|
||
</Block>
|
||
</Block>
|
||
)
|
||
})
|
||
|
||
type GooActionsProps = {
|
||
gooPoints: number
|
||
onAddGoo: () => void
|
||
onOpenSearch: () => void
|
||
}
|
||
const GooActions = observer<GooActionsProps>(function GooActions({ gooPoints, onAddGoo, onOpenSearch }) {
|
||
const { isPolling } = userBalanceStore
|
||
const isDev = __DEV__
|
||
const isAuthenticated = userStore.isAuthenticated
|
||
return (
|
||
<Block>
|
||
<Block className="mt-[12px] flex-row items-center gap-[8px]">
|
||
{!!isAuthenticated && (
|
||
<Block
|
||
className="flex-row items-center gap-[4px] rounded-full border-[3px] border-white bg-black px-[12px] py-[8px] shadow-[3px_3px_0px_rgba(0,0,0,0.5)]"
|
||
onClick={onAddGoo}
|
||
>
|
||
<Text className="text-[12px] font-black text-accent">{gooPoints}</Text>
|
||
<Ionicons color="#FFE500" name="add" size={12} style={{ fontWeight: 'bold' }} />
|
||
{isDev && isPolling && <Block className="ml-[4px] size-[6px] rounded-full bg-green-500" />}
|
||
</Block>
|
||
)}
|
||
|
||
<Block
|
||
className="size-[48px] items-center justify-center rounded-full border-[3px] border-black bg-white shadow-[4px_4px_0px_#000]"
|
||
onClick={onOpenSearch}
|
||
>
|
||
<Ionicons color="black" name="search" size={22} />
|
||
</Block>
|
||
</Block>
|
||
</Block>
|
||
)
|
||
})
|
||
|
||
type HeroCircleProps = {
|
||
selectedItem: MediaItem | null
|
||
onQuickGen: () => void
|
||
onOpenSearch: () => void
|
||
}
|
||
const HeroCircle = observer<HeroCircleProps>(function HeroCircle({ selectedItem, onQuickGen, onOpenSearch }) {
|
||
const isAuthenticated = userStore.isAuthenticated
|
||
const { balance } = userBalanceStore
|
||
|
||
const Width = 216
|
||
const previewUrl = selectedItem?.webpHighPreviewUrl || selectedItem?.webpPreviewUrl || ''
|
||
|
||
return (
|
||
<Block className="relative z-10 items-center">
|
||
<Block className="relative mx-[12px] flex-row justify-between">
|
||
<Block className="flex-1">
|
||
<Block className="relative z-10 mt-[20px] flex size-[224px] rounded-full border-4 border-black shadow-deep-black">
|
||
<Block className="relative size-full overflow-hidden rounded-full">
|
||
<Img style={{ height: Width, width: Width, borderRadius: Width }} width={256} src={previewUrl} />
|
||
</Block>
|
||
|
||
<Block className="pointer-events-none absolute inset-0 rounded-full border-2 border-black/10" />
|
||
|
||
<Block className="absolute bottom-[24px] left-[8px] z-20 max-w-[100px] bg-black px-[12px] py-[4px] shadow-small-accent">
|
||
<Text className="font-900 -skew-x-12 text-[10px] text-white" ellipsizeMode="tail" numberOfLines={1}>
|
||
{selectedItem?.title}
|
||
</Text>
|
||
</Block>
|
||
|
||
<Block
|
||
className="absolute bottom-[16px] right-0 z-30 -skew-x-6 flex-row items-center gap-[4px] border-[3px] border-black bg-accent px-[16px] py-[6px] shadow-medium-black"
|
||
onClick={onQuickGen}
|
||
>
|
||
<Ionicons color="black" name="flash" size={14} />
|
||
<Text className="font-900 text-[12px] text-black">GET 同款</Text>
|
||
</Block>
|
||
</Block>
|
||
</Block>
|
||
<GooActions
|
||
gooPoints={balance}
|
||
onAddGoo={() => {
|
||
if (!isAuthenticated) {
|
||
router.push('/auth')
|
||
} else {
|
||
router.push('/pointList')
|
||
}
|
||
}}
|
||
onOpenSearch={onOpenSearch}
|
||
/>
|
||
</Block>
|
||
</Block>
|
||
)
|
||
})
|
||
|
||
type FilterSectionProps = {
|
||
activeTab: ActiveTab
|
||
onChange: (t: ActiveTab) => void
|
||
}
|
||
const FilterSection = memo<FilterSectionProps>(function FilterSection({ activeTab, onChange }) {
|
||
const tabs = useMemo(
|
||
() => [
|
||
{ label: '最热', state: '' as const },
|
||
{ label: '最新', state: 'new' as const },
|
||
{ label: '喜欢', state: 'like' as const },
|
||
],
|
||
[],
|
||
)
|
||
|
||
return (
|
||
<Block className="mt-[12px] flex-row items-end justify-between">
|
||
<Block className="flex-row gap-[8px]">
|
||
{tabs.map(({ label, state }) => {
|
||
const isActive = activeTab === state
|
||
return (
|
||
<Block
|
||
className={`border-2 border-black px-[16px] py-[4px] ${isActive ? 'bg-accent' : 'bg-white'}`}
|
||
key={state}
|
||
onClick={() => onChange(state)}
|
||
style={{
|
||
transform: [{ skewX: '-6deg' }],
|
||
}}
|
||
>
|
||
<Text className={`text-[10px] font-[900] ${isActive ? 'text-black' : 'text-gray-500'}`}>{label}</Text>
|
||
</Block>
|
||
)
|
||
})}
|
||
</Block>
|
||
</Block>
|
||
)
|
||
})
|
||
|
||
type GridItemProps = {
|
||
item: MediaItem
|
||
isSelected: boolean
|
||
itemWidth: number
|
||
onSelect: () => void
|
||
isVisible: boolean
|
||
}
|
||
|
||
const GridItem = memo<GridItemProps>(function GridItem({ item, isSelected, itemWidth, onSelect, isVisible }) {
|
||
// console.log('item-------------', item);
|
||
|
||
const previewUrl = item?.webpPreviewUrl || item?.url || ''
|
||
// console.log('previewUrl-------------', isVisible)
|
||
// 使用压缩好了webp预览,不可见情况下不渲染图片
|
||
|
||
return (
|
||
<Block className="relative mb-[12px]" onClick={onSelect}>
|
||
<Block
|
||
className={`relative overflow-hidden border-2 ${isSelected ? 'border-accent' : 'border-black'}`}
|
||
style={{
|
||
transform: [{ skewX: '-6deg' }],
|
||
height: itemWidth,
|
||
width: itemWidth,
|
||
}}
|
||
>
|
||
{isVisible && (
|
||
<Img
|
||
src={previewUrl}
|
||
width={128}
|
||
isCompression={false}
|
||
style={{ height: itemWidth, width: itemWidth }}
|
||
isWebP={false}
|
||
/>
|
||
)}
|
||
|
||
{isSelected && <Block className="absolute inset-0 border-[3px] border-accent" />}
|
||
|
||
<Block
|
||
className={cn(
|
||
'absolute left-[0px] top-[0px] z-[20] border-b-[2px] border-r-[2px] border-black bg-black px-[6px] py-[2px]',
|
||
{
|
||
'bg-accent': isSelected,
|
||
},
|
||
)}
|
||
>
|
||
<Text
|
||
className={cn('font-900 text-[8px] text-white', {
|
||
'text-black': isSelected,
|
||
})}
|
||
>
|
||
{item.title}
|
||
</Text>
|
||
</Block>
|
||
|
||
<Block
|
||
className="absolute inset-x-0 bottom-0 z-20 items-end justify-between p-[4px] pt-[16px]"
|
||
style={{ transform: [{ skewX: '6deg' }] }}
|
||
>
|
||
<LinearGradient
|
||
colors={['rgba(0,0,0,0.8)', 'transparent']}
|
||
end={{ x: 0, y: 0 }}
|
||
start={{ x: 0, y: 1 }}
|
||
style={{
|
||
position: 'absolute',
|
||
left: 0,
|
||
right: 0,
|
||
top: 0,
|
||
bottom: 0,
|
||
}}
|
||
/>
|
||
|
||
<Block className="w-full flex-1 flex-row items-center justify-between px-[4px]">
|
||
<Block className="">
|
||
<Text className="font-900 max-w-[40px] -skew-x-6 text-[7px] text-white" numberOfLines={1}>
|
||
{item.authorName || '未知作者'}
|
||
</Text>
|
||
</Block>
|
||
<Block className="-skew-x-6 flex-row items-center gap-[2px]">
|
||
<Ionicons color="#FF0000" name="heart" size={10} />
|
||
<Text className="font-900 text-[8px] text-white">{item.likeCount}</Text>
|
||
</Block>
|
||
</Block>
|
||
</Block>
|
||
</Block>
|
||
</Block>
|
||
)
|
||
})
|
||
|
||
export default Index
|