diff --git a/app/templateDetail.tsx b/app/templateDetail.tsx index 0b8f219..a1235de 100644 --- a/app/templateDetail.tsx +++ b/app/templateDetail.tsx @@ -1,66 +1,79 @@ +import { useEffect } from 'react' import { View, Text, StyleSheet, Pressable, StatusBar as RNStatusBar, + ActivityIndicator, } from 'react-native' import { StatusBar } from 'expo-status-bar' 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 { LeftArrowIcon } from '@/components/icon' import SearchResultsGrid from '@/components/SearchResultsGrid' +import { useTemplateDetail, useTemplateGenerations, type TemplateGeneration } from '@/hooks' -// 模板详情数据 - 根据实际需求可以改为从 API 获取 -const templateDetailData = [ - { - id: 1, - title: '猫咪圣诞写真', - image: require('@/assets/images/android-icon-background.png'), - height: 214, - }, - { - id: 2, - title: '猫咪圣诞写真', - image: require('@/assets/images/android-icon-background.png'), - height: 236, - }, - { - id: 3, - title: '猫咪圣诞写真', - image: require('@/assets/images/favicon.png'), - height: 214, - }, - { - id: 4, - title: '猫咪圣诞写真', - image: require('@/assets/images/icon.png'), - height: 200, - }, - { - id: 5, - title: '猫咪圣诞写真', - image: require('@/assets/images/android-icon-background.png'), - height: 220, - }, - { - id: 6, - title: '猫咪圣诞写真', - image: require('@/assets/images/android-icon-background.png'), - height: 214, - }, -] +const CARD_HEIGHTS = [214, 236, 200, 220, 210, 225] export default function TemplateDetailScreen() { const { t } = useTranslation() const router = useRouter() + const params = useLocalSearchParams() + const templateId = typeof params.id === 'string' ? params.id : undefined + + const { data: templateDetail, loading: templateLoading, error: templateError, execute: loadTemplateDetail } = useTemplateDetail() + const { generations, loading: generationsLoading, execute: loadGenerations } = useTemplateGenerations() + + useEffect(() => { + if (templateId) { + loadTemplateDetail({ id: templateId }) + loadGenerations({ templateId, page: 1, limit: 20 }) + } + }, [templateId, loadTemplateDetail, loadGenerations]) + + // 直接使用 TemplateGeneration 数据,只添加必要的 height 字段 + const displayData = generations.map((generation, index) => ({ + ...generation, + height: CARD_HEIGHTS[index % CARD_HEIGHTS.length], + title: generation.template?.title || generation.template?.titleEn || '', + image: generation.resultUrl?.[0] || generation.originalUrl || '', + })) + + if (templateLoading && !templateDetail) { + return ( + + + + + + + + ) + } + + if (templateError && !templateDetail) { + return ( + + + + + 加载失败,请返回重试 + router.back()}> + 返回 + + + + ) + } + return ( - + {/* 顶部导航栏 */} - {t('templateDetail.title')} + {templateDetail?.title || templateDetail?.titleEn || t('templateDetail.title')} {t('templateDetail.subtitle')} - {/* 图片网格 */} - + {/* 加载更多指示器 */} + {generationsLoading && generations.length > 0 && ( + + + 加载中... + + )} + + ) } @@ -92,6 +112,12 @@ const styles = StyleSheet.create({ flex: 1, backgroundColor: '#090A0B', }, + centerContainer: { + flex: 1, + alignItems: 'center', + justifyContent: 'center', + gap: 12, + }, header: { flexDirection: 'row', alignItems: 'center', @@ -117,5 +143,32 @@ const styles = StyleSheet.create({ fontSize: 14, fontWeight: '400', }, + errorText: { + color: '#FFFFFF', + fontSize: 14, + textAlign: 'center', + }, + retryButton: { + backgroundColor: '#FF6699', + paddingHorizontal: 24, + paddingVertical: 12, + borderRadius: 8, + }, + retryButtonText: { + color: '#FFFFFF', + fontSize: 14, + fontWeight: '600', + }, + loadingMoreContainer: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'center', + gap: 8, + paddingVertical: 16, + }, + loadingMoreText: { + color: '#ABABAB', + fontSize: 12, + }, }) diff --git a/components/SearchResultsGrid.tsx b/components/SearchResultsGrid.tsx index 487f6f3..6df04d7 100644 --- a/components/SearchResultsGrid.tsx +++ b/components/SearchResultsGrid.tsx @@ -12,17 +12,14 @@ import { useRouter } from 'expo-router' import { useTranslation } from 'react-i18next' import { WhiteStarIcon } from '@/components/icon' +import type { TemplateGeneration } from '@/hooks' const { width: screenWidth } = Dimensions.get('window') -interface SearchResultItem { - id: number - title: string - image: any +interface SearchResultItem extends TemplateGeneration { height: number - videoUrl?: any - thumbnailUrl?: any - duration?: string + title: string + image: string } interface SearchResultsGridProps { @@ -33,27 +30,24 @@ export default function SearchResultsGrid({ results }: SearchResultsGridProps) { const { t } = useTranslation() const router = useRouter() const [gridWidth, setGridWidth] = useState(screenWidth) - - const horizontalPadding = 8 * 2 // gridContainer 的左右 padding - const cardGap = 5 // 两个卡片之间的间距 + + const horizontalPadding = 8 * 2 + const cardGap = 5 const cardWidth = (gridWidth - horizontalPadding - cardGap) / 2 const handleSameStylePress = (item: SearchResultItem) => { - // 构建模板数据,传递给 generateVideo 页面 const templateData = { - id: item.id, - videoUrl: item.videoUrl || item.image, // 如果没有 videoUrl,使用 image - thumbnailUrl: item.thumbnailUrl || item.image, // 如果没有 thumbnailUrl,使用 image - title: item.title, - duration: item.duration || '00:00', // 默认时长 + id: item.template?.id, + videoUrl: item.resultUrl?.[0] || item.originalUrl, + thumbnailUrl: item.template?.coverImageUrl, + title: item.template?.title || item.template?.titleEn || '', } router.push({ pathname: '/generateVideo' as any, - params: { - template: JSON.stringify(templateData), - }, + params: { template: JSON.stringify(templateData) }, } as any) } + if (results.length === 0) { return ( @@ -61,6 +55,7 @@ export default function SearchResultsGrid({ results }: SearchResultsGridProps) { ) } + return ( - + {item.title} @@ -116,9 +104,6 @@ const styles = StyleSheet.create({ scrollView: { flex: 1, }, - scrollContent: { - // paddingBottom: 100, - }, gridContainer: { flexDirection: 'row', flexWrap: 'wrap', @@ -142,18 +127,23 @@ const styles = StyleSheet.create({ borderRadius: 12, overflow: 'hidden', marginBottom: 8, - position: 'relative', }, cardImage: { width: '100%', height: '100%', }, + cardTitle: { + color: '#FFFFFF', + fontSize: 12, + fontWeight: '500', + paddingHorizontal: 8, + }, sameStyleButton: { flexDirection: 'row', alignItems: 'center', gap: 4, backgroundColor: '#262A31', - height:32, + height: 32, marginHorizontal: 8, marginTop: 10, marginBottom: 8, @@ -166,12 +156,6 @@ const styles = StyleSheet.create({ fontWeight: '500', lineHeight: 17, }, - cardTitle: { - color: '#FFFFFF', - fontSize: 12, - fontWeight: '500', - paddingHorizontal: 8, - }, emptyContainer: { flex: 1, backgroundColor: '#090A0B', diff --git a/hooks/index.ts b/hooks/index.ts new file mode 100644 index 0000000..116628f --- /dev/null +++ b/hooks/index.ts @@ -0,0 +1,6 @@ +export { useActivates } from './use-activates' +export { useCategories } from './use-categories' +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' diff --git a/hooks/use-activates.ts b/hooks/use-activates.ts index 6b18a3e..6b07705 100644 --- a/hooks/use-activates.ts +++ b/hooks/use-activates.ts @@ -1,17 +1,38 @@ -import { OWNER_ID } from "@/lib/auth" -import { ApiError } from "@/lib/types" import { root } from '@repo/core' -import { ActivityController, ListActivitiesResult } from "@repo/sdk" -import { useState } from "react" +import { ActivityController, type ListActivitiesInput, type ListActivitiesResult } from '@repo/sdk' +import { useState } from 'react' + +import { type ApiError } from '@/lib/types' + +import { handleError } from './use-error' + +const OWNER_ID = process.env.EXPO_PUBLIC_OWNER_ID || '' + export const useActivates = () => { const [loading, setLoading] = useState(false) const [error, setError] = useState(null) const [data, setData] = useState() - const load = async () => { + + const load = async (params?: ListActivitiesInput) => { try { setLoading(true) - const c = root.get(ActivityController) - const data = await c.list({ page: 1, limit: 10, isActive: true, orderBy: 'sortOrder', order: 'desc', ownerId: OWNER_ID }) + const activity = root.get(ActivityController) + const { data, error } = await handleError( + async () => + await activity.list({ + page: 1, + limit: 10, + isActive: true, + orderBy: 'sortOrder', + order: 'desc', + ownerId: OWNER_ID, + ...params, + }), + ) + if (error) { + setError(error) + return + } setData(data) } catch (e) { setError(e as ApiError) @@ -24,6 +45,6 @@ export const useActivates = () => { load, loading, error, - data + data, } -} \ No newline at end of file +} diff --git a/hooks/use-categories.ts b/hooks/use-categories.ts new file mode 100644 index 0000000..6bd2f41 --- /dev/null +++ b/hooks/use-categories.ts @@ -0,0 +1,52 @@ +import { root } from '@repo/core' +import { CategoryController, type ListCategoriesInput, type ListCategoriesResult } from '@repo/sdk' +import { useState } from 'react' + +import { type ApiError } from '@/lib/types' + +import { handleError } from './use-error' + +const OWNER_ID = process.env.EXPO_PUBLIC_OWNER_ID || '' + +export const useCategories = () => { + const [loading, setLoading] = useState(false) + const [error, setError] = useState(null) + const [data, setData] = useState() + + const load = async (params?: ListCategoriesInput) => { + try { + setLoading(true) + const category = root.get(CategoryController) + const { data, error } = await handleError( + async () => + await category.list({ + page: 1, + limit: 1000, + isActive: true, + orderBy: 'sortOrder', + order: 'desc', + withChildren: true, + ownerId: OWNER_ID, + ...params, + }), + ) + 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-error.ts b/hooks/use-error.ts new file mode 100644 index 0000000..3c244c6 --- /dev/null +++ b/hooks/use-error.ts @@ -0,0 +1,10 @@ +import { type ApiError } from '@/lib/types' + +export async function handleError(cb: () => Promise) { + try { + const data = await cb() + return { data, error: null } + } catch (e) { + return { data: null, error: e as ApiError } + } +} diff --git a/hooks/use-template-detail.ts b/hooks/use-template-detail.ts new file mode 100644 index 0000000..784ead2 --- /dev/null +++ b/hooks/use-template-detail.ts @@ -0,0 +1,52 @@ +import { root } from '@repo/core' +import { TemplateController, type TemplateDetail } from '@repo/sdk' +import { useCallback, useState } from 'react' + +import { type ApiError } from '@/lib/types' + +import { handleError } from './use-error' + +interface UseTemplateDetailParams { + id: string +} + +export const useTemplateDetail = () => { + const [loading, setLoading] = useState(false) + const [error, setError] = useState(null) + const [data, setData] = useState() + + const execute = useCallback(async (params: UseTemplateDetailParams) => { + setLoading(true) + setError(null) + + const template = root.get(TemplateController) + const { data, error } = await handleError(async () => await template.get(params.id)) + + if (error) { + setError(error) + setLoading(false) + return { data: undefined, error } + } + + setData(data) + setLoading(false) + return { data, error: null } + }, []) + + const refetch = useCallback( + (params: UseTemplateDetailParams) => { + return execute(params) + }, + [execute], + ) + + return { + data, + loading, + error, + execute, + refetch, + } +} + +export type { TemplateDetail } diff --git a/hooks/use-template-generations.ts b/hooks/use-template-generations.ts new file mode 100644 index 0000000..727725f --- /dev/null +++ b/hooks/use-template-generations.ts @@ -0,0 +1,108 @@ +import { root } from '@repo/core' +import { + TemplateGenerationController, + type ListTemplateGenerationsInput, + type ListTemplateGenerationsResult, + type TemplateGeneration, +} from '@repo/sdk' +import { useCallback, useRef, useState } from 'react' + +import { type ApiError } from '@/lib/types' + +import { handleError } from './use-error' + +const OWNER_ID = process.env.EXPO_PUBLIC_OWNER_ID || '' + +export const useTemplateGenerations = () => { + const [loading, setLoading] = useState(false) + const [loadingMore, setLoadingMore] = useState(false) + const [error, setError] = useState(null) + const [data, setData] = useState() + const currentPageRef = useRef(1) + const hasMoreRef = useRef(true) + + const execute = useCallback(async (params?: ListTemplateGenerationsInput) => { + setLoading(true) + setError(null) + currentPageRef.current = params?.page || 1 + + const templateGeneration = root.get(TemplateGenerationController) + const { data, error } = await handleError( + async () => + await templateGeneration.list({ + page: params?.page || 1, + limit: params?.limit || 20, + ...params, + }), + ) + if (error) { + setError(error) + setLoading(false) + return { data: undefined, error } + } + + const generations = data?.data || [] + hasMoreRef.current = generations.length >= (params?.limit || 20) + setData(data) + setLoading(false) + return { data, error: null } + }, []) + + const loadMore = useCallback( + async (params?: Omit) => { + if (loadingMore || loading || !hasMoreRef.current) return { data: undefined, error: null } + + setLoadingMore(true) + const nextPage = currentPageRef.current + 1 + + const templateGeneration = root.get(TemplateGenerationController) + const { data: newData, error } = await handleError( + async () => + await templateGeneration.list({ + page: nextPage, + limit: params?.limit || 20, + ...params, + }), + ) + + if (error) { + setLoadingMore(false) + return { data: undefined, error } + } + + const newGenerations = newData?.data || [] + hasMoreRef.current = newGenerations.length >= (params?.limit || 20) + currentPageRef.current = nextPage + + setData((prev) => ({ + ...newData, + data: [...(prev?.data || []), ...newGenerations], + })) + setLoadingMore(false) + return { data: newData, error: null } + }, + [loading, loadingMore], + ) + + const refetch = useCallback( + (params?: ListTemplateGenerationsInput) => { + hasMoreRef.current = true + return execute(params) + }, + [execute], + ) + + return { + data, + generations: data?.data || [], + loading, + loadingMore, + error, + execute, + refetch, + loadMore, + hasMore: hasMoreRef.current, + } +} + +export type { TemplateGeneration } diff --git a/hooks/use-templates.ts b/hooks/use-templates.ts new file mode 100644 index 0000000..2619621 --- /dev/null +++ b/hooks/use-templates.ts @@ -0,0 +1,109 @@ +import { root } from '@repo/core' +import { type ListTemplatesInput, type ListTemplatesResult, TemplateController, type TemplateDetail } from '@repo/sdk' +import { useCallback, useRef, useState } from 'react' + +import { type ApiError } from '@/lib/types' + +import { handleError } from './use-error' + +const OWNER_ID = process.env.EXPO_PUBLIC_OWNER_ID || '' + +type ListTemplatesParams = Omit + +const DEFAULT_PARAMS = { + limit: 20, + sortBy: 'createdAt' as const, + sortOrder: 'desc' as const, +} + +export const useTemplates = (initialParams?: ListTemplatesParams) => { + const [loading, setLoading] = useState(false) + const [loadingMore, setLoadingMore] = useState(false) + const [error, setError] = useState(null) + const [data, setData] = useState() + const currentPageRef = useRef(1) + const hasMoreRef = useRef(true) + + const execute = useCallback(async (params?: ListTemplatesParams) => { + setLoading(true) + setError(null) + currentPageRef.current = params?.page || 1 + + const template = root.get(TemplateController) + const { data, error } = await handleError( + async () => + await template.list({ + ...DEFAULT_PARAMS, + ...initialParams, + ...params, + ownerId: OWNER_ID, + }), + ) + + if (error) { + setError(error) + setLoading(false) + return { data: undefined, error } + } + + const templates = data?.templates || [] + hasMoreRef.current = templates.length >= (params?.limit || DEFAULT_PARAMS.limit) + setData(data) + setLoading(false) + return { data, error: null } + }, [initialParams]) + + const loadMore = useCallback(async () => { + if (loadingMore || loading || !hasMoreRef.current) return { data: undefined, error: null } + + setLoadingMore(true) + const nextPage = currentPageRef.current + 1 + + const template = root.get(TemplateController) + const { data: newData, error } = await handleError( + async () => + await template.list({ + ...DEFAULT_PARAMS, + ...initialParams, + page: nextPage, + ownerId: OWNER_ID, + }), + ) + + if (error) { + setLoadingMore(false) + return { data: undefined, error } + } + + const newTemplates = newData?.templates || [] + hasMoreRef.current = newTemplates.length >= DEFAULT_PARAMS.limit + currentPageRef.current = nextPage + + setData((prev) => ({ + ...newData, + templates: [...(prev?.templates || []), ...newTemplates], + })) + setLoadingMore(false) + return { data: newData, error: null } + }, [loading, loadingMore, initialParams]) + + const refetch = useCallback(() => { + hasMoreRef.current = true + return execute() + }, [execute]) + + return { + data, + templates: data?.templates || [], + loading, + loadingMore, + error, + execute, + refetch, + loadMore, + hasMore: hasMoreRef.current, + } +} + +export type { TemplateDetail } +