281 lines
8.1 KiB
TypeScript
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,
|
|
},
|
|
});
|