369 lines
16 KiB
TypeScript
369 lines
16 KiB
TypeScript
import { Ionicons } from '@expo/vector-icons'
|
|
import { Block, ConfirmModal, Text, Toast, VideoBox } from '@share/components'
|
|
import Img from '@share/components/Img'
|
|
import { LinearGradient } from 'expo-linear-gradient'
|
|
import { router } from 'expo-router'
|
|
import React, { useCallback, useEffect, useMemo, useState } from 'react'
|
|
import { Dimensions, ScrollView } from 'react-native'
|
|
|
|
import { useTemplateActions } from '@/hooks/actions/use-template-actions'
|
|
import { useAuth } from '@/hooks/core/use-auth'
|
|
import { useUserBalance } from '@/hooks/core/use-user-balance'
|
|
import { type TemplateGeneration, useTemplateGenerations } from '@/hooks/data/use-template-generations'
|
|
|
|
const BACKGROUND_VIDEOS = [
|
|
'https://cdn.roasmax.cn/material/b46f380532e14cf58dd350dbacc7c34a.mp4',
|
|
'https://cdn.roasmax.cn/material/992e6c5d940c42feb71c27e556b754c0.mp4',
|
|
'https://cdn.roasmax.cn/material/e4947477843f4067be7c37569a33d17b.mp4',
|
|
]
|
|
|
|
function isVideoUrl(url: string) {
|
|
return url.endsWith('.mp4')
|
|
}
|
|
|
|
export default function My() {
|
|
const { user, isLoading: authLoading, signOut } = useAuth()
|
|
const { data: generationsData, loading: generationsLoading, load: loadGenerations } = useTemplateGenerations()
|
|
const { balance, load: loadBalance } = useUserBalance()
|
|
const { batchDeleteGenerations, loading: deleteLoading } = useTemplateActions()
|
|
|
|
const [activeFilter, setActiveFilter] = useState<'my_gen' | 'my_album'>('my_gen')
|
|
const [bgVideo] = useState(() => BACKGROUND_VIDEOS[Math.floor(Math.random() * BACKGROUND_VIDEOS.length)])
|
|
const [isSelectionMode, setIsSelectionMode] = useState(false)
|
|
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set())
|
|
|
|
useEffect(() => {
|
|
if (user?.id) {
|
|
loadGenerations({ userId: user.id })
|
|
loadBalance(user.id)
|
|
}
|
|
}, [user?.id])
|
|
|
|
const generations = useMemo(() => generationsData?.data || [], [generationsData])
|
|
const filteredPosts =
|
|
activeFilter === 'my_gen' ? generations : generations.filter((g: TemplateGeneration) => g.status === 'completed')
|
|
|
|
const toggleSelectionMode = useCallback(() => {
|
|
setIsSelectionMode((v) => !v)
|
|
setSelectedIds(new Set())
|
|
}, [])
|
|
|
|
const handleItemClick = useCallback(
|
|
(generation: any) => {
|
|
if (isSelectionMode) {
|
|
setSelectedIds((prev) => {
|
|
const next = new Set(prev)
|
|
if (next.has(generation.id)) next.delete(generation.id)
|
|
else next.add(generation.id)
|
|
return next
|
|
})
|
|
}
|
|
},
|
|
[isSelectionMode],
|
|
)
|
|
|
|
const handleDelete = useCallback(async () => {
|
|
if (selectedIds.size === 0) return
|
|
|
|
const { success } = await batchDeleteGenerations(Array.from(selectedIds))
|
|
|
|
if (success && user?.id) {
|
|
await loadGenerations({ userId: user.id })
|
|
setSelectedIds(new Set())
|
|
setIsSelectionMode(false)
|
|
}
|
|
}, [selectedIds, batchDeleteGenerations, user?.id, loadGenerations])
|
|
|
|
const selectAll = useCallback(() => {
|
|
if (selectedIds.size === filteredPosts.length) setSelectedIds(new Set())
|
|
else setSelectedIds(new Set(filteredPosts.map((p: TemplateGeneration) => p.id)))
|
|
}, [selectedIds.size, filteredPosts])
|
|
|
|
const handleLogout = useCallback(() => {
|
|
Toast.showModal(
|
|
<ConfirmModal
|
|
title="确认退出登录?"
|
|
badge="LOGOUT"
|
|
content="退出登录后需要重新登录才能使用完整功能"
|
|
confirmText="确认退出"
|
|
cancelText="取消"
|
|
onConfirm={async () => {
|
|
Toast.hideModal()
|
|
await signOut()
|
|
router.replace('/auth')
|
|
}}
|
|
onCancel={() => Toast.hideModal()}
|
|
/>,
|
|
{},
|
|
)
|
|
}, [signOut])
|
|
|
|
const { width: screenWidth } = Dimensions.get('window')
|
|
const itemWidth = Math.floor((screenWidth - 24 - 12 * 2) / 3)
|
|
|
|
const renderBanner = useCallback(
|
|
() => (
|
|
<Block className="absolute inset-0 z-0 overflow-hidden">
|
|
<VideoBox url={bgVideo} style={{ position: 'absolute', left: 0, right: 0, top: 0, bottom: 0, opacity: 0.6 }} />
|
|
<Block className="absolute inset-0 bg-black/10" />
|
|
</Block>
|
|
),
|
|
[bgVideo],
|
|
)
|
|
|
|
const renderHeaderCard = useCallback(() => {
|
|
const username = user?.name || user?.email || 'Guest'
|
|
const avatarUrl =
|
|
user?.image || 'https://image.pollinations.ai/prompt/cool%20anime%20boy%20avatar%20hoodie?seed=123&nologo=true'
|
|
const uid = user?.id?.slice(-6) || '000000'
|
|
const generationCount = generations.length
|
|
const completedCount = generations.filter((g: TemplateGeneration) => g.status === 'completed').length
|
|
|
|
return (
|
|
<Block className="relative mb-[24px] mt-[16px] px-[12px]">
|
|
<Block className="relative overflow-hidden border-4 border-black bg-white p-[16px] shadow-deep-black">
|
|
<Block style={{ position: 'absolute', left: 0, right: 0, top: 0, bottom: 0, opacity: 0.1 }} />
|
|
<Block className="relative z-10 flex-row items-center gap-[16px]">
|
|
<Block className="size-[96px] border-[3px] border-black bg-accent p-[4px] shadow-soft-black-10">
|
|
<Img src={avatarUrl} className="size-full border border-black" />
|
|
</Block>
|
|
<Block className="flex-1">
|
|
<Block className="mb-[4px] flex-row items-center justify-between">
|
|
<Text className="font-900 -skew-x-6 text-[24px] tracking-[-0.5px] text-black" numberOfLines={1}>
|
|
{username}
|
|
</Text>
|
|
<Block onClick={handleLogout} className="border-2 border-black bg-black p-[8px] shadow-medium-gray">
|
|
<Ionicons name="log-out-outline" size={18} color="white" />
|
|
</Block>
|
|
</Block>
|
|
<Block className="flex-row items-center gap-[8px]">
|
|
<Text className="font-900 border border-black bg-black px-[8px] py-[2px] text-[10px] text-white">
|
|
UID: {uid}
|
|
</Text>
|
|
<Text className="font-900 border-2 border-black bg-accent px-[8px] py-[2px] text-[10px] text-black shadow-small-accent">
|
|
CREDITS: {balance}
|
|
</Text>
|
|
</Block>
|
|
<Block className="mt-[12px] flex-row items-center gap-[16px] border-t-2 border-black pt-[8px]">
|
|
<Block>
|
|
<Text className="font-700 text-[10px] text-gray-500">TOTAL</Text>
|
|
<Text className="font-900 text-[14px] text-black">{generationCount}</Text>
|
|
</Block>
|
|
<Block>
|
|
<Text className="font-700 text-[10px] text-gray-500">COMPLETED</Text>
|
|
<Text className="font-900 text-[14px] text-black">{completedCount}</Text>
|
|
</Block>
|
|
<Block>
|
|
<Text className="font-700 text-[10px] text-gray-500">CREDITS</Text>
|
|
<Text className="font-900 text-[14px] text-black">{balance}</Text>
|
|
</Block>
|
|
</Block>
|
|
</Block>
|
|
</Block>
|
|
{balance > 100 && (
|
|
<Block className="absolute right-[-32px] top-px z-20 rotate-45 border-y-2 border-black bg-[#e61e25] px-[40px] py-[4px]">
|
|
<Text className="font-900 text-[12px] text-white">PRO</Text>
|
|
</Block>
|
|
)}
|
|
</Block>
|
|
</Block>
|
|
)
|
|
}, [user, balance, generations])
|
|
|
|
const renderActions = useCallback(
|
|
() => (
|
|
<Block className="mb-[24px] flex-row gap-[12px] px-[12px]">
|
|
{[
|
|
{ label: 'SHOP', color: '#4ADE80', icon: 'bag-outline' as const },
|
|
{ label: 'SYNC', color: '#FFE500', icon: 'watch-outline' as const },
|
|
{ label: 'PAY', color: '#e61e25', icon: 'card-outline' as const },
|
|
].map(({ label, color, icon }) => (
|
|
<Block
|
|
key={label}
|
|
className="relative flex-1 items-center justify-center border-[3px] border-black bg-white"
|
|
style={{ height: 56 }}
|
|
>
|
|
<Ionicons name={icon} size={20} color="black" />
|
|
<Text className="font-900 text-[10px] text-black">{label}</Text>
|
|
</Block>
|
|
))}
|
|
</Block>
|
|
),
|
|
[],
|
|
)
|
|
|
|
const renderFilters = useCallback(
|
|
() => (
|
|
<Block className="mb-[12px] flex-row items-center justify-between px-[12px]">
|
|
<Block className="flex-row gap-[8px]">
|
|
{['我的生成', '我的专辑'].map((label) => {
|
|
const target = label === '我的生成' ? 'my_gen' : 'my_album'
|
|
const isActive = activeFilter === (target as 'my_gen' | 'my_album')
|
|
return (
|
|
<Block
|
|
key={label}
|
|
onClick={() => setActiveFilter(target as 'my_gen' | 'my_album')}
|
|
className={`border-2 border-black px-[16px] py-[4px] ${isActive ? 'bg-black' : 'bg-white'} -skew-x-12`}
|
|
>
|
|
<Text className={`font-900 text-[10px] ${isActive ? 'text-accent' : 'text-black'} skew-x-12`}>
|
|
{label}
|
|
</Text>
|
|
</Block>
|
|
)
|
|
})}
|
|
</Block>
|
|
<Block
|
|
onClick={toggleSelectionMode}
|
|
className={`border-2 border-black px-[16px] py-[6px] shadow-medium-black ${isSelectionMode ? 'bg-[#e61e25] text-white' : 'bg-accent text-black'}`}
|
|
>
|
|
<Text className="font-900 text-[12px]">{isSelectionMode ? '取消' : '管理'}</Text>
|
|
</Block>
|
|
</Block>
|
|
),
|
|
[activeFilter, isSelectionMode, toggleSelectionMode],
|
|
)
|
|
|
|
const renderGrid = useCallback(() => {
|
|
if (filteredPosts.length === 0) {
|
|
return (
|
|
<Block className="items-center justify-center px-[12px] py-[80px]">
|
|
<Block className="items-center gap-[16px] border-[3px] border-black bg-white p-[32px] shadow-large-black">
|
|
<Ionicons name="images-outline" size={64} color="#cccccc" />
|
|
<Text className="font-900 text-[16px] text-gray-500">
|
|
{activeFilter === 'my_gen' ? '暂无生成作品' : '暂无收藏'}
|
|
</Text>
|
|
</Block>
|
|
</Block>
|
|
)
|
|
}
|
|
|
|
return (
|
|
<Block className="px-[12px]">
|
|
<Block className="flex-row flex-wrap gap-[12px]">
|
|
{filteredPosts.map((generation: TemplateGeneration) => {
|
|
const isSelected = selectedIds.has(generation.id)
|
|
const imageUrl = generation.resultUrl?.[0] || generation.originalUrl || ''
|
|
const statusRank = generation.status === 'completed' ? 'S' : generation.status === 'processing' ? 'A' : 'B'
|
|
|
|
return (
|
|
<Block key={generation.id} onClick={() => handleItemClick(generation)} className="relative">
|
|
<Block
|
|
className={`relative overflow-hidden border-2 ${isSelected ? 'shadow-[0px_0px_0px_4px_#FFE500]' : 'shadow-hard-black'} ${isSelected ? 'border-accent' : 'border-black'}`}
|
|
style={{ transform: [{ skewX: '-6deg' }], height: itemWidth, width: itemWidth }}
|
|
>
|
|
<Block style={{ transform: [{ skewX: '6deg' }], height: itemWidth, width: itemWidth }}>
|
|
{imageUrl ? (
|
|
isVideoUrl(imageUrl) ? (
|
|
<VideoBox url={`${imageUrl}#t=0.1`} style={{ height: itemWidth, width: itemWidth }} />
|
|
) : (
|
|
<Img src={imageUrl} className="size-full" />
|
|
)
|
|
) : (
|
|
<Block className="size-full items-center justify-center bg-gray-200">
|
|
<Ionicons name="image-outline" size={32} color="#999" />
|
|
</Block>
|
|
)}
|
|
</Block>
|
|
{isSelected && <Block className="absolute inset-0 border-[3px] border-accent" />}
|
|
|
|
{isSelectionMode && (
|
|
<Block
|
|
className="absolute inset-0 z-30 items-center justify-center"
|
|
style={{ transform: [{ skewX: '6deg' }] }}
|
|
>
|
|
<Block
|
|
className={`size-[32px] items-center justify-center rounded-full border-[3px] border-black ${isSelected ? 'bg-accent' : 'bg-white/50'}`}
|
|
>
|
|
<Ionicons name="checkmark" size={20} color="black" />
|
|
</Block>
|
|
</Block>
|
|
)}
|
|
{!isSelectionMode && (
|
|
<Block className="absolute left-0 top-0 z-20 border-b-2 border-r-2 border-black bg-accent px-[6px] py-[2px]">
|
|
<Text className="font-900 text-[8px] text-black">{statusRank}</Text>
|
|
</Block>
|
|
)}
|
|
{!isSelectionMode && (
|
|
<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']}
|
|
start={{ x: 0, y: 1 }}
|
|
end={{ x: 0, y: 0 }}
|
|
style={{ position: 'absolute', left: 0, right: 0, top: 0, bottom: 0 }}
|
|
/>
|
|
<Text className="font-900 max-w-[40px] -skew-x-6 text-[7px] text-white" numberOfLines={1}>
|
|
{generation.type}
|
|
</Text>
|
|
<Block className="-skew-x-6 flex-row items-center gap-[2px]">
|
|
<Ionicons name="time-outline" size={10} color="#FFE500" />
|
|
<Text className="font-900 text-[8px] text-white">
|
|
{new Date(generation.createdAt).toLocaleDateString()}
|
|
</Text>
|
|
</Block>
|
|
</Block>
|
|
)}
|
|
</Block>
|
|
</Block>
|
|
)
|
|
})}
|
|
</Block>
|
|
<Block className="h-[200px] w-full" />
|
|
</Block>
|
|
)
|
|
}, [filteredPosts, selectedIds, isSelectionMode, handleItemClick, activeFilter, itemWidth])
|
|
|
|
const renderSelection = useCallback(() => {
|
|
if (!isSelectionMode) return null
|
|
return (
|
|
<Block className="absolute inset-x-[16px] bottom-[96px] z-50">
|
|
<Block className="-skew-x-3 flex-row items-center justify-between border-[3px] border-black bg-white p-[12px] shadow-large-black">
|
|
<Block className="skew-x-3 flex-row items-center gap-[12px] pl-[8px]">
|
|
<Text className="font-900 text-[14px]">已选: {selectedIds.size}</Text>
|
|
<Block onClick={selectAll} className="text-[12px] underline">
|
|
<Text className="font-700 text-[12px]">全选</Text>
|
|
</Block>
|
|
</Block>
|
|
<Block
|
|
onClick={handleDelete}
|
|
className={`font-900 skew-x-3 flex-row items-center gap-[8px] border-2 border-black px-[16px] py-[8px] text-[14px] shadow-medium-black${selectedIds.size > 0 && !deleteLoading ? 'bg-[#e61e25]' : 'bg-gray-200'}`}
|
|
>
|
|
{deleteLoading ? (
|
|
<Ionicons name="hourglass-outline" size={16} color="white" />
|
|
) : (
|
|
<Ionicons name="trash-outline" size={16} color="white" />
|
|
)}
|
|
<Text className="font-900 text-[14px] text-white">{deleteLoading ? '删除中...' : '删除'}</Text>
|
|
</Block>
|
|
</Block>
|
|
</Block>
|
|
)
|
|
}, [isSelectionMode, selectedIds.size, selectAll, handleDelete, deleteLoading])
|
|
if (authLoading || generationsLoading) {
|
|
return (
|
|
<Block className="relative flex-1 items-center justify-center bg-black">
|
|
{renderBanner()}
|
|
<Block className="relative z-10 items-center gap-[16px] border-[3px] border-accent bg-white p-[32px] shadow-large-accent">
|
|
<Ionicons name="hourglass-outline" size={48} color="#FFE500" />
|
|
<Text className="font-900 text-[16px] text-black">加载中...</Text>
|
|
</Block>
|
|
</Block>
|
|
)
|
|
}
|
|
|
|
return (
|
|
<Block className="relative flex-1 bg-black text-white">
|
|
{renderBanner()}
|
|
<ScrollView className="relative z-10 flex-1">
|
|
{renderHeaderCard()}
|
|
{renderActions()}
|
|
{renderFilters()}
|
|
{renderGrid()}
|
|
</ScrollView>
|
|
{renderSelection()}
|
|
</Block>
|
|
)
|
|
}
|