diff --git a/.env b/.env
new file mode 100644
index 0000000..e69de29
diff --git a/app/(tabs)/index.tsx b/app/(tabs)/index.tsx
index c8afa5c..e80462f 100644
--- a/app/(tabs)/index.tsx
+++ b/app/(tabs)/index.tsx
@@ -1,6 +1,7 @@
import { Image } from 'expo-image'
+import { VideoView, useVideoPlayer } from 'expo-video'
import { LinearGradient } from 'expo-linear-gradient'
-import { useRouter } from 'expo-router'
+import { useRouter, useLocalSearchParams } from 'expo-router'
import { StatusBar } from 'expo-status-bar'
import { useEffect, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
@@ -23,10 +24,29 @@ import { useCategories } from '@/hooks/use-categories'
const { width: screenWidth } = Dimensions.get('window')
+// 视频预览组件
+function VideoPreview({ source, style }: { source: string; style: any }) {
+ const player = useVideoPlayer(source, (player) => {
+ player.loop = true
+ player.muted = true
+ player.play()
+ })
+
+ return (
+
+ )
+}
+
export default function HomeScreen() {
const { t } = useTranslation()
const insets = useSafeAreaInsets()
const router = useRouter()
+ const params = useLocalSearchParams()
const [activeTab, setActiveTab] = useState(0)
const [selectedCategoryId, setSelectedCategoryId] = useState(null)
@@ -50,6 +70,19 @@ export default function HomeScreen() {
}
}, [categoriesData, categoriesError])
+ // 监听从 channels 页面传递过来的 categoryId 参数
+ useEffect(() => {
+ const categoryIdFromParams = params.categoryId as string | undefined
+ if (categoryIdFromParams && categoriesData?.categories) {
+ setSelectedCategoryId(categoryIdFromParams)
+ // 同时更新 activeTab 索引
+ const categoryIndex = categoriesData.categories.findIndex(cat => cat.id === categoryIdFromParams)
+ if (categoryIndex !== -1) {
+ setActiveTab(categoryIndex)
+ }
+ }
+ }, [params.categoryId, categoriesData])
+
// 使用接口返回的分类数据,如果没有则使用默认翻译
const categories = categoriesData?.categories || []
const tabs = categories.length > 0
@@ -69,17 +102,22 @@ export default function HomeScreen() {
// 将 CategoryTemplate 数据转换为卡片数据格式
const displayCardData = categoryTemplates.length > 0
- ? categoryTemplates.map((template, idx) => ({
- id: template.id || `template-${idx}`,
- title: template.title,
- image: { uri: template.previewUrl || template.coverImageUrl || `` },
- isHot: false,
- users: 0,
- height: undefined,
- previewUrl: template.previewUrl,
- aspectRatio: template.aspectRatio,
- tag: template.tag,
- }))
+ ? categoryTemplates.map((template, idx) => {
+ const previewUrl = (template as any).webpPreviewUrl || template.previewUrl || template.coverImageUrl || ``
+ const isVideo = previewUrl.includes('.mp4') || previewUrl.includes('.mov') || previewUrl.includes('.webm')
+ return {
+ id: template.id || `template-${idx}`,
+ title: template.title,
+ image: { uri: previewUrl },
+ isHot: false,
+ users: 0,
+ height: undefined,
+ previewUrl: template.previewUrl,
+ aspectRatio: template.aspectRatio,
+ tag: template.tag,
+ isVideo,
+ }
+ })
: []
const [gridWidth, setGridWidth] = useState(screenWidth)
const [showTabArrow, setShowTabArrow] = useState(false)
@@ -164,7 +202,10 @@ export default function HomeScreen() {
>
router.push('/channels')}
+ onPress={() => router.push({
+ pathname: '/channels' as any,
+ params: selectedCategoryId ? { categoryId: selectedCategoryId } : undefined,
+ })}
>
@@ -321,11 +362,15 @@ export default function HomeScreen() {
{ height: card.height || cardWidth * 1.2 },
]}
>
-
+ {card.isVideo ? (
+
+ ) : (
+
+ )}
{
const { t } = useTranslation()
const router = useRouter()
- const [isPlaying, setIsPlaying] = useState(false)
const [imageSize, setImageSize] = useState<{ width: number; height: number } | null>(null)
const handleImageLoad = useCallback((event: ImageLoadEventData) => {
@@ -91,21 +91,19 @@ const VideoItem = memo(({ item, videoHeight }: { item: TemplateDetail; videoHeig
const imageStyle = calculateImageSize(imageSize, videoHeight)
+ // 优先使用 WebP 格式(支持动画),回退到普通预览图
+ const displayUrl = item.webpPreviewUrl || item.previewUrl
+
return (
-
- {!isPlaying && (
- setIsPlaying(true)}>
-
-
-
-
+ {displayUrl && (
+
)}
@@ -126,11 +124,12 @@ const VideoItem = memo(({ item, videoHeight }: { item: TemplateDetail; videoHeig
export default function VideoScreen() {
const flatListRef = useRef(null)
const videoHeight = screenHeight - TAB_BAR_HEIGHT
+ const PAGE_SIZE = 10 // 每页10个数据
const { templates, loading, error, execute, refetch, loadMore, hasMore } = useTemplates({
sortBy: 'likeCount',
sortOrder: 'desc',
page: 1,
- limit: 20,
+ limit: PAGE_SIZE,
})
const [refreshing, setRefreshing] = useState(false)
@@ -145,9 +144,14 @@ export default function VideoScreen() {
}, [refetch])
const handleLoadMore = useCallback(() => {
- if (!hasMore || loading) return
+ console.log('onEndReached triggered', { hasMore, loading, templatesLength: templates.length })
+ if (!hasMore || loading) {
+ console.log('LoadMore blocked', { hasMore, loading })
+ return
+ }
+ console.log('Loading more...')
loadMore()
- }, [hasMore, loading, loadMore])
+ }, [hasMore, loading, loadMore, templates.length])
const renderItem = useCallback(({ item }: { item: TemplateDetail }) => (
@@ -186,8 +190,11 @@ export default function VideoScreen() {
/>
}
onEndReached={handleLoadMore}
- onEndReachedThreshold={0.5}
+ onEndReachedThreshold={0.1}
ListFooterComponent={loading ? : null}
+ maxToRenderPerBatch={5}
+ windowSize={7}
+ initialNumToRender={3}
/>
)
@@ -223,31 +230,6 @@ const styles = StyleSheet.create({
alignItems: 'center',
justifyContent: 'center',
},
- playButton: {
- ...StyleSheet.absoluteFillObject,
- alignItems: 'center',
- justifyContent: 'center',
- backgroundColor: 'rgba(0, 0, 0, 0.3)',
- },
- playIcon: {
- width: 56,
- height: 56,
- borderRadius: 28,
- backgroundColor: 'rgba(255, 255, 255, 0.85)',
- alignItems: 'center',
- justifyContent: 'center',
- },
- playTriangle: {
- width: 0,
- height: 0,
- borderLeftWidth: 18,
- borderTopWidth: 11,
- borderBottomWidth: 11,
- borderLeftColor: '#000000',
- borderTopColor: 'transparent',
- borderBottomColor: 'transparent',
- marginLeft: 3,
- },
thumbnailContainer: {
position: 'absolute',
left: 12,
diff --git a/app/channels.tsx b/app/channels.tsx
index 00b7510..5e11668 100644
--- a/app/channels.tsx
+++ b/app/channels.tsx
@@ -1,32 +1,46 @@
-import { useState } from 'react'
+import { useState, useEffect } from 'react'
import {
View,
Text,
StyleSheet,
Pressable,
StatusBar as RNStatusBar,
+ ActivityIndicator,
} from 'react-native'
import { StatusBar } from 'expo-status-bar'
import { LinearGradient } from 'expo-linear-gradient'
import { SafeAreaView } from 'react-native-safe-area-context'
-import { useRouter } from 'expo-router'
+import { useRouter, useLocalSearchParams } from 'expo-router'
import { useTranslation } from 'react-i18next'
import { TopArrowIcon } from '@/components/icon'
import GradientText from '@/components/GradientText'
+import { useCategories } from '@/hooks/use-categories'
export default function ChannelsScreen() {
const { t } = useTranslation()
const router = useRouter()
+ const params = useLocalSearchParams()
const [isExpanded, setIsExpanded] = useState(true)
- const [selectedChannel, setSelectedChannel] = useState(2) // 默认选中第二个频道
-
- // 频道数据
- const channels = Array.from({ length: 13 }, (_, i) => ({
- id: i + 1,
- name: t('channels.channelName'),
- }))
+
+ // 使用 useCategories hook 获取分类数据
+ const { load, data: categoriesData, loading: categoriesLoading, error: categoriesError } = useCategories()
+
+ // 从路由参数获取当前选中的分类 ID
+ const currentCategoryId = params.categoryId as string | undefined
+
+ useEffect(() => {
+ // 加载分类数据
+ load()
+ }, [])
+
+ // 使用接口返回的分类数据
+ const categories = categoriesData?.categories || []
+
+ // 如果没有分类数据,显示加载状态或空状态
+ const showLoading = categoriesLoading
+ const showEmptyState = !categoriesLoading && categories.length === 0
return (
@@ -41,11 +55,7 @@ export default function ChannelsScreen() {
{t('channels.title')}
{
- if (isExpanded) {
- router.push('/')
- } else {
- setIsExpanded(true)
- }
+ router.back()
}}
>
@@ -55,45 +65,65 @@ export default function ChannelsScreen() {
{/* 频道选择区域 */}
{isExpanded && (
-
- {channels.map((channel) => {
- return (
- setSelectedChannel(channel.id)}
- style={[styles.channelButtonWrapper]}
- >
- {selectedChannel === channel.id ? (
-
-
-
- {channel.name}
-
+ {showLoading ? (
+
+
+
+ ) : showEmptyState ? (
+
+ {t('channels.noCategories') || '暂无分类'}
+ load()}>
+ {t('channels.retry') || '重新加载'}
+
+
+ ) : (
+
+ {categories.map((category) => {
+ const isSelected = currentCategoryId === category.id
+ return (
+ {
+ // 选择分类后返回首页
+ router.push({
+ pathname: '/' as any,
+ params: { categoryId: category.id },
+ })
+ }}
+ style={[styles.channelButtonWrapper]}
+ >
+ {isSelected ? (
+
+
+
+ {category.name}
+
+
+
+ ) : (
+
+
+
+ {category.name}
+
+
-
- ) : (
-
-
-
- {channel.name}
-
-
-
- )}
-
- )
- })}
-
+ )}
+
+ )
+ })}
+
+ )}
)}
@@ -169,5 +199,31 @@ const styles = StyleSheet.create({
fontWeight: '500',
textAlign: 'center',
},
+ loadingContainer: {
+ paddingVertical: 24,
+ alignItems: 'center',
+ justifyContent: 'center',
+ },
+ emptyStateContainer: {
+ paddingVertical: 40,
+ alignItems: 'center',
+ justifyContent: 'center',
+ gap: 16,
+ },
+ emptyStateText: {
+ color: '#ABABAB',
+ fontSize: 14,
+ },
+ retryButton: {
+ backgroundColor: '#FF6699',
+ paddingHorizontal: 24,
+ paddingVertical: 12,
+ borderRadius: 8,
+ },
+ retryButtonText: {
+ color: '#FFFFFF',
+ fontSize: 14,
+ fontWeight: '600',
+ },
})
diff --git a/bun.lock b/bun.lock
index 39a0bca..97dad8c 100644
--- a/bun.lock
+++ b/bun.lock
@@ -13,8 +13,8 @@
"@react-navigation/bottom-tabs": "^7.4.0",
"@react-navigation/elements": "^2.6.3",
"@react-navigation/native": "^7.1.8",
- "@repo/core": "1.0.1",
- "@repo/sdk": "1.0.4",
+ "@repo/core": "1.0.2",
+ "@repo/sdk": "1.0.7",
"@stripe/react-stripe-js": "^5.4.1",
"@stripe/stripe-js": "^8.5.3",
"@stripe/stripe-react-native": "^0.57.0",
@@ -625,9 +625,9 @@
"@react-navigation/routers": ["@react-navigation/routers@7.5.2", "", { "dependencies": { "nanoid": "^3.3.11" } }, "sha512-kymreY5aeTz843E+iPAukrsOtc7nabAH6novtAPREmmGu77dQpfxPB2ZWpKb5nRErIRowp1kYRoN2Ckl+S6JYw=="],
- "@repo/core": ["@repo/core@1.0.1", "https://gitea.bowongai.com/api/packages/bowong/npm/%40repo%2Fcore/-/1.0.1/core-1.0.1.tgz", {}, "sha512-dzdae2NBT0L4GWCtz6PscmaRvElGFXWeJ46vQhDYc2z49wjnRYRxZgIcwB5bxXjfYZF3sj0cnbbs5mz8F16oAw=="],
+ "@repo/core": ["@repo/core@1.0.2", "https://gitea.bowongai.com/api/packages/bowong/npm/%40repo%2Fcore/-/1.0.2/core-1.0.2.tgz", {}, "sha512-/d1L9I+9u8rHiWBNXW2GC5hB6okd/EoePpcuiJrbbtRH8rZuQOVtdRDuDHIokrL0eFN9rEi3zaoFdvyZgD0hrg=="],
- "@repo/sdk": ["@repo/sdk@1.0.4", "https://gitea.bowongai.com/api/packages/bowong/npm/%40repo%2Fsdk/-/1.0.4/sdk-1.0.4.tgz", { "dependencies": { "@repo/core": "1.0.1", "reflect-metadata": "^0.2.1", "zod": "^4.2.1" } }, "sha512-uMjxK4G2aEvssshtcxBIZsDSnP2JYLC8ClB/GrstjKeBuElbZKTAomaeIL8PmZILfIA96Jq6sWKh5ZlKVK2jfg=="],
+ "@repo/sdk": ["@repo/sdk@1.0.7", "https://gitea.bowongai.com/api/packages/bowong/npm/%40repo%2Fsdk/-/1.0.7/sdk-1.0.7.tgz", { "dependencies": { "@repo/core": "1.0.2", "reflect-metadata": "^0.2.1", "zod": "^4.2.1" } }, "sha512-KQ8bgj3fA85xFN3X6rVkM19wk5NhXoc3un5jEUn05xBXGmWtDLZ1TgbF1vR1fSz8XBejnxnlb6c9AtqjDthFPw=="],
"@rtsao/scc": ["@rtsao/scc@1.1.0", "", {}, "sha512-zt6OdqaDoOnJ1ZYsCYGt9YmWzDXl4vQdKTyJev62gFhRGKdx7mcT54V9KIjg+d2wi9EXsPvAPKe7i7WjfVWB8g=="],
diff --git a/hooks/index.ts b/hooks/index.ts
index 116628f..cae00b8 100644
--- a/hooks/index.ts
+++ b/hooks/index.ts
@@ -4,3 +4,5 @@ export { useTemplateActions } from './use-template-actions'
export { useTemplates, type TemplateDetail } from './use-templates'
export { useTemplateDetail, type TemplateDetail as TemplateDetailType } from './use-template-detail'
export { useTemplateGenerations, type TemplateGeneration } from './use-template-generations'
+export { useSearchHistory } from './use-search-history'
+export { useTags } from './use-tags'
diff --git a/hooks/use-search-history.ts b/hooks/use-search-history.ts
new file mode 100644
index 0000000..ee2832f
--- /dev/null
+++ b/hooks/use-search-history.ts
@@ -0,0 +1,74 @@
+import { useCallback, useEffect, useState } from 'react'
+import AsyncStorage from '@react-native-async-storage/async-storage'
+
+const STORAGE_KEY = '@searchHistory'
+const MAX_HISTORY_LENGTH = 10
+
+interface UseSearchHistoryReturn {
+ history: string[]
+ addToHistory: (keyword: string) => Promise
+ removeFromHistory: (keyword: string) => Promise
+ clearHistory: () => Promise
+ isLoading: boolean
+}
+
+export function useSearchHistory(): UseSearchHistoryReturn {
+ const [history, setHistory] = useState([])
+ const [isLoading, setIsLoading] = useState(true)
+
+ useEffect(() => {
+ loadHistory()
+ }, [])
+
+ const loadHistory = useCallback(async () => {
+ try {
+ const stored = await AsyncStorage.getItem(STORAGE_KEY)
+ setHistory(stored ? JSON.parse(stored) : [])
+ } catch {
+ setHistory([])
+ } finally {
+ setIsLoading(false)
+ }
+ }, [])
+
+ const saveHistory = useCallback(async (newHistory: string[]) => {
+ try {
+ await AsyncStorage.setItem(STORAGE_KEY, JSON.stringify(newHistory))
+ setHistory(newHistory)
+ } catch {
+ setHistory(newHistory)
+ }
+ }, [])
+
+ const addToHistory = useCallback(
+ async (keyword: string) => {
+ const trimmed = keyword.trim()
+ if (!trimmed) return
+
+ const filtered = history.filter((item) => item !== trimmed)
+ const updated = [trimmed, ...filtered].slice(0, MAX_HISTORY_LENGTH)
+ await saveHistory(updated)
+ },
+ [history, saveHistory]
+ )
+
+ const removeFromHistory = useCallback(
+ async (keyword: string) => {
+ const updated = history.filter((item) => item !== keyword)
+ await saveHistory(updated)
+ },
+ [history, saveHistory]
+ )
+
+ const clearHistory = useCallback(async () => {
+ await saveHistory([])
+ }, [saveHistory])
+
+ return {
+ history,
+ addToHistory,
+ removeFromHistory,
+ clearHistory,
+ isLoading,
+ }
+}
diff --git a/hooks/use-templates.ts b/hooks/use-templates.ts
index a444b27..761aa71 100644
--- a/hooks/use-templates.ts
+++ b/hooks/use-templates.ts
@@ -49,7 +49,9 @@ export const useTemplates = (initialParams?: ListTemplatesParams) => {
}
const templates = data?.templates || []
- hasMoreRef.current = templates.length >= (params?.limit || DEFAULT_PARAMS.limit)
+ const currentPage = requestParams.page || 1
+ const totalPages = data?.totalPages || 1
+ hasMoreRef.current = currentPage < totalPages
setData(data)
setLoading(false)
return { data, error: null }
@@ -79,7 +81,8 @@ export const useTemplates = (initialParams?: ListTemplatesParams) => {
}
const newTemplates = newData?.templates || []
- hasMoreRef.current = newTemplates.length >= DEFAULT_PARAMS.limit
+ const totalPages = newData?.totalPages || 1
+ hasMoreRef.current = nextPage < totalPages
currentPageRef.current = nextPage
setData((prev) => ({
diff --git a/locales/en-US.json b/locales/en-US.json
index 89933d0..ffd7f66 100644
--- a/locales/en-US.json
+++ b/locales/en-US.json
@@ -93,7 +93,9 @@
},
"channels": {
"title": "All Channels",
- "channelName": "Channel Name"
+ "channelName": "Channel Name",
+ "noCategories": "No categories available",
+ "retry": "Retry"
},
"notFound": {
"title": "Page Not Found",
@@ -120,7 +122,8 @@
"exploreMore": "Explore More",
"refresh": "Refresh",
"searchWorks": "Search Generated Works",
- "searchWorksPlaceholder": "Enter keywords to search generated works"
+ "searchWorksPlaceholder": "Enter keywords to search generated works",
+ "noTags": "No recommended tags"
},
"templateDetail": {
"title": "Hello, I'm a new resident of Zootopia 👋",
diff --git a/locales/zh-CN.json b/locales/zh-CN.json
index 7071f0e..fbd0b57 100644
--- a/locales/zh-CN.json
+++ b/locales/zh-CN.json
@@ -93,7 +93,9 @@
},
"channels": {
"title": "全部频道",
- "channelName": "频道名称"
+ "channelName": "频道名称",
+ "noCategories": "暂无分类",
+ "retry": "重新加载"
},
"notFound": {
"title": "页面未找到",
@@ -120,7 +122,8 @@
"exploreMore": "探索更多",
"refresh": "换一换",
"searchWorks": "搜索生成的作品",
- "searchWorksPlaceholder": "请输入关键词搜索生成的作品"
+ "searchWorksPlaceholder": "请输入关键词搜索生成的作品",
+ "noTags": "暂无推荐标签"
},
"templateDetail": {
"title": "泥嚎 我是动物城的新居民 👋",
diff --git a/package.json b/package.json
index 9f1ff08..cf8d4b2 100644
--- a/package.json
+++ b/package.json
@@ -16,8 +16,8 @@
"lint": "expo lint"
},
"dependencies": {
- "@repo/core": "1.0.1",
- "@repo/sdk": "1.0.4",
+ "@repo/core": "1.0.2",
+ "@repo/sdk": "1.0.7",
"@better-auth/expo": "1.3.34",
"@expo/vector-icons": "^15.0.3",
"@gorhom/bottom-sheet": "^5.2.8",