diff --git a/app/(tabs)/video.tsx b/app/(tabs)/video.tsx index eafbd34..cdd55eb 100644 --- a/app/(tabs)/video.tsx +++ b/app/(tabs)/video.tsx @@ -129,6 +129,8 @@ export default function VideoScreen() { const { templates, loading, error, execute, refetch, loadMore, hasMore } = useTemplates({ sortBy: 'likeCount', sortOrder: 'desc', + page: 1, + limit: 20, }) const [refreshing, setRefreshing] = useState(false) diff --git a/app/searchResults.tsx b/app/searchResults.tsx index b8c6a05..bf42bfb 100644 --- a/app/searchResults.tsx +++ b/app/searchResults.tsx @@ -1,8 +1,9 @@ -import { useState } from 'react' +import { useState, useEffect } from 'react' import { View, StyleSheet, StatusBar as RNStatusBar, + RefreshControl, } from 'react-native' import { StatusBar } from 'expo-status-bar' import { SafeAreaView } from 'react-native-safe-area-context' @@ -10,44 +11,52 @@ import { useRouter, useLocalSearchParams } from 'expo-router' import SearchResultsGrid from '@/components/SearchResultsGrid' import SearchBar from '@/components/SearchBar' -import type { TemplateGeneration } from '@/hooks' - -const createMockResult = (id: string, height: number): TemplateGeneration & { height: number; title: string; image: any } => ({ - id, - userId: 'test', - templateId: `template-${id}`, - type: 'VIDEO', - resultUrl: [], - status: 'completed', - createdAt: new Date(), - updatedAt: new Date(), - title: '猫咪圣诞写真', - image: require('@/assets/images/android-icon-background.png'), - height, - template: { - id: `template-${id}`, - title: '猫咪圣诞写真', - titleEn: 'Cat Christmas', - coverImageUrl: '', - }, -}) - -const searchResults = [ - createMockResult('1', 236), - createMockResult('2', 131), - createMockResult('3', 236), - createMockResult('4', 236), - createMockResult('5', 95), - createMockResult('6', 236), - createMockResult('7', 228), - createMockResult('8', 95), - createMockResult('9', 228), -] +import { useTemplates, useSearchHistory } from '@/hooks' export default function SearchResultsScreen() { const router = useRouter() const params = useLocalSearchParams() const [searchText, setSearchText] = useState((params.q as string) || '') + const [refreshing, setRefreshing] = useState(false) + + // 使用 useTemplates hook 进行搜索 + const { templates, loading, error, execute, refetch } = useTemplates() + + // 使用搜索历史 hook + const { addToHistory } = useSearchHistory() + + // 当搜索关键词变化时,执行搜索 + useEffect(() => { + if (params.q && typeof params.q === 'string') { + const query = params.q.trim() + setSearchText(query) + if (query) { + // 执行搜索 + execute({ search: query, limit: 50, sortBy: 'createdAt', sortOrder: 'desc', page: 1 }) + // 添加到搜索历史 + addToHistory(query) + } + } + }, [params.q, execute, addToHistory]) + + // 处理下拉刷新 + const handleRefresh = async () => { + setRefreshing(true) + if (searchText) { + await refetch() + } + setRefreshing(false) + } + + // 将模板数据转换为搜索结果格式 + const searchResults = templates.map(template => ({ + id: template.id, + title: template.title || template.titleEn || '', + image: { uri: template.previewUrl || template.coverImageUrl || '' }, + previewUrl: template.previewUrl, + coverImageUrl: template.coverImageUrl, + aspectRatio: template.aspectRatio ? parseFloat(template.aspectRatio as string) : undefined, + })) return ( @@ -57,7 +66,12 @@ export default function SearchResultsScreen() { {}} + onSearch={(text) => { + router.push({ + pathname: '/searchTemplate', + params: { q: text, focus: 'true' }, + }) + }} onBack={() => router.back()} readOnly={true} onInputPress={() => { @@ -75,7 +89,10 @@ export default function SearchResultsScreen() { marginBottom={12} /> - + ) } diff --git a/app/searchTemplate.tsx b/app/searchTemplate.tsx index 9b349bf..128f0a7 100644 --- a/app/searchTemplate.tsx +++ b/app/searchTemplate.tsx @@ -8,6 +8,7 @@ import { StatusBar as RNStatusBar, Dimensions, TextInput, + ActivityIndicator, } from 'react-native' import { StatusBar } from 'expo-status-bar' import { SafeAreaView } from 'react-native-safe-area-context' @@ -16,39 +17,41 @@ import { useTranslation } from 'react-i18next' import { DeleteIcon, ChangeIcon, Close1Icon } from '@/components/icon' import SearchBar from '@/components/SearchBar' +import { useSearchHistory, useTags } from '@/hooks' const { width: screenWidth } = Dimensions.get('window') -// 探索更多数据 -const exploreMore = [ - '照片自动唱歌', - '头像ai漫画', - '冬天第一束花', - '真人唱歌动图', - '背景替换', - '闪电动态头像', - '用自己的头像唱歌', - '动漫头像', - '人像', - '戳戳手', -] - export default function SearchTemplateScreen() { const { t } = useTranslation() const router = useRouter() const params = useLocalSearchParams() - const [searchText, setSearchText] = useState('猫咪') + const [searchText, setSearchText] = useState('') const [isDeleteMode, setIsDeleteMode] = useState(false) - const [searchHistory, setSearchHistory] = useState(['猫咪写真']) const inputRef = useRef(null) const isNavigatingRef = useRef(false) + // 使用搜索历史 hook + const { history: searchHistory, addToHistory, removeFromHistory, clearHistory, isLoading: historyLoading } = useSearchHistory() + + // 使用推荐标签 hook + const { load: loadTags, data: tagsData, loading: tagsLoading } = useTags() + + useEffect(() => { + // 加载推荐标签 + loadTags({ page: 1, limit: 20, orderBy: 'sortOrder', order: 'desc' }) + }, []) + // 当从搜索结果页面返回时,更新搜索文本 useEffect(() => { if (params.q && typeof params.q === 'string') { setSearchText(params.q) + // 将搜索关键词添加到历史记录 + addToHistory(params.q) } - }, [params.q]) + }, [params.q, addToHistory]) + + // 推荐标签数据 + const recommendedTags = tagsData?.tags?.map(tag => tag.name) || [] return ( @@ -60,6 +63,8 @@ export default function SearchTemplateScreen() { searchText={searchText} onSearchTextChange={setSearchText} onSearch={(text) => { + // 执行搜索时,添加到历史记录 + addToHistory(text) router.push({ pathname: '/searchResults', params: { q: text }, @@ -72,51 +77,106 @@ export default function SearchTemplateScreen() { {/* 搜索历史和推荐标签 */} - {/* 搜索历史 */} - - - {t('search.history')} - {isDeleteMode ? ( - + {!historyLoading && searchHistory.length > 0 && ( + + + {t('search.history')} + {isDeleteMode ? ( + + { + clearHistory() + setIsDeleteMode(false) + }} + > + {t('search.clearAll')} + + + setIsDeleteMode(false)} + > + {t('search.done')} + + + ) : ( { - setSearchHistory([]) - setIsDeleteMode(false) - }} + onPress={() => setIsDeleteMode(true)} > - {t('search.clearAll')} + - - setIsDeleteMode(false)} + )} + + + {searchHistory.map((item, index) => ( + - {t('search.done')} - - - ) : ( - setIsDeleteMode(true)} - > - - - )} + { + if (!isDeleteMode && !isNavigatingRef.current) { + isNavigatingRef.current = true + requestAnimationFrame(() => { + router.push({ + pathname: '/searchResults', + params: { q: item }, + }) + setTimeout(() => { + isNavigatingRef.current = false + }, 500) + }) + } + }} + > + {item} + {isDeleteMode && ( + { + removeFromHistory(item) + }} + > + + + )} + + + ))} + - - {searchHistory.map((item, index) => ( - + )} + + {/* 探索更多 - 推荐标签 */} + + + {t('search.exploreMore')} + loadTags({ page: 1, limit: 20, orderBy: 'sortOrder', order: 'desc' })} + > + + {t('search.refresh')} + + + {tagsLoading ? ( + + + + ) : recommendedTags.length > 0 ? ( + + {recommendedTags.map((item, index) => ( { - if (!isDeleteMode && !isNavigatingRef.current) { + if (!isNavigatingRef.current) { isNavigatingRef.current = true requestAnimationFrame(() => { + addToHistory(item) router.push({ pathname: '/searchResults', params: { q: item }, @@ -128,56 +188,15 @@ export default function SearchTemplateScreen() { } }} > - {item} - {isDeleteMode && ( - { - setSearchHistory(searchHistory.filter((_, i) => i !== index)) - }} - > - - - )} + {item} - - ))} - - - - {/* 探索更多 */} - - - {t('search.exploreMore')} - - - {t('search.refresh')} - - - - {exploreMore.map((item, index) => ( - { - if (!isNavigatingRef.current) { - isNavigatingRef.current = true - requestAnimationFrame(() => { - router.push({ - pathname: '/searchResults', - params: { q: item }, - }) - setTimeout(() => { - isNavigatingRef.current = false - }, 500) - }) - } - }} - > - {item} - - ))} - + ))} + + ) : ( + + {t('search.noTags') || '暂无推荐标签'} + + )} @@ -271,6 +290,20 @@ const styles = StyleSheet.create({ fontSize: 12, fontWeight: '400', }, + loadingContainer: { + paddingVertical: 24, + alignItems: 'center', + justifyContent: 'center', + }, + emptyContainer: { + paddingVertical: 40, + alignItems: 'center', + justifyContent: 'center', + }, + emptyText: { + color: '#ABABAB', + fontSize: 14, + }, exploreTags: { flexDirection: 'row', flexWrap: 'wrap', @@ -287,4 +320,3 @@ const styles = StyleSheet.create({ fontWeight: '400', }, }) - diff --git a/components/SearchResultsGrid.tsx b/components/SearchResultsGrid.tsx index 693bf0d..078e57a 100644 --- a/components/SearchResultsGrid.tsx +++ b/components/SearchResultsGrid.tsx @@ -6,27 +6,42 @@ import { ScrollView, Dimensions, Pressable, + ActivityIndicator, } from 'react-native' import { Image } from 'expo-image' import { useRouter } from 'expo-router' import { useTranslation } from 'react-i18next' import { WhiteStarIcon } from '@/components/icon' -import type { TemplateGeneration } from '@/hooks' +import type { TemplateDetail } from '@/hooks' const { width: screenWidth } = Dimensions.get('window') -interface SearchResultItem extends TemplateGeneration { - height: number +interface TemplateSearchResultItem { + id: string title: string - image: string + image: string | { uri: string } + previewUrl?: string + coverImageUrl?: string + aspectRatio?: number + height?: number } interface SearchResultsGridProps { - results: SearchResultItem[] + results: TemplateSearchResultItem[] + loading?: boolean } -export default function SearchResultsGrid({ results }: SearchResultsGridProps) { +// 计算卡片高度的辅助函数 +const calculateCardHeight = (width: number, aspectRatio?: number): number => { + if (aspectRatio) { + return width / aspectRatio + } + // 默认宽高比 + return width * 1.2 +} + +export default function SearchResultsGrid({ results, loading }: SearchResultsGridProps) { const { t } = useTranslation() const router = useRouter() const [gridWidth, setGridWidth] = useState(screenWidth) @@ -35,17 +50,19 @@ export default function SearchResultsGrid({ results }: SearchResultsGridProps) { const cardGap = 5 const cardWidth = (gridWidth - horizontalPadding - cardGap) / 2 - const handleSameStylePress = (item: SearchResultItem) => { - const templateData = { - id: item.template?.id, - videoUrl: item.resultUrl?.[0] || item.originalUrl, - thumbnailUrl: item.template?.coverImageUrl, - title: item.template?.title || item.template?.titleEn || '', - } + const handleCardPress = (item: TemplateSearchResultItem) => { router.push({ - pathname: '/generateVideo' as any, - params: { template: JSON.stringify(templateData) }, - } as any) + pathname: '/templateDetail' as any, + params: { id: item.id.toString() }, + }) + } + + if (loading) { + return ( + + + + ) } if (results.length === 0) { @@ -68,38 +85,44 @@ export default function SearchResultsGrid({ results }: SearchResultsGridProps) { setGridWidth(width) }} > - {results.map((item, index) => ( - - - - - - {item.title} - + {results.map((item, index) => { + const height = item.height || calculateCardHeight(cardWidth, item.aspectRatio) + + return ( handleSameStylePress(item)} + key={item.id} + style={[ + styles.card, + { width: cardWidth }, + index % 2 === 0 ? styles.cardLeft : styles.cardRight, + ]} + onPress={() => handleCardPress(item)} > - - {t('searchResults.makeSame')} + + + + {item.title} + + - - ))} + ) + })} ) } const styles = StyleSheet.create({ + loadingContainer: { + flex: 1, + justifyContent: 'center', + alignItems: 'center', + paddingVertical: 40, + }, scrollView: { flex: 1, }, @@ -114,6 +137,7 @@ const styles = StyleSheet.create({ borderBottomLeftRadius: 12, borderBottomRightRadius: 12, marginBottom: 12, + overflow: 'hidden', }, cardLeft: { marginRight: 0, @@ -126,44 +150,29 @@ const styles = StyleSheet.create({ borderRadius: 12, overflow: 'hidden', marginBottom: 8, + position: 'relative', }, cardImage: { width: '100%', height: '100%', }, cardTitle: { + position: 'absolute', + bottom: 8, + left: 8, color: '#FFFFFF', fontSize: 12, fontWeight: '500', - paddingHorizontal: 8, - }, - sameStyleButton: { - flexDirection: 'row', - alignItems: 'center', - gap: 4, - backgroundColor: '#262A31', - height: 32, - marginHorizontal: 8, - marginTop: 10, - marginBottom: 8, - borderRadius: 100, - justifyContent: 'center', - }, - sameStyleText: { - color: '#FFFFFF', - fontSize: 12, - fontWeight: '500', - lineHeight: 17, }, emptyContainer: { flex: 1, backgroundColor: '#090A0B', justifyContent: 'center', alignItems: 'center', + paddingVertical: 60, }, emptyText: { color: '#8A8A8A', fontSize: 14, }, }) - diff --git a/hooks/use-tags.ts b/hooks/use-tags.ts new file mode 100644 index 0000000..385c306 --- /dev/null +++ b/hooks/use-tags.ts @@ -0,0 +1,43 @@ +import { root } from '@repo/core' +import { type ListTagsInput, type ListTagsResult, TagController } from '@repo/sdk' +import { useState } from 'react' + +import { type ApiError } from '@/lib/types' + +import { handleError } from './use-error' + +export const useTags = () => { + const [loading, setLoading] = useState(false) + const [error, setError] = useState(null) + const [data, setData] = useState() + + const load = async (params?: ListTagsInput) => { + try { + setLoading(true) + const tag = root.get(TagController) + const { data, error } = await handleError( + async () => + await tag.list({ + page: params?.page || 1, + limit: params?.limit || 20, + orderBy: 'sortOrder', + order: 'desc', + ownerId: process.env.EXPO_PUBLIC_OWNER_ID || '', + }), + ) + + if (error) { + setError(error) + return + } + + setData(data) + } catch (e) { + setError(e as ApiError) + } finally { + setLoading(false) + } + } + + return { load, loading, error, data } +} diff --git a/hooks/use-template-actions.ts b/hooks/use-template-actions.ts index 77fc4b3..9cce64f 100644 --- a/hooks/use-template-actions.ts +++ b/hooks/use-template-actions.ts @@ -26,7 +26,7 @@ export const useTemplateActions = () => { return { error } } - return { generationId: data?.generationId, error: null } + return { generationId: data?.generationId } }, []) return { diff --git a/hooks/use-templates.ts b/hooks/use-templates.ts index 5987e30..a444b27 100644 --- a/hooks/use-templates.ts +++ b/hooks/use-templates.ts @@ -34,6 +34,7 @@ export const useTemplates = (initialParams?: ListTemplatesParams) => { ...DEFAULT_PARAMS, ...initialParams, ...params, + page: params?.page ?? initialParams?.page ?? 1, ownerId: OWNER_ID, }