expo-duooomi-app/app/(tabs)/index.tsx

618 lines
19 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 { Ionicons } from '@expo/vector-icons'
import { Block, ConfirmModal, Text, Toast, VideoBox } from '@share/components'
import Img from '@share/components/Img'
import { FlashList } from '@shopify/flash-list'
import { LinearGradient } from 'expo-linear-gradient'
import { router } from 'expo-router'
import React, { memo, 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 { useAuth } from '@/hooks/core/use-auth'
import { useUserBalance } from '@/hooks/core/use-user-balance'
import { useTemplates } from '@/hooks/data'
import { useFavoriteTemplates } from '@/hooks/data/use-favorite-templates'
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 = 21
type MediaItem = {
id: string
type: 'image' | 'video'
url: string
poster?: string
likeCount: number
price: number
}
type ActiveTab = 'gen' | '' | 'new' | 'like'
/** =========================
* Entry page
* ========================= */
export default function Sync() {
const { user, isAuthenticated, signOut } = useAuth()
const { balance, load: loadBalance } = useUserBalance()
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)
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
isDeleted?: boolean
}
const transformTemplateToMediaItem = (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 || '',
poster: isVideo ? template.coverImageUrl : undefined,
likeCount: template.likeCount || 0,
price: template.price || 2,
}
}
/** ================= 统一请求函数 ================= */
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') {
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)
}
setAllItems((prev) => (mode === 'loadMore' ? [...prev, ...newItems] : newItems))
} catch (e) {
console.error('fetchList error:', e)
setHasMore(false)
} finally {
loadingRef.current = false
// 标记首次(或第一页)加载已完成,避免 FlashList 首次渲染触发 onEndReached
const currentPage = queryRef.current.page
if (currentPage === 1) {
firstLoadDoneRef.current = true
}
setRefreshing(false)
setLoadingMore(false)
}
}
/** ================= tab 切换(抽象后的重点) ================= */
const changeTab = (tab: ActiveTab) => {
queryRef.current = {
...queryRef.current,
tab,
page: 1,
}
setHasMore(true)
firstLoadDoneRef.current = false
fetchList('init')
}
/** ================= 搜索 ================= */
const handleSearch = (text: string) => {
queryRef.current = {
...queryRef.current,
search: text,
page: 1,
}
setHasMore(true)
firstLoadDoneRef.current = false
fetchList('refresh')
}
/** ================= 刷新 / 加载更多 ================= */
const handleRefresh = () => {
queryRef.current.page = 1
setHasMore(true)
firstLoadDoneRef.current = false
fetchList('refresh')
}
const handleLoadMore = () => {
// 防止重复请求或首次渲染时误触发
if (loadingRef.current || !hasMore || refreshing || loadingMore) return
if (!firstLoadDoneRef.current) return
// if (allItems.length === 0) return // 首次加载未完成时不触发加载更多
queryRef.current.page += 1
fetchList('loadMore')
console.log('handleLoadMore-------------')
}
/** ================= selectedItem 修正 ================= */
useEffect(() => {
if (!allItems.length) {
setSelectedItem(null)
return
}
if (!allItems.find((i) => i.id === selectedItem?.id)) {
setSelectedItem(allItems[0])
}
}, [allItems, selectedItem?.id])
/** ================= balance ================= */
useEffect(() => {
loadBalance()
fetchList('init')
}, [])
/** ================= 快速生成 ================= */
const handleQuickGen = async () => {
if (!isAuthenticated) {
Toast.show({ title: '请先登录' })
return
}
if (!selectedItem) {
Toast.show({ title: '请先选择一个模板' })
return
}
if (balance < selectedItem.price) {
Toast.show({ title: '余额不足,请充值' })
return
}
Toast.showModal(
<ConfirmModal
content={
<Text className="text-[14px] font-bold">
<Text className="mx-[4px] text-[20px] text-[#e61e25]">{selectedItem.price} Goo</Text>
</Text>
}
onCancel={Toast.hideModal}
onConfirm={async () => {
Toast.hideModal()
const { generationId, error } = await runTemplate({
templateId: selectedItem.id,
data: {},
originalUrl: selectedItem.url,
})
if (generationId && user?.id) loadBalance()
else Toast.show({ title: error?.message || '生成失败' })
}}
/>,
)
}
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>
)
}
}
/** ================= UI ================= */
return (
<Block className="flex-1 bg-black px-[12px]">
<BannerSection />
<HeroCircle
onQuickGen={handleQuickGen}
rightSlot={
<GooActions
gooPoints={balance}
onAddGoo={() => router.push('/pointList')}
onOpenSearch={() => setIsSearchOpen(true)}
/>
}
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}
drawDistance={1200}
onEndReachedThreshold={0.3}
refreshControl={<RefreshControl colors={['#FFE500']} refreshing={refreshing} onRefresh={handleRefresh} />}
renderItem={({ item }) => (
<GridItem
isSelected={selectedItem?.id === item.id}
item={item}
itemWidth={ITEM_WIDTH}
onSelect={() => setSelectedItem(item)}
/>
)}
ListEmptyComponent={renderListEmpty}
showsVerticalScrollIndicator={false}
data={allItems}
// @ts-ignore
estimatedItemSize={ITEM_WIDTH}
/>
<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 = (text: string) => {
setSearchText(text)
onChange && onChange(text)
if (timerRef.current) {
clearTimeout(timerRef.current)
}
timerRef.current = setTimeout(() => {
onSearch(text)
}, 2000)
}
useEffect(() => {
// 删除定时器
return () => {
if (timerRef.current) {
clearTimeout(timerRef.current)
}
}
}, [])
const handleClose = () => {
setSearchText('')
onSearch('')
onClose && onClose()
}
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 = memo<GooActionsProps>(function GooActions({ gooPoints, onAddGoo, onOpenSearch }) {
return (
<Block>
<Block className="mt-[12px] flex-row items-center gap-[8px]">
<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} Goo</Text>
<Ionicons color="#FFE500" name="add" size={12} style={{ fontWeight: 'bold' }} />
</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
rightSlot: React.ReactNode
}
const HeroCircle = memo<HeroCircleProps>(function HeroCircle({ selectedItem, onQuickGen, rightSlot }) {
if (!selectedItem) {
return (
<Block className="relative z-10 mt-[12px] items-center justify-between px-[12px]">
<Block className="relative flex-row justify-between">
<Block className="relative z-10 mt-[20px] size-[224px] items-center justify-center rounded-full border-4 border-black bg-white/20 shadow-deep-black">
<Text className="text-[14px] font-bold text-white/60">...</Text>
</Block>
{rightSlot}
</Block>
</Block>
)
}
return (
<Block className="relative z-10 items-center justify-between">
<Block className="relative mx-[12px] flex-row justify-between">
<Block className="relative z-10 mt-[20px] size-[224px] rounded-full border-4 border-black bg-white shadow-deep-black">
<Block className="relative size-full overflow-hidden rounded-full bg-black">
{selectedItem.type === 'video' ? (
<VideoBox style={{ height: 216, width: 216, borderRadius: 108 }} url={selectedItem.url} />
) : (
<Img className="size-full" src={selectedItem.url} />
)}
<Block className="absolute inset-0">
<LinearGradient
colors={['rgba(255,255,255,0.3)', 'transparent']}
end={{ x: 1, y: 1 }}
start={{ x: 0, y: 0 }}
style={{
position: 'absolute',
left: 0,
right: 0,
top: 0,
bottom: 0,
borderRadius: 9999,
}}
/>
</Block>
</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?.id}
</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>
{rightSlot}
</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
}
const GridItem = memo<GridItemProps>(function GridItem({ item, isSelected, itemWidth, onSelect }) {
// console.log('item-------------', item);
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,
}}
>
<VideoBox style={{ height: itemWidth, width: itemWidth }} url={item.url} />
{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.id}
</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.id}
</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>
)
})