bw-expo-app/app/(tabs)/home.tsx

281 lines
8.1 KiB
TypeScript

import { router } from 'expo-router';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { ScrollView, StyleSheet, View, type NativeScrollEvent, type NativeSyntheticEvent } from 'react-native';
import {
CategoryTabs,
CommunityGrid,
FeatureCarousel,
Header,
PageLayout,
SectionHeader,
StatusBarSpacer,
type CommunityItem,
type FeatureItem
} from '@/components/bestai';
import type { FeatureItem as FeatureItemType } from '@/components/bestai/feature-carousel';
import { useAuth } from '@/hooks/use-auth';
import { useAuthGuard } from '@/hooks/use-auth-guard';
import { getActivities, type Activity } from '@/lib/api/activities';
import { categoriesWithChildren } from '@/lib/api/categories-with-children';
import type { CategoryTemplate, CategoryWithChildren } from '@/lib/types/template';
function coalesceText(...values: Array<string | null | undefined>): string {
for (const value of values) {
const trimmed = (value ?? '').trim();
if (trimmed.length > 0) {
return trimmed;
}
}
return '';
}
function translateTemplateToCommunity(template: CategoryTemplate): CommunityItem | null {
const image = coalesceText(template.coverImageUrl, template.previewUrl);
const title = coalesceText(template.titleEn, template.title);
if (!image || !title) {
return null;
}
const primaryTag = template.tags?.[0];
const chip = coalesceText(primaryTag?.nameEn, primaryTag?.name, template.aspectRatio) || 'Featured';
return {
id: template.id,
title,
image,
chip,
actionLabel: 'Generate',
};
}
function translateActivity(activity: Activity): FeatureItem | null {
const image = (activity.coverUrl || '').trim();
const title = (activity.titleEn || '').trim() || (activity.title || '').trim();
if (!image || !title) {
return null;
}
const subtitle = (activity.descEn || '').trim() || (activity.desc || '').trim();
return {
id: activity.id,
title,
subtitle,
image,
};
}
export default function ExploreScreen() {
const { isAuthenticated } = useAuth();
const { requireAuth } = useAuthGuard();
const [activeCategory, setActiveCategory] = useState<string>('');
const [activityFeatures, setActivityFeatures] = useState<FeatureItem[]>([]);
const [categoryCollection, setCategoryCollection] = useState<CategoryWithChildren[]>([]);
const scrollViewRef = useRef<ScrollView>(null);
const categoryPositionsRef = useRef<Map<string, number>>(new Map());
const isScrollingProgrammaticallyRef = useRef(false);
useEffect(() => {
const hydrateFeatureCarousel = async () => {
try {
const activities = await getActivities({ isActive: true });
const curatedFeatures = activities
.map(translateActivity)
.filter((feature): feature is FeatureItem => feature !== null);
if (curatedFeatures.length > 0) {
setActivityFeatures(curatedFeatures);
}
} catch (error) {
const detail = error instanceof Error ? error.message : String(error);
console.warn('FeatureCarousel activities feed unavailable:', detail);
}
};
hydrateFeatureCarousel();
}, []);
useEffect(() => {
let isActive = true;
const hydrateCategories = async () => {
try {
const response = await categoriesWithChildren();
if (!isActive) {
return;
}
const payload = response.success && Array.isArray(response.data) ? response.data : [];
const curated = payload.filter((category) => category.id && (category.isActive ?? true));
setCategoryCollection(curated.length > 0 ? curated : payload);
} catch (error) {
if (isActive) {
const detail = error instanceof Error ? error.message : String(error);
console.warn('Categories feed unavailable:', detail);
}
}
};
hydrateCategories();
return () => {
isActive = false;
};
}, [isAuthenticated]);
useEffect(() => {
if (categoryCollection.length === 0) {
return;
}
const hasActive = categoryCollection.some((category) => category.id === activeCategory);
if (!hasActive) {
setActiveCategory(categoryCollection[0].id);
}
}, [categoryCollection, activeCategory]);
const categoryOptions = useMemo(() => {
if (categoryCollection.length === 0) {
return [];
}
const options = categoryCollection
.map((category) => {
const label = coalesceText(category.nameEn, category.name);
return label
? {
id: category.id,
label,
}
: null;
})
.filter((category): category is { id: string; label: string } => category !== null);
return options.length > 0 ? options : [];
}, [categoryCollection]);
const allCategoriesWithTemplates = useMemo(() => {
return categoryCollection.map((category) => {
const templates = (category.templates ?? [])
.map(translateTemplateToCommunity)
.filter((item): item is CommunityItem => item !== null);
return {
id: category.id,
name: coalesceText(category.nameEn, category.name),
templates,
};
});
}, [categoryCollection]);
const handleScroll = useCallback((event: NativeSyntheticEvent<NativeScrollEvent>) => {
if (isScrollingProgrammaticallyRef.current) {
return;
}
const scrollY = event.nativeEvent.contentOffset.y;
const positions = Array.from(categoryPositionsRef.current.entries());
let newActiveCategory = activeCategory;
for (let i = positions.length - 1; i >= 0; i--) {
const [categoryId, position] = positions[i];
if (scrollY >= position - 100) {
newActiveCategory = categoryId;
break;
}
}
if (newActiveCategory !== activeCategory) {
setActiveCategory(newActiveCategory);
}
}, [activeCategory]);
const handleCategoryChange = useCallback((categoryId: string) => {
const targetPosition = categoryPositionsRef.current.get(categoryId);
if (targetPosition === undefined) {
return;
}
isScrollingProgrammaticallyRef.current = true;
scrollViewRef.current?.scrollTo({
y: targetPosition,
animated: true,
});
setTimeout(() => {
isScrollingProgrammaticallyRef.current = false;
}, 500);
setActiveCategory(categoryId);
}, []);
const handleGeneratePress = useCallback((item: CommunityItem) => {
requireAuth(() => {
const templateId = item.id.split('-').pop() || item.id;
router.push(`/templates/${templateId}`);
});
}, [requireAuth]);
const handleFeaturePress = useCallback((item: FeatureItemType) => {
requireAuth(() => {
// TODO: 实现功能逻辑
});
}, [requireAuth]);
return (
<PageLayout backgroundColor="#050505" topInset="none">
<StatusBarSpacer />
<Header />
<CategoryTabs
categories={categoryOptions}
activeId={activeCategory}
onChange={handleCategoryChange}
/>
<ScrollView
ref={scrollViewRef}
style={styles.scroll}
contentContainerStyle={styles.contentContainer}
showsVerticalScrollIndicator={false}
onScroll={handleScroll}
scrollEventThrottle={16}
>
{activityFeatures.length > 0 && (
<FeatureCarousel
items={activityFeatures}
onPress={handleFeaturePress}
/>
)}
{allCategoriesWithTemplates.map((category, index) => (
<View
key={category.id}
onLayout={(event) => {
const layout = event.nativeEvent.layout;
categoryPositionsRef.current.set(category.id, layout.y);
}}
>
<SectionHeader title={category.name} />
<CommunityGrid
items={category.templates.map((template, idx) => ({
...template,
id: `${category.id}-${template.id || idx}`,
}))}
onPressAction={handleGeneratePress}
/>
</View>
))}
</ScrollView>
</PageLayout>
);
}
const styles = StyleSheet.create({
scroll: {
flex: 1,
},
contentContainer: {
paddingBottom: 16,
},
});