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,
}