703 lines
24 KiB
TypeScript
703 lines
24 KiB
TypeScript
import React, { memo, useMemo, useState, useEffect, useCallback, useRef } from 'react'
|
|
import { Block, ConfirmModal, Text, Toast, VideoBox } from '@share/components'
|
|
import Img from '@share/components/Img'
|
|
import { Ionicons } from '@expo/vector-icons'
|
|
import { Dimensions, FlatList, TextInput, RefreshControl, ActivityIndicator } from 'react-native'
|
|
import { LinearGradient } from 'expo-linear-gradient'
|
|
import Animated, { Easing, useAnimatedStyle, useSharedValue, withRepeat, withTiming } from 'react-native-reanimated'
|
|
|
|
import { router, useRouter } from 'expo-router'
|
|
import { IOS_UNIVERSAL_LINK } from '@/app.constants'
|
|
import { FlashList } from '@shopify/flash-list'
|
|
import { screenHeight, screenWidth } from '@/utils'
|
|
import { cn } from '@/utils/cn'
|
|
import { useAuth } from '@/hooks/core/use-auth'
|
|
import { useUserBalance } from '@/hooks/core/use-user-balance'
|
|
import { useTemplates } from '@/hooks/data/use-templates'
|
|
import { usePublicTemplates } from '@/hooks/data/use-public-templates'
|
|
import { useFavoriteTemplates } from '@/hooks/data/use-favorite-templates'
|
|
import type { GetUserFavoritesResponse } from '@repo/sdk'
|
|
import { useAigcTask } from '@/hooks/actions/use-aigc-task'
|
|
import { useTemplateInteraction } from '@/hooks/actions/use-template-interaction'
|
|
|
|
const BACKGROUND_VIDEOS = [
|
|
'https://cdn.roasmax.cn/material/b46f380532e14cf58dd350dbacc7c34a.mp4',
|
|
'https://cdn.roasmax.cn/material/992e6c5d940c42feb71c27e556b754c0.mp4',
|
|
'https://cdn.roasmax.cn/material/e4947477843f4067be7c37569a33d17b.mp4',
|
|
]
|
|
|
|
type MediaItem = {
|
|
id: string
|
|
type: 'image' | 'video'
|
|
url: string
|
|
poster?: string
|
|
likeCount: number
|
|
}
|
|
type ActiveTab = 'gen' | '' | 'new' | 'like'
|
|
|
|
/** =========================
|
|
* Small memo components
|
|
* ========================= */
|
|
|
|
type BannerProps = { bgVideo: string }
|
|
const Banner = memo<BannerProps>(function Banner({ bgVideo }) {
|
|
return (
|
|
<Block className="absolute inset-0 bottom-0 left-0 right-0 top-0 z-[0] overflow-hidden">
|
|
{/* <VideoBox url={bgVideo} style={{ width: screenWidth, height: screenHeight }} /> */}
|
|
<Img src={bgVideo} style={{ width: screenWidth, height: screenHeight }} />
|
|
</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)
|
|
}
|
|
}
|
|
}, [])
|
|
|
|
if (!isOpen) return null
|
|
return (
|
|
<Block className="absolute left-0 right-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 name="search" size={20} color="black" style={{ marginRight: 8 }} />
|
|
<TextInput
|
|
autoFocus
|
|
value={searchText}
|
|
onChangeText={handleTextChange}
|
|
placeholder="搜索作品 / 用户..."
|
|
placeholderTextColor="#9CA3AF"
|
|
style={{
|
|
flex: 1,
|
|
fontSize: 14,
|
|
fontWeight: 'bold',
|
|
color: 'black',
|
|
}}
|
|
/>
|
|
</Block>
|
|
<Block
|
|
onClick={onClose}
|
|
className="items-center justify-center border-[3px] border-black bg-[#FFE500] shadow-[4px_4px_0px_#000]"
|
|
style={{ width: 48, height: 48, transform: [{ skewX: '-6deg' }] }}
|
|
>
|
|
<Ionicons name="close" size={24} color="black" style={{ transform: [{ skewX: '6deg' }] }} />
|
|
</Block>
|
|
|
|
<Block
|
|
className="items-center justify-center border-[3px] border-black bg-[#FFE500] shadow-[4px_4px_0px_#000]"
|
|
style={{ width: 48, height: 48, transform: [{ skewX: '-6deg' }] }}
|
|
>
|
|
<Text>登出</Text>
|
|
</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
|
|
onClick={onAddGoo}
|
|
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)]"
|
|
>
|
|
<Text className="text-[12px] font-black italic text-[#FFE500]">{gooPoints} Goo</Text>
|
|
<Ionicons name="add" size={12} color="#FFE500" style={{ fontWeight: 'bold' }} />
|
|
</Block>
|
|
|
|
<Block
|
|
onClick={onOpenSearch}
|
|
className="h-[48px] w-[48px] items-center justify-center rounded-full border-[3px] border-black bg-white shadow-[4px_4px_0px_#000]"
|
|
>
|
|
<Ionicons name="search" size={22} color="black" />
|
|
</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] h-[224px] w-[224px] items-center justify-center rounded-full border-[4px] 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] h-[224px] w-[224px] rounded-full border-[4px] border-black bg-white shadow-deep-black">
|
|
<Block className="relative h-full w-full overflow-hidden rounded-full bg-black">
|
|
{selectedItem.type === 'video' ? (
|
|
<VideoBox url={selectedItem.url} style={{ height: 216, width: 216, borderRadius: 108 }} />
|
|
) : (
|
|
<Img src={selectedItem.url} className="h-full w-full" />
|
|
)}
|
|
|
|
<Block className="absolute inset-[0px]">
|
|
<LinearGradient
|
|
colors={['rgba(255,255,255,0.3)', 'transparent']}
|
|
start={{ x: 0, y: 0 }}
|
|
end={{ x: 1, y: 1 }}
|
|
style={{ position: 'absolute', left: 0, right: 0, top: 0, bottom: 0, borderRadius: 9999 }}
|
|
/>
|
|
</Block>
|
|
</Block>
|
|
|
|
<Block className="pointer-events-none absolute inset-[0px] rounded-full border-[2px] 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-[-12deg] text-[10px] italic text-white" numberOfLines={1} ellipsizeMode="tail">
|
|
{selectedItem?.id}
|
|
</Text>
|
|
</Block>
|
|
|
|
<Block
|
|
onClick={onQuickGen}
|
|
className="absolute bottom-[16px] right-[0px] z-[30] skew-x-[-6deg] flex-row items-center gap-[4px] border-[3px] border-black bg-accent px-[16px] py-[6px] shadow-medium-black"
|
|
>
|
|
<Ionicons name="flash" size={14} color="black" />
|
|
<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}
|
|
onClick={() => onChange(state)}
|
|
className={`border-[2px] border-black px-[16px] py-[4px] ${isActive ? 'bg-accent' : 'bg-white'}`}
|
|
style={{
|
|
transform: [{ skewX: '-6deg' }],
|
|
}}
|
|
>
|
|
<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 onClick={onSelect} className="relative mb-[12px]">
|
|
<Block
|
|
className={`relative overflow-hidden border-[2px] ${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 url={item.url} style={{ height: itemWidth, width: itemWidth }} />
|
|
|
|
{isSelected && <Block className="absolute inset-[0px] 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 bottom-[0px] left-[0px] right-[0px] z-[20] items-end justify-between p-[4px] pt-[16px]"
|
|
style={{ transform: [{ skewX: '6deg' }] }}
|
|
>
|
|
<LinearGradient
|
|
colors={['rgba(0,0,0,0.8)', 'transparent']}
|
|
start={{ x: 0, y: 1 }}
|
|
end={{ x: 0, y: 0 }}
|
|
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-[-6deg] text-[7px] italic text-white" numberOfLines={1}>
|
|
{item.id}
|
|
</Text>
|
|
</Block>
|
|
<Block className="skew-x-[-6deg] flex-row items-center gap-[2px]">
|
|
<Ionicons name="heart" size={10} color="#FF0000" />
|
|
<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, loading: balanceLoading, 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 { submitTask, startPolling } = useAigcTask()
|
|
|
|
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])
|
|
|
|
const [bgVideo] = useState(() => BACKGROUND_VIDEOS[Math.floor(Math.random() * BACKGROUND_VIDEOS.length)])
|
|
|
|
const outerRotate = useSharedValue(0)
|
|
|
|
useEffect(() => {
|
|
if (isAuthenticated && user?.id) {
|
|
loadBalance(user.id)
|
|
}
|
|
}, [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 = 20
|
|
|
|
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])
|
|
|
|
useEffect(() => {
|
|
outerRotate.value = withRepeat(withTiming(360, { duration: 12000, easing: Easing.linear }), -1, false)
|
|
}, [])
|
|
|
|
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>
|
|
}
|
|
onConfirm={async () => {
|
|
Toast.hideModal()
|
|
|
|
const { taskId, error } = await submitTask({
|
|
model_name: 'default',
|
|
prompt: `生成与 ${selectedItem.id} 相似的内容`,
|
|
img_url: selectedItem.url,
|
|
})
|
|
|
|
if (error) {
|
|
Toast.show({ title: `提交失败: ${error.message}` })
|
|
return
|
|
}
|
|
|
|
if (taskId) {
|
|
Toast.show({ title: '任务提交成功,正在生成中...' })
|
|
startPolling(
|
|
taskId,
|
|
(result) => {
|
|
Toast.show({ title: '生成成功!' })
|
|
if (user?.id) loadBalance(user.id)
|
|
},
|
|
(error) => {
|
|
Toast.show({ title: `生成失败: ${error.message}` })
|
|
},
|
|
)
|
|
}
|
|
}}
|
|
onCancel={() => Toast.hideModal()}
|
|
/>,
|
|
{},
|
|
)
|
|
}, [isAuthenticated, balance, selectedItem, submitTask, startPolling, user?.id, loadBalance])
|
|
|
|
const openSearch = useCallback(() => setIsSearchOpen(true), [])
|
|
const closeSearch = useCallback(() => {
|
|
setIsSearchOpen(false)
|
|
setSearchText('')
|
|
}, [])
|
|
|
|
// keep for parity with your original file (even if not actively used yet)
|
|
const outerAnimatedStyle = useAnimatedStyle(() => ({
|
|
transform: [{ rotate: `${outerRotate.value}deg` }],
|
|
}))
|
|
|
|
const { width: winW } = Dimensions.get('window')
|
|
const itemWidth = useMemo(() => Math.floor((winW - 24 - 12 * 2) / 3), [winW])
|
|
|
|
const renderGridItem = useCallback(
|
|
({ item }: { item: MediaItem }) => (
|
|
<GridItem item={item} isSelected={selectedItem?.id === item.id} itemWidth={itemWidth} onSelect={() => setSelectedItem(item)} />
|
|
),
|
|
[selectedItem, itemWidth],
|
|
)
|
|
|
|
const renderListFooter = useCallback(() => {
|
|
if (!loadingMore) return null
|
|
return (
|
|
<Block className="items-center py-[20px]">
|
|
<ActivityIndicator size="small" color="#FFE500" />
|
|
</Block>
|
|
)
|
|
}, [loadingMore])
|
|
|
|
const renderListEmpty = useCallback(() => {
|
|
if (activeTab === 'like' && !isAuthenticated) {
|
|
return (
|
|
<Block className="mt-[40px] items-center justify-center gap-[16px] py-[60px]">
|
|
<Block className="h-[80px] w-[80px] items-center justify-center rounded-full border-[4px] border-white/20 bg-white/10">
|
|
<Ionicons name="heart-outline" size={40} color="rgba(255,255,255,0.6)" />
|
|
</Block>
|
|
<Text className="text-[16px] font-bold text-white/80">查看您收藏的模板</Text>
|
|
<Text className="text-[12px] text-white/50">登录后即可查看和管理收藏</Text>
|
|
<Block
|
|
onClick={() => router.push('/auth')}
|
|
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' }] }}
|
|
>
|
|
<Ionicons name="flash" size={16} color="black" 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]">
|
|
<Banner bgVideo={bgVideo} />
|
|
|
|
<HeroCircle
|
|
selectedItem={selectedItem}
|
|
onQuickGen={handleQuickGen}
|
|
rightSlot={<GooActions gooPoints={balance} onAddGoo={onAddGoo} onOpenSearch={openSearch} />}
|
|
/>
|
|
|
|
<FilterSection activeTab={activeTab} onChange={setActiveTab} />
|
|
|
|
<FlashList
|
|
data={galleryItems}
|
|
renderItem={renderGridItem}
|
|
keyExtractor={(item) => item.id}
|
|
numColumns={3}
|
|
contentContainerStyle={{ marginTop: 12, paddingBottom: 200 }}
|
|
showsVerticalScrollIndicator={false}
|
|
refreshControl={<RefreshControl refreshing={refreshing} onRefresh={handleRefresh} tintColor="#FFE500" colors={['#FFE500']} />}
|
|
onEndReached={handleLoadMore}
|
|
onEndReachedThreshold={0.5}
|
|
ListFooterComponent={renderListFooter}
|
|
ListEmptyComponent={renderListEmpty}
|
|
/>
|
|
|
|
<SearchOverlay isOpen={isSearchOpen} onClose={() => setIsSearchOpen(false)} onSearch={handleSearch} />
|
|
</Block>
|
|
)
|
|
}
|