491 lines
17 KiB
TypeScript
491 lines
17 KiB
TypeScript
import { FontAwesome, Fontisto, Ionicons } from '@expo/vector-icons'
|
|
import { useRouter } from 'expo-router'
|
|
import React, { memo, useCallback, useEffect, useMemo, useState } from 'react'
|
|
import { ActivityIndicator } from 'react-native'
|
|
import { ScrollView } from 'react-native-gesture-handler'
|
|
import { useAnimatedStyle } from 'react-native-reanimated'
|
|
import { useSafeAreaInsets } from 'react-native-safe-area-context'
|
|
import Svg, { Circle, Defs, Pattern, Rect } from 'react-native-svg'
|
|
|
|
import { imgPicker } from '@/@share/apis/imgPicker'
|
|
import { Block, Img, Input, Text, VideoBox } from '@/@share/components'
|
|
import BannerSection from '@/components/BannerSection'
|
|
import { useCategories } from '@/hooks/data/use-categories'
|
|
import { useRecommendedTemplates } from '@/hooks/data/use-recommended-templates'
|
|
import { useTemplates } from '@/hooks/data/use-templates'
|
|
import { type ApiError } from '@/lib/types'
|
|
import { screenHeight, screenWidth } from '@/utils'
|
|
|
|
export default function Index() {
|
|
const router = useRouter()
|
|
const env = process.env.EXPO_PUBLIC_ENV
|
|
|
|
const [prompt, setPrompt] = useState(`${env} update`)
|
|
const [selectedTemplateId, setSelectedTemplateId] = useState('')
|
|
const [selectedCategoryId, setSelectedCategoryId] = useState('')
|
|
const [meImg, setMeImg] = useState('')
|
|
const [friendImg, setFriendImg] = useState('')
|
|
|
|
const recommendedTemplates = useRecommendedTemplates()
|
|
const categories = useCategories()
|
|
const templates = useTemplates()
|
|
|
|
useEffect(() => {
|
|
recommendedTemplates.execute({ isActive: true })
|
|
categories.load({ isActive: true })
|
|
}, [])
|
|
|
|
useEffect(() => {
|
|
const params = selectedCategoryId ? { categoryId: selectedCategoryId, isActive: true } : { isActive: true }
|
|
templates.execute(params)
|
|
}, [selectedCategoryId])
|
|
|
|
const displayTemplates = useMemo(() => {
|
|
const recommended = recommendedTemplates.data?.templates || []
|
|
const regular = templates.data?.templates || []
|
|
|
|
const all = selectedCategoryId
|
|
? regular
|
|
: [...recommended.map((r: RecommendedTemplate) => r.template).filter(Boolean), ...regular]
|
|
|
|
return all.map(
|
|
(t: any): Template => ({
|
|
id: t.id,
|
|
name: t.title,
|
|
image: t.coverImageUrl,
|
|
type: 'video' as const,
|
|
data: t,
|
|
}),
|
|
)
|
|
}, [recommendedTemplates.data, templates.data, selectedCategoryId])
|
|
|
|
const categoryList = useMemo(() => {
|
|
return (categories.data?.categories || []).map((c) => ({
|
|
id: c.id,
|
|
name: c.name,
|
|
}))
|
|
}, [categories.data])
|
|
|
|
const selectedTemplate = useMemo(() => {
|
|
return displayTemplates.find((t) => t.id === selectedTemplateId)
|
|
}, [displayTemplates, selectedTemplateId])
|
|
|
|
useEffect(() => {
|
|
if (displayTemplates.length > 0 && !selectedTemplateId) {
|
|
setSelectedTemplateId(displayTemplates[0].id)
|
|
}
|
|
}, [displayTemplates, selectedTemplateId])
|
|
|
|
const handleSearch = useCallback(() => {
|
|
router.push('/searchTemplate')
|
|
}, [router])
|
|
|
|
const handleGenerate = useCallback(() => {
|
|
if (!selectedTemplate) return
|
|
setTimeout(() => {}, 2000)
|
|
}, [selectedTemplate])
|
|
|
|
const pickImage = useCallback(async (target: 'me' | 'friend') => {
|
|
const [uri] = (await imgPicker({ maxImages: 1, resultType: 'uri' })) as string[]
|
|
if (!uri) return
|
|
if (target === 'me') setMeImg(uri)
|
|
else setFriendImg(uri)
|
|
}, [])
|
|
|
|
const handleRandom = useCallback(() => {
|
|
if (displayTemplates.length === 0) return
|
|
const random = displayTemplates[Math.floor(Math.random() * displayTemplates.length)]
|
|
setSelectedTemplateId(random.id)
|
|
}, [displayTemplates])
|
|
|
|
const handleSelectTemplate = useCallback((t: Template) => {
|
|
setSelectedTemplateId(t.id)
|
|
}, [])
|
|
|
|
const handleSelectCategory = useCallback((id: string) => {
|
|
setSelectedCategoryId(id)
|
|
setSelectedTemplateId('')
|
|
}, [])
|
|
|
|
const onPickMe = useCallback(() => pickImage('me'), [pickImage])
|
|
const onPickFriend = useCallback(() => pickImage('friend'), [pickImage])
|
|
|
|
const isLoading = templates.loading || recommendedTemplates.loading
|
|
const hasError = templates.error || recommendedTemplates.error
|
|
|
|
const handleRetry = useCallback(() => {
|
|
if (selectedCategoryId) {
|
|
templates.refetch({ categoryId: selectedCategoryId, isActive: true })
|
|
} else {
|
|
recommendedTemplates.refetch({ isActive: true })
|
|
templates.refetch({ isActive: true })
|
|
}
|
|
}, [selectedCategoryId, templates, recommendedTemplates])
|
|
|
|
return (
|
|
<Block className="relative flex-1 flex-col overflow-visible bg-black">
|
|
<BannerSection />
|
|
|
|
<ScrollView contentContainerStyle={{ overflow: 'visible' }}>
|
|
<Block className="px-[12px]">
|
|
<Header onSearch={handleSearch} />
|
|
|
|
<Block className="px-16px relative z-10 flex-1">
|
|
<UploadSection friendImg={friendImg} meImg={meImg} onPickFriend={onPickFriend} onPickMe={onPickMe} />
|
|
<PromptSection prompt={prompt} onChangePrompt={setPrompt} />
|
|
{categoryList.length > 0 && (
|
|
<CategoryFilter
|
|
categories={categoryList}
|
|
selectedId={selectedCategoryId}
|
|
onSelect={handleSelectCategory}
|
|
/>
|
|
)}
|
|
<TemplateSection
|
|
error={hasError}
|
|
loading={isLoading}
|
|
selectedTemplateId={selectedTemplateId}
|
|
templates={displayTemplates}
|
|
onRandom={handleRandom}
|
|
onRetry={handleRetry}
|
|
onSelectTemplate={handleSelectTemplate}
|
|
/>
|
|
</Block>
|
|
</Block>
|
|
|
|
<Block className="h-[200px] w-full"></Block>
|
|
</ScrollView>
|
|
|
|
<GenerateSection onGenerate={handleGenerate} />
|
|
</Block>
|
|
)
|
|
}
|
|
|
|
/** =========================
|
|
* Small memo components
|
|
* ========================= */
|
|
|
|
type BannerProps = { bgVideo: string }
|
|
const Banner = memo<BannerProps>(function Banner({ bgVideo }) {
|
|
return (
|
|
<Block className="absolute inset-0 z-0 overflow-hidden">
|
|
<VideoBox style={{ width: screenWidth, height: screenHeight }} url={bgVideo} />
|
|
|
|
<Block className="absolute inset-0">
|
|
<Svg height="100%" preserveAspectRatio="none" viewBox="0 0 400 800" width="100%">
|
|
<Defs>
|
|
<Pattern height="15" id="dots" patternUnits="userSpaceOnUse" width="15" x="0" y="0">
|
|
<Circle cx="2" cy="2" fill="#ffff00" opacity="0.05" r="2" />
|
|
</Pattern>
|
|
</Defs>
|
|
<Rect fill="url(#dots)" height="100%" width="100%" x="0" y="0" />
|
|
</Svg>
|
|
</Block>
|
|
|
|
<Block className="absolute right-[-10px] top-[96px] z-0">
|
|
<Text className="font-700 text-[120px] italic leading-[120px] tracking-tighter text-white/10">CREATE</Text>
|
|
</Block>
|
|
</Block>
|
|
)
|
|
})
|
|
|
|
type HeaderProps = { onSearch: () => void }
|
|
const Header = memo<HeaderProps>(function Header({ onSearch }) {
|
|
return (
|
|
<Block className="z-20 flex-row items-center justify-between py-[12px]">
|
|
<Block className="-skew-x-12 flex-row items-center gap-[8px] border border-white/20 bg-black px-[12px] py-[4px]">
|
|
<Block className="size-[8px] bg-accent" />
|
|
<Text className="font-700 skew-x-12 text-[12px] tracking-wider text-white">创造终端</Text>
|
|
</Block>
|
|
|
|
<Block className="items-center">
|
|
<Text className="font-700 text-[24px] italic tracking-tighter text-white">GEN_STUDIO</Text>
|
|
</Block>
|
|
|
|
<Block className="size-[32px] items-center justify-center" onClick={onSearch}>
|
|
<Ionicons color="white" name="search" size={20} />
|
|
</Block>
|
|
</Block>
|
|
)
|
|
})
|
|
|
|
type UploadCardProps = {
|
|
variant: 'me' | 'friend'
|
|
img: string
|
|
onPick: () => void
|
|
}
|
|
const UploadCard = memo<UploadCardProps>(function UploadCard({ variant, img, onPick }) {
|
|
const isMe = variant === 'me'
|
|
|
|
return (
|
|
<Block className="flex-1 overflow-hidden border-[3px] border-black bg-white" onClick={onPick}>
|
|
{isMe ? (
|
|
<Block className="absolute left-0 top-0 z-10 border-b-2 border-r-2 border-black bg-accent px-[8px] py-[2px]">
|
|
<Text className="text-[10px] font-[900] italic text-black">我</Text>
|
|
</Block>
|
|
) : (
|
|
<Block className="absolute right-0 top-0 z-10 border-b-2 border-l-2 border-white bg-black px-[8px] py-[2px]">
|
|
<Text className="text-[10px] font-[900] italic text-white">朋友</Text>
|
|
</Block>
|
|
)}
|
|
|
|
<Block className="size-full items-center justify-center bg-[#F3F4F6]">
|
|
{img ? (
|
|
<Img className="size-full" contentFit="cover" src={img} />
|
|
) : (
|
|
<Block className="items-center">
|
|
{isMe ? (
|
|
<Block className="mb-[4px] size-[40px] items-center justify-center rounded-[20px] border-2 border-black bg-white">
|
|
<Text className="font-700 text-[16px] text-black">+</Text>
|
|
</Block>
|
|
) : (
|
|
<Block className="mb-[4px] size-[40px] items-center justify-center rounded-[20px] bg-black">
|
|
<Text className="font-700 text-[16px] text-white">+</Text>
|
|
</Block>
|
|
)}
|
|
|
|
<Text className="font-700 text-[9px] italic text-black/50">点击上传</Text>
|
|
</Block>
|
|
)}
|
|
</Block>
|
|
</Block>
|
|
)
|
|
})
|
|
|
|
type UploadSectionProps = {
|
|
meImg: string
|
|
friendImg: string
|
|
onPickMe: () => void
|
|
onPickFriend: () => void
|
|
}
|
|
const UploadSection = memo<UploadSectionProps>(function UploadSection({ meImg, friendImg, onPickMe, onPickFriend }) {
|
|
return (
|
|
<Block className="z-10 flex h-[160px] w-full flex-row gap-x-[12px]">
|
|
<UploadCard img={meImg} variant="me" onPick={onPickMe} />
|
|
<UploadCard img={friendImg} variant="friend" onPick={onPickFriend} />
|
|
</Block>
|
|
)
|
|
})
|
|
|
|
type PromptSectionProps = {
|
|
prompt: string
|
|
onChangePrompt: (v: string) => void
|
|
}
|
|
const PromptSection = memo<PromptSectionProps>(function PromptSection({ prompt, onChangePrompt }) {
|
|
return (
|
|
<Block className="mt-[24px] -skew-x-6">
|
|
<Block className="mb-[4px] flex-row items-center px-[4px]">
|
|
<Block className="border border-white/20 bg-black px-[8px] py-[2px]">
|
|
<Text className="text-[12px] font-[900] italic text-accent">提示词</Text>
|
|
</Block>
|
|
</Block>
|
|
|
|
<Block className="border-[3px] border-black bg-white p-[4px]">
|
|
<Input
|
|
multiline
|
|
className="font-700 min-h-[50px] w-full bg-transparent p-[8px] text-[14px] leading-[18px] text-black"
|
|
numberOfLines={2}
|
|
placeholder="描述画面细节..."
|
|
value={prompt}
|
|
onChangeText={onChangePrompt}
|
|
/>
|
|
</Block>
|
|
</Block>
|
|
)
|
|
})
|
|
|
|
type Template = {
|
|
id: string
|
|
name: string
|
|
image: string
|
|
type: 'video'
|
|
data?: any
|
|
}
|
|
|
|
type TemplateItemProps = {
|
|
item: Template
|
|
itemWidth: number
|
|
isSelected: boolean
|
|
onSelect: () => void
|
|
}
|
|
const TemplateItem = memo<TemplateItemProps>(function TemplateItem({ item, itemWidth, isSelected, onSelect }) {
|
|
return (
|
|
<Block
|
|
className={`relative border-2 ${isSelected ? 'border-accent' : 'border-black'}`}
|
|
style={{
|
|
transform: [{ skewX: '-6deg' }],
|
|
height: itemWidth,
|
|
width: itemWidth,
|
|
borderWidth: 2,
|
|
borderColor: isSelected ? '#FFE500' : '#000000',
|
|
}}
|
|
onClick={onSelect}
|
|
>
|
|
<Img className="size-full" contentFit="cover" src={item.image} />
|
|
|
|
{isSelected && <Block className="absolute inset-0 bg-black/60" />}
|
|
{isSelected && <Block className="absolute inset-[-5px] border-[3px] border-accent" />}
|
|
|
|
<Block className="absolute inset-x-0 bottom-0 items-center bg-black/90 p-[4px]">
|
|
<Text className={`text-[8px] font-[700] italic ${isSelected ? 'text-accent' : 'text-white'}`}>{item.name}</Text>
|
|
</Block>
|
|
|
|
{item.type === 'video' && (
|
|
<Block className="rounded-4px absolute right-[4px] top-[4px] rounded-[4px] border border-white/50 bg-black p-[4px]">
|
|
<Fontisto color="white" name="youtube-play" size={8} />
|
|
</Block>
|
|
)}
|
|
</Block>
|
|
)
|
|
})
|
|
|
|
type CategoryChipProps = {
|
|
name: string
|
|
isSelected: boolean
|
|
onSelect: () => void
|
|
}
|
|
const CategoryChip = memo<CategoryChipProps>(function CategoryChip({ name, isSelected, onSelect }) {
|
|
return (
|
|
<Block
|
|
className={`-skew-x-6 border-2 px-[12px] py-[4px] ${isSelected ? 'border-accent bg-accent' : 'border-black bg-white'}`}
|
|
onClick={onSelect}
|
|
>
|
|
<Text className={`text-[10px] font-[900] italic ${isSelected ? 'text-black' : 'text-black/60'}`}>{name}</Text>
|
|
</Block>
|
|
)
|
|
})
|
|
|
|
type CategoryFilterProps = {
|
|
categories: { id: string; name: string }[]
|
|
selectedId: string
|
|
onSelect: (id: string) => void
|
|
}
|
|
const CategoryFilter = memo<CategoryFilterProps>(function CategoryFilter({ categories, selectedId, onSelect }) {
|
|
return (
|
|
<Block className="mt-[16px]">
|
|
<ScrollView horizontal contentContainerStyle={{ gap: 8 }} showsHorizontalScrollIndicator={false}>
|
|
<CategoryChip isSelected={selectedId === ''} name="全部" onSelect={() => onSelect('')} />
|
|
{categories.map((cat) => (
|
|
<CategoryChip
|
|
key={cat.id}
|
|
isSelected={selectedId === cat.id}
|
|
name={cat.name}
|
|
onSelect={() => onSelect(cat.id)}
|
|
/>
|
|
))}
|
|
</ScrollView>
|
|
</Block>
|
|
)
|
|
})
|
|
|
|
type TemplateSectionProps = {
|
|
templates: Template[]
|
|
selectedTemplateId: string
|
|
onSelectTemplate: (t: Template) => void
|
|
onRandom: () => void
|
|
loading?: boolean
|
|
error?: ApiError | null
|
|
onRetry?: () => void
|
|
}
|
|
const TemplateSection = memo<TemplateSectionProps>(function TemplateSection({
|
|
templates,
|
|
selectedTemplateId,
|
|
onSelectTemplate,
|
|
onRandom,
|
|
loading,
|
|
error,
|
|
onRetry,
|
|
}) {
|
|
const style = useAnimatedStyle(() => ({
|
|
transform: [{ skewX: '-6deg' }],
|
|
backgroundColor: '#FFE500',
|
|
}))
|
|
|
|
const itemWidth = useMemo(() => {
|
|
return Math.floor((screenWidth - 24 - 12 * 2) / 3)
|
|
}, [])
|
|
|
|
return (
|
|
<Block className="mt-[16px]">
|
|
<Block className="flex-row items-center justify-between">
|
|
<Block
|
|
animated
|
|
className="flex-row items-center gap-[8px] border-2 border-black bg-accent px-[12px] py-[4px]"
|
|
style={style}
|
|
>
|
|
<FontAwesome color="black" name="film" size={16} />
|
|
<Text className="text-[10px] font-[900] italic text-black">视频模版</Text>
|
|
</Block>
|
|
|
|
<Block
|
|
className="-skew-x-6 items-center justify-center border-2 border-black bg-white px-[8px] py-[4px]"
|
|
onClick={onRandom}
|
|
>
|
|
<Text className="text-[10px] font-[900] italic text-black">换一波</Text>
|
|
</Block>
|
|
</Block>
|
|
|
|
{loading ? (
|
|
<Block className="mt-[32px] items-center justify-center py-[40px]">
|
|
<ActivityIndicator color="#FFE500" size="large" />
|
|
</Block>
|
|
) : error ? (
|
|
<Block className="mt-[32px] items-center justify-center py-[40px]">
|
|
<Text className="mb-[8px] text-[14px] text-white/60">加载失败</Text>
|
|
{onRetry && (
|
|
<Block className="-skew-x-6 border-2 border-white bg-white px-[16px] py-[6px]" onClick={onRetry}>
|
|
<Text className="text-[12px] font-[900] italic text-black">重试</Text>
|
|
</Block>
|
|
)}
|
|
</Block>
|
|
) : templates.length === 0 ? (
|
|
<Block className="mt-[32px] items-center justify-center py-[40px]">
|
|
<Text className="text-[14px] text-white/60">暂无模板</Text>
|
|
</Block>
|
|
) : (
|
|
<Block className="mt-[16px] flex-row flex-wrap gap-[12px]">
|
|
{templates.map((item) => {
|
|
const isSelected = selectedTemplateId === item.id
|
|
return (
|
|
<TemplateItem
|
|
key={item.id}
|
|
isSelected={isSelected}
|
|
item={item}
|
|
itemWidth={itemWidth}
|
|
onSelect={() => onSelectTemplate(item)}
|
|
/>
|
|
)
|
|
})}
|
|
</Block>
|
|
)}
|
|
</Block>
|
|
)
|
|
})
|
|
|
|
type GenerateSectionProps = { onGenerate: () => void }
|
|
const GenerateSection = memo<GenerateSectionProps>(function GenerateSection({ onGenerate }) {
|
|
const insets = useSafeAreaInsets()
|
|
return (
|
|
<Block className="absolute inset-x-[16px] bottom-[96px] z-40" style={{ paddingBottom: insets.bottom }}>
|
|
<Block style={{ transform: [{ skewX: '-6deg' }] }}>
|
|
<Block
|
|
className={`relative flex-row items-center justify-between border-[3px] border-black bg-accent py-[8px]`}
|
|
style={{ paddingHorizontal: 24 }}
|
|
onClick={onGenerate}
|
|
>
|
|
{/* 左侧文字 */}
|
|
<Block className="z-10 flex-col">
|
|
<Text className={`text-[24px] italic text-black`}>立即生成</Text>
|
|
<Text className={`text-[10px] font-bold text-black`}>开始创作</Text>
|
|
</Block>
|
|
|
|
{/* 右侧 Goo 标签 */}
|
|
<Block className="z-10 flex-row items-center gap-[8px] border-2 border-black bg-black px-[12px] py-[4px]">
|
|
<Ionicons color="#FFE500" name="flash" size={14} />
|
|
<Text className="text-[14px] font-black italic text-accent">3 Goo</Text>
|
|
</Block>
|
|
</Block>
|
|
</Block>
|
|
</Block>
|
|
)
|
|
})
|