376 lines
10 KiB
TypeScript
376 lines
10 KiB
TypeScript
import { router } from 'expo-router';
|
|
import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
|
import { StyleSheet, TouchableOpacity, View, AppState } from 'react-native';
|
|
import { Image } from 'expo-image';
|
|
import { FlashList } from '@shopify/flash-list';
|
|
import VideoPlayer from '@/components/sker/video-player/video-player'
|
|
import {
|
|
CategoryTabs,
|
|
FeatureCarousel,
|
|
Header,
|
|
PageLayout,
|
|
SectionHeader,
|
|
StatusBarSpacer,
|
|
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 { CategoryWithChildren } from '@/lib/types/template';
|
|
|
|
type ListItem =
|
|
| { type: 'feature'; data: FeatureItem[] }
|
|
| { type: 'section'; categoryId: string; title: string; tagName: string }
|
|
| { type: 'template-row'; categoryId: string; items: Array<{ id: string; template: any; tagName: string }>; itemWidth: number };
|
|
|
|
type TemplateItemProps = {
|
|
template: any;
|
|
tagName: string;
|
|
itemWidth: number;
|
|
marginRight: number;
|
|
onPress: (tagName: string) => void;
|
|
};
|
|
|
|
const TemplateItem = memo(({ template, tagName, itemWidth, marginRight, onPress }: TemplateItemProps) => {
|
|
const aspectRatio = template.aspectRatio || '3:4';
|
|
const [w, h] = aspectRatio.split(':');
|
|
const height = itemWidth * parseInt(h) / parseInt(w);
|
|
|
|
const handlePress = useCallback(() => {
|
|
onPress(tagName);
|
|
}, [tagName, onPress]);
|
|
|
|
return (
|
|
<TouchableOpacity
|
|
activeOpacity={0.9}
|
|
onPress={handlePress}
|
|
style={[
|
|
styles.templateItem,
|
|
{
|
|
width: itemWidth,
|
|
height,
|
|
marginRight,
|
|
}
|
|
]}
|
|
>
|
|
<VideoPlayer
|
|
source={{ uri: template.previewUrl, useCaching: true, metadata: { title: template.title } }}
|
|
style={styles.videoPlayer}
|
|
dwellTime={1000}
|
|
threshold={0.3}
|
|
/>
|
|
</TouchableOpacity>
|
|
);
|
|
});
|
|
TemplateItem.displayName = 'TemplateItem';
|
|
|
|
type TemplateRowProps = {
|
|
items: Array<{ id: string; template: any; tagName: string }>;
|
|
itemWidth: number;
|
|
onPress: (tagName: string) => void;
|
|
};
|
|
|
|
const TemplateRow = memo(({ items, itemWidth, onPress }: TemplateRowProps) => (
|
|
<View style={styles.row}>
|
|
{items.map((item, index) => {
|
|
if (!item.template) return null;
|
|
return (
|
|
<TemplateItem
|
|
key={item.id}
|
|
template={item.template}
|
|
tagName={item.tagName}
|
|
itemWidth={itemWidth}
|
|
marginRight={index < items.length - 1 ? 8 : 0}
|
|
onPress={onPress}
|
|
/>
|
|
);
|
|
})}
|
|
</View>
|
|
));
|
|
TemplateRow.displayName = 'TemplateRow';
|
|
|
|
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 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 [searchQuery, setSearchQuery] = useState('');
|
|
const [containerWidth, setContainerWidth] = useState(0);
|
|
|
|
useEffect(() => {
|
|
const subscription = AppState.addEventListener('change', (nextAppState) => {
|
|
if (nextAppState === 'background' || nextAppState === 'inactive') {
|
|
Image.clearMemoryCache();
|
|
if (__DEV__) {
|
|
console.log('[MemoryManager] Cleared image cache on app background');
|
|
}
|
|
}
|
|
});
|
|
|
|
return () => {
|
|
subscription.remove();
|
|
};
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
const hydrateFeatureCarousel = async () => {
|
|
try {
|
|
const activities = await getActivities({ isActive: 'true' });
|
|
const curatedFeatures = activities
|
|
.map(translateActivity)
|
|
.filter((feature: any): 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: any) => 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 flattenedData = useMemo(() => {
|
|
const result: ListItem[] = [];
|
|
const itemWidth = containerWidth > 0 ? (containerWidth - 8) / 2 : 0;
|
|
|
|
if (activityFeatures.length > 0) {
|
|
result.push({ type: 'feature', data: activityFeatures });
|
|
}
|
|
|
|
categoryCollection.forEach((category) => {
|
|
result.push({
|
|
type: 'section',
|
|
categoryId: category.id,
|
|
title: category.nameEn || category.name || '',
|
|
tagName: category.tags[0]?.name || ''
|
|
});
|
|
|
|
const tags = category.tags || [];
|
|
for (let i = 0; i < tags.length; i += 2) {
|
|
const rowItems = tags.slice(i, i + 2)
|
|
.map(tag => ({
|
|
id: tag.templates[0]?.id || tag.id,
|
|
template: tag.templates[0],
|
|
tagName: tag.nameEn || tag.name || ''
|
|
}))
|
|
.filter(item => item.template);
|
|
|
|
if (rowItems.length > 0) {
|
|
result.push({
|
|
type: 'template-row',
|
|
categoryId: category.id,
|
|
items: rowItems,
|
|
itemWidth
|
|
});
|
|
}
|
|
}
|
|
});
|
|
|
|
return result;
|
|
}, [categoryCollection, activityFeatures, containerWidth]);
|
|
|
|
const handleCategoryPress = useCallback((tagName: string) => {
|
|
router.push(`/category/${tagName}` as any);
|
|
}, []);
|
|
|
|
const handleFeaturePress = useCallback((item: FeatureItemType) => {
|
|
requireAuth(() => {
|
|
// TODO: 实现功能逻辑
|
|
});
|
|
}, [requireAuth]);
|
|
|
|
const handleTemplatePress = useCallback((templateId: string) => {
|
|
router.push(`/templates/${templateId}` as any);
|
|
}, []);
|
|
|
|
const renderItem = useCallback(({ item }: { item: ListItem }) => {
|
|
if (item.type === 'feature') {
|
|
return (
|
|
<FeatureCarousel
|
|
items={item.data}
|
|
onPress={handleFeaturePress}
|
|
/>
|
|
);
|
|
}
|
|
|
|
if (item.type === 'section') {
|
|
return (
|
|
<SectionHeader
|
|
title={item.title}
|
|
onPress={() => handleCategoryPress(item.tagName)}
|
|
/>
|
|
);
|
|
}
|
|
|
|
if (item.type === 'template-row') {
|
|
return (
|
|
<TemplateRow
|
|
items={item.items}
|
|
itemWidth={item.itemWidth}
|
|
onPress={handleTemplatePress}
|
|
/>
|
|
);
|
|
}
|
|
|
|
return null;
|
|
}, [handleFeaturePress, handleCategoryPress, handleTemplatePress]);
|
|
|
|
return (
|
|
<PageLayout backgroundColor="#050505" topInset="none">
|
|
<StatusBarSpacer />
|
|
<Header onSearchChange={setSearchQuery} />
|
|
<CategoryTabs
|
|
categories={categoryOptions}
|
|
activeId={activeCategory}
|
|
onChange={setActiveCategory}
|
|
/>
|
|
<View
|
|
style={styles.container}
|
|
onLayout={(event) => {
|
|
setContainerWidth(event.nativeEvent.layout.width);
|
|
}}
|
|
>
|
|
{containerWidth > 0 && (
|
|
<FlashList
|
|
data={flattenedData}
|
|
renderItem={renderItem}
|
|
keyExtractor={(item, index) => {
|
|
if (item.type === 'feature') return 'feature-carousel';
|
|
if (item.type === 'section') return `section-${item.categoryId}`;
|
|
if (item.type === 'template-row') return `row-${item.categoryId}-${index}`;
|
|
return `item-${index}`;
|
|
}}
|
|
/>
|
|
)}
|
|
</View>
|
|
</PageLayout>
|
|
);
|
|
}
|
|
|
|
const styles = StyleSheet.create({
|
|
container: {
|
|
flex: 1,
|
|
},
|
|
row: {
|
|
flexDirection: 'row',
|
|
paddingHorizontal: 0,
|
|
marginBottom: 8,
|
|
},
|
|
templateItem: {
|
|
backgroundColor: '#2E3031',
|
|
borderRadius: 12,
|
|
overflow: 'hidden',
|
|
},
|
|
videoPlayer: {
|
|
borderRadius: 8
|
|
}
|
|
});
|
|
|
|
|
|
/**
|
|
<Waterfall
|
|
items={waterfallItems}
|
|
column={column}
|
|
itemPadding={8}
|
|
containerWidth={containerWidth}
|
|
renderItem={(item: CommunityItem, width: number, height: number) => (
|
|
<CommunityCard
|
|
item={{ ...item, width, height }}
|
|
onPressCard={handleCardPress}
|
|
onPressAction={handleGeneratePress}
|
|
/>
|
|
)}
|
|
/>
|
|
*/ |