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

731 lines
24 KiB
TypeScript

import { Ionicons } from '@expo/vector-icons'
import type { GetUserFavoritesResponse } from '@repo/sdk'
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, 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 { 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 { usePublicTemplates } from '@/hooks/data/use-public-templates'
import { screenWidth } from '@/utils'
import { cn } from '@/utils/cn'
const BACKGROUND_VIDEOS = [
'https://cdn.roasmax.cn/material/b46f380532e14cf58dd350dbacc7c34a.mp4',
'https://cdn.roasmax.cn/material/992e6c5d940c42feb71c27e556b754c0.mp4',
'https://cdn.roasmax.cn/material/e4947477843f4067be7c37569a33d17b.mp4',
]
const CATEGORY_ID = `cat_iw83x5bg54fmjgvciju`
type MediaItem = {
id: string
type: 'image' | 'video'
url: string
poster?: string
likeCount: number
}
type ActiveTab = 'gen' | '' | 'new' | 'like'
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)
}
}
}, [])
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
placeholder="搜索作品 / 用户..."
placeholderTextColor="#9CA3AF"
value={searchText}
style={{
flex: 1,
fontSize: 14,
fontWeight: 'bold',
color: 'black',
}}
onChangeText={handleTextChange}
/>
</Block>
<Block
className="items-center justify-center border-[3px] border-black bg-accent shadow-[4px_4px_0px_#000]"
style={{ width: 48, height: 48, transform: [{ skewX: '-6deg' }] }}
onClick={onClose}
>
<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 italic 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">
<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 mt-[12px] items-center justify-between">
<Block className="relative 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] italic 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] italic 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
key={state}
className={`border-2 border-black px-[16px] py-[4px] ${isActive ? 'bg-accent' : 'bg-white'}`}
style={{
transform: [{ skewX: '-6deg' }],
}}
onClick={() => onChange(state)}
>
<Text className={`text-[10px] font-[900] italic ${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,
}}
>
{/* {item.type === 'video' ? (
<VideoBox url={item.url} style={{ height: itemWidth, width: itemWidth }} />
) : (
<Img src={item.poster} className="h-full w-full" />
)} */}
{/* <Img src={item.poster} className="h-full w-full" /> */}
<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] italic 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] italic text-white">{item.likeCount}</Text>
</Block>
</Block>
</Block>
</Block>
</Block>
)
})
/** =========================
* Entry page
* ========================= */
export default function Sync() {
const { user, isAuthenticated } = useAuth()
const { balance, load: loadBalance } = useUserBalance()
const { data: templatesData, loading: templatesLoading, execute: loadTemplates } = useTemplates()
const {
data: publicTemplatesData,
loading: publicTemplatesLoading,
execute: loadPublicTemplates,
} = usePublicTemplates()
const { data: favoritesData, loading: favoritesLoading, execute: loadFavorites } = useFavoriteTemplates()
const { runTemplate } = useTemplateActions()
const [searchText, setSearchText] = useState('')
const [isSearchOpen, setIsSearchOpen] = useState(false)
const [activeTab, setActiveTab] = useState<ActiveTab>('')
const [page, setPage] = useState(1)
const [hasMore, setHasMore] = useState(true)
const [refreshing, setRefreshing] = useState(false)
const [loadingMore, setLoadingMore] = useState(false)
const [allItems, setAllItems] = useState<MediaItem[]>([])
const isLoadingRef = useRef(false)
const onAddGoo = useCallback(() => {
router.push('/pointList')
}, [])
const transformTemplateToMediaItem = useCallback((template: any): MediaItem => {
const isVideo = template.previewUrl?.includes('.mp4')
return {
id: template.id,
type: (isVideo ? 'video' : 'image') as 'video' | 'image',
url: isVideo ? template.previewUrl : template.coverImageUrl || template.previewUrl,
poster: isVideo ? template.coverImageUrl : undefined,
likeCount: template.likeCount || 0,
}
}, [])
const galleryItems = useMemo<MediaItem[]>(() => allItems, [allItems])
const [selectedItem, setSelectedItem] = useState<MediaItem | null>(null)
useEffect(() => {
if (galleryItems.length > 0 && !selectedItem) {
setSelectedItem(galleryItems[0])
}
}, [galleryItems, selectedItem])
useEffect(() => {
if (isAuthenticated && user?.id) {
loadBalance()
}
}, [isAuthenticated, user?.id])
// Tab 切换时重置并加载数据
useEffect(() => {
setPage(1)
setHasMore(true)
setAllItems([])
const initialLoad = async () => {
if (isLoadingRef.current) return
isLoadingRef.current = true
setRefreshing(true)
try {
let newItems: MediaItem[] = []
const limit = 20
if (activeTab === 'like') {
if (!isAuthenticated) {
setHasMore(false)
return
}
const { data, error } = await loadFavorites({ limit, page: 1 })
if (error || !data?.favorites) {
setHasMore(false)
return
}
newItems = data.favorites
.filter((fav: GetUserFavoritesResponse['favorites'][number]) => fav.template && !fav.template.isDeleted)
.map((fav: GetUserFavoritesResponse['favorites'][number]) => transformTemplateToMediaItem(fav.template!))
} else if (activeTab === 'new') {
if (isAuthenticated) {
const { data, error } = await loadTemplates({
limit,
page: 1,
sortBy: 'createdAt',
sortOrder: 'desc',
})
if (error || !data?.templates) {
setHasMore(false)
return
}
newItems = data.templates.map(transformTemplateToMediaItem)
} else {
const { data, error } = await loadPublicTemplates({
limit,
sortBy: 'createdAt',
sortOrder: 'desc',
})
if (error || !data?.templates) {
setHasMore(false)
return
}
newItems = data.templates.map(transformTemplateToMediaItem)
setHasMore(false)
}
} else {
if (isAuthenticated) {
const { data, error } = await loadTemplates({
limit,
page: 1,
sortBy: 'likeCount',
sortOrder: 'desc',
})
if (error || !data?.templates) {
setHasMore(false)
return
}
newItems = data.templates.map(transformTemplateToMediaItem)
} else {
const { data, error } = await loadPublicTemplates({
limit,
sortBy: 'likeCount',
sortOrder: 'desc',
})
if (error || !data?.templates) {
setHasMore(false)
return
}
newItems = data.templates.map(transformTemplateToMediaItem)
setHasMore(false)
}
}
if (newItems.length < limit) {
setHasMore(false)
}
setAllItems(newItems)
} catch (error) {
console.error('Failed to load data:', error)
setHasMore(false)
} finally {
isLoadingRef.current = false
setRefreshing(false)
}
}
initialLoad()
}, [activeTab, isAuthenticated])
const loadData = useCallback(
async (pageNum: number, isRefresh: boolean = false) => {
if (!isRefresh && !hasMore) return
if (loadingMore || refreshing || isLoadingRef.current) return
isLoadingRef.current = true
if (isRefresh) {
setRefreshing(true)
} else {
setLoadingMore(true)
}
try {
let newItems: MediaItem[] = []
const limit = 21
if (activeTab === 'like') {
if (!isAuthenticated) {
setHasMore(false)
return
}
const { data, error } = await loadFavorites({ limit, page: pageNum })
if (error || !data?.favorites) {
setHasMore(false)
return
}
newItems = data.favorites
.filter((fav: GetUserFavoritesResponse['favorites'][number]) => fav.template && !fav.template.isDeleted)
.map((fav: GetUserFavoritesResponse['favorites'][number]) => transformTemplateToMediaItem(fav.template!))
} else if (activeTab === 'new') {
if (!isAuthenticated) {
setHasMore(false)
return
}
const { data, error } = await loadTemplates({
limit,
page: pageNum,
sortBy: 'createdAt',
sortOrder: 'desc',
})
if (error || !data?.templates) {
setHasMore(false)
return
}
newItems = data.templates.map(transformTemplateToMediaItem)
} else {
if (!isAuthenticated) {
setHasMore(false)
return
}
const { data, error } = await loadTemplates({
limit,
page: pageNum,
sortBy: 'likeCount',
sortOrder: 'desc',
})
if (error || !data?.templates) {
setHasMore(false)
return
}
newItems = data.templates.map(transformTemplateToMediaItem)
}
if (newItems.length < limit) {
setHasMore(false)
}
if (isRefresh) {
setAllItems(newItems)
} else {
setAllItems((prev) => [...prev, ...newItems])
}
} catch (error) {
console.error('Failed to load data:', error)
setHasMore(false)
} finally {
isLoadingRef.current = false
setRefreshing(false)
setLoadingMore(false)
}
},
[
activeTab,
isAuthenticated,
hasMore,
loadingMore,
refreshing,
loadTemplates,
loadPublicTemplates,
loadFavorites,
transformTemplateToMediaItem,
],
)
const handleRefresh = useCallback(() => {
setPage(1)
setHasMore(true)
loadData(1, true)
}, [loadData])
const handleLoadMore = useCallback(() => {
if (!hasMore || loadingMore || refreshing || isLoadingRef.current) return
const nextPage = page + 1
setPage(nextPage)
loadData(nextPage, false)
}, [page, hasMore, loadingMore, refreshing, loadData])
const handleQuickGen = useCallback(async () => {
if (!isAuthenticated) {
Toast.show({ title: '请先登录' })
return
}
if (!selectedItem) {
Toast.show({ title: '请先选择一个模板' })
return
}
const cost = 2
if (balance < cost) {
Toast.show({ title: '余额不足,请充值' })
return
}
Toast.showModal(
<ConfirmModal
content={
<Text className="text-[14px] font-bold leading-relaxed text-gray-800">
<Text className="mx-[4px] text-[20px] font-black italic text-[#e61e25]">{cost} Goo</Text>{' '}
</Text>
}
onCancel={() => Toast.hideModal()}
onConfirm={async () => {
Toast.hideModal()
// 要传 扣费返回的凭证
const { generationId, error } = await runTemplate({
templateId: selectedItem.id,
data: {},
originalUrl: selectedItem.url,
})
if (error || !generationId) {
Toast.show({ title: error?.message || '生成失败' })
return
}
Toast.show({ title: '生成成功!' })
if (user?.id) loadBalance()
}}
/>,
{},
)
}, [isAuthenticated, balance, selectedItem, runTemplate, user?.id, loadBalance])
const openSearch = useCallback(() => setIsSearchOpen(true), [])
const closeSearch = useCallback(() => {
setIsSearchOpen(false)
setSearchText('')
}, [])
const itemWidth = Math.floor((screenWidth - 24 - 12 * 2) / 3)
const renderGridItem = useCallback(
({ item }: { item: MediaItem }) => {
const isSelected = selectedItem?.id === item.id
return (
<GridItem isSelected={isSelected} item={item} itemWidth={itemWidth} onSelect={() => setSelectedItem(item)} />
)
},
[selectedItem?.id],
)
const renderListFooter = useCallback(() => {
if (!loadingMore) return null
return (
<Block className="items-center py-[20px]">
<ActivityIndicator color="#FFE500" size="small" />
</Block>
)
}, [loadingMore])
const renderListEmpty = useCallback(() => {
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] italic text-black" style={{ transform: [{ skewX: '6deg' }] }}>
</Text>
</Block>
</Block>
)
}
return (
<Block className="mt-[12px] items-center justify-center py-[40px]">
<Text className="text-[14px] font-bold text-white/60"></Text>
</Block>
)
}, [activeTab, isAuthenticated])
const handleSearch = useCallback((text: string) => {
console.log('Search for:', text)
// Implement your search logic here
}, [])
return (
<Block className="relative flex-1 flex-col overflow-visible bg-black px-[12px]">
<BannerSection />
<HeroCircle
rightSlot={<GooActions gooPoints={balance} onAddGoo={onAddGoo} onOpenSearch={openSearch} />}
selectedItem={selectedItem}
onQuickGen={handleQuickGen}
/>
<FilterSection activeTab={activeTab} onChange={setActiveTab} />
<FlashList
contentContainerStyle={{ marginTop: 12, paddingBottom: 200 }}
data={galleryItems}
drawDistance={1200}
// @ts-ignore
estimatedItemSize={itemWidth}
extraData={selectedItem?.id}
keyExtractor={(item) => item.id}
ListEmptyComponent={renderListEmpty}
ListFooterComponent={renderListFooter}
numColumns={3}
renderItem={renderGridItem}
showsVerticalScrollIndicator={false}
refreshControl={
<RefreshControl colors={['#FFE500']} refreshing={refreshing} tintColor="#FFE500" onRefresh={handleRefresh} />
}
onEndReached={handleLoadMore}
onEndReachedThreshold={0.5}
/>
<SearchOverlay isOpen={isSearchOpen} onClose={() => setIsSearchOpen(false)} onSearch={handleSearch} />
</Block>
)
}