Initial commit: expo-popcore project
This commit is contained in:
64
app/(tabs)/_layout.tsx
Normal file
64
app/(tabs)/_layout.tsx
Normal file
@@ -0,0 +1,64 @@
|
||||
import { Tabs } from 'expo-router';
|
||||
import React from 'react';
|
||||
import { Image } from 'expo-image';
|
||||
|
||||
import { HapticTab } from '@/components/haptic-tab';
|
||||
import { useAuth } from '@/hooks/use-auth';
|
||||
|
||||
export default function TabLayout() {
|
||||
const { isAuthenticated, isLoading } = useAuth();
|
||||
|
||||
if (isLoading) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Tabs
|
||||
screenOptions={{
|
||||
tabBarActiveTintColor: '#FFFFFF',
|
||||
headerShown: false,
|
||||
tabBarButton: HapticTab,
|
||||
tabBarStyle: {
|
||||
backgroundColor: '#050505',
|
||||
borderTopColor: '#1a1a1a',
|
||||
},
|
||||
}}>
|
||||
<Tabs.Screen
|
||||
name="home"
|
||||
options={{
|
||||
title: 'Home',
|
||||
tabBarIcon: ({ focused }) => (
|
||||
<Image
|
||||
source={require('@/assets/images/home.png')}
|
||||
style={{ width: 28, height: 28, opacity: focused ? 1 : 0.5 }}
|
||||
/>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
<Tabs.Screen
|
||||
name="history"
|
||||
options={{
|
||||
title: 'Content',
|
||||
tabBarIcon: ({ focused }) => (
|
||||
<Image
|
||||
source={require('@/assets/images/content.png')}
|
||||
style={{ width: 28, height: 28, opacity: focused ? 1 : 0.5 }}
|
||||
/>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
<Tabs.Screen
|
||||
name="profile"
|
||||
options={{
|
||||
title: 'Profile',
|
||||
tabBarIcon: ({ focused }) => (
|
||||
<Image
|
||||
source={require('@/assets/images/user.png')}
|
||||
style={{ width: 28, height: 28, opacity: focused ? 1 : 0.5 }}
|
||||
/>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
</Tabs>
|
||||
);
|
||||
}
|
||||
354
app/(tabs)/history.tsx
Normal file
354
app/(tabs)/history.tsx
Normal file
@@ -0,0 +1,354 @@
|
||||
import { ErrorView } from '@/components/ErrorView';
|
||||
import { PageLayout } from '@/components/bestai/layout';
|
||||
import VideoPlayer from '@/components/sker/video-player/video-player';
|
||||
import { getTemplateGenerations, TemplateGeneration } from '@/lib/api/template-generations';
|
||||
import { groupByDate } from '@/lib/utils/date';
|
||||
import { distributeToColumns } from '@/lib/utils/media';
|
||||
import { FlashList } from '@shopify/flash-list';
|
||||
import { router } from 'expo-router';
|
||||
import { StatusBar } from 'expo-status-bar';
|
||||
import React, { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import {
|
||||
ActivityIndicator,
|
||||
StyleSheet,
|
||||
Text,
|
||||
TouchableOpacity,
|
||||
View
|
||||
} from 'react-native';
|
||||
|
||||
const LAYOUT_CONFIG = {
|
||||
VIDEO_HEIGHT: 280,
|
||||
IMAGE_HEIGHT: 240,
|
||||
DEFAULT_HEIGHT: 200,
|
||||
COLUMN_GAP: 16,
|
||||
PAGE_SIZE: 20,
|
||||
} as const;
|
||||
|
||||
const ColumnRenderer = React.memo(({ items }: { items: TemplateGeneration[] }) => (
|
||||
<>
|
||||
{items.map(item => {
|
||||
const itemHeight = item.type === 'VIDEO' ? LAYOUT_CONFIG.VIDEO_HEIGHT : LAYOUT_CONFIG.IMAGE_HEIGHT;
|
||||
return (
|
||||
<TouchableOpacity
|
||||
key={item.id}
|
||||
style={[styles.frame, { height: itemHeight }]}
|
||||
onPress={() => router.push(`/result?generationId=${item.id}`)}
|
||||
activeOpacity={0.9}
|
||||
>
|
||||
{item.resultUrl.map(uri => {
|
||||
return <VideoPlayer key={uri} source={{ uri, useCaching: true }} style={{ borderRadius: 8 }}/>
|
||||
})}
|
||||
</TouchableOpacity>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
));
|
||||
|
||||
ColumnRenderer.displayName = 'ColumnRenderer';
|
||||
|
||||
interface DateGroupItem {
|
||||
date: string;
|
||||
items: TemplateGeneration[];
|
||||
}
|
||||
|
||||
export default function HistoryScreen() {
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [page, setPage] = useState(1);
|
||||
const [hasMore, setHasMore] = useState(true);
|
||||
const [allGenerations, setAllGenerations] = useState<TemplateGeneration[]>([]);
|
||||
const [refreshing, setRefreshing] = useState(false);
|
||||
|
||||
const groupedData = useMemo(() => groupByDate(allGenerations), [allGenerations]);
|
||||
const flatData = useMemo<DateGroupItem[]>(() =>
|
||||
Object.entries(groupedData).map(([date, items]) => ({ date, items })),
|
||||
[groupedData]
|
||||
);
|
||||
|
||||
const fetchData = useCallback(async (pageNum: number = 1, isRefresh: boolean = false) => {
|
||||
try {
|
||||
if (isRefresh) {
|
||||
setRefreshing(true);
|
||||
} else if (pageNum === 1) {
|
||||
setLoading(true);
|
||||
}
|
||||
|
||||
const response = await getTemplateGenerations({
|
||||
page: String(pageNum),
|
||||
limit: String(LAYOUT_CONFIG.PAGE_SIZE)
|
||||
});
|
||||
|
||||
if (response?.success && response.data) {
|
||||
const newGenerations = response.data.generations as any[];
|
||||
|
||||
if (isRefresh || pageNum === 1) {
|
||||
setAllGenerations(newGenerations);
|
||||
setPage(1);
|
||||
} else {
|
||||
setAllGenerations(prev => [...prev, ...newGenerations]);
|
||||
}
|
||||
|
||||
setHasMore(newGenerations.length === LAYOUT_CONFIG.PAGE_SIZE);
|
||||
setError(null);
|
||||
} else {
|
||||
setError('获取数据失败');
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to fetch data:', err);
|
||||
const errorMessage = err instanceof Error ? err.message : '获取数据失败';
|
||||
if (!errorMessage.includes('401')) {
|
||||
setError(errorMessage);
|
||||
}
|
||||
} finally {
|
||||
setLoading(false);
|
||||
setRefreshing(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
fetchData(1);
|
||||
}, [fetchData]);
|
||||
|
||||
const onRefresh = useCallback(() => {
|
||||
fetchData(1, true);
|
||||
}, [fetchData]);
|
||||
|
||||
const loadMore = useCallback(() => {
|
||||
if (hasMore && !loading && !refreshing) {
|
||||
const nextPage = page + 1;
|
||||
setPage(nextPage);
|
||||
fetchData(nextPage, false);
|
||||
}
|
||||
}, [hasMore, page, loading, refreshing, fetchData]);
|
||||
|
||||
const renderItem = useCallback(({ item }: { item: DateGroupItem }) => {
|
||||
const { leftColumn, rightColumn } = distributeToColumns(
|
||||
item.items,
|
||||
LAYOUT_CONFIG.VIDEO_HEIGHT,
|
||||
LAYOUT_CONFIG.IMAGE_HEIGHT,
|
||||
LAYOUT_CONFIG.COLUMN_GAP
|
||||
);
|
||||
|
||||
return (
|
||||
<View style={styles.dateGroup}>
|
||||
<Text style={styles.dateline}>{item.date}</Text>
|
||||
<View style={styles.gallery}>
|
||||
<View style={styles.leadingLane}>
|
||||
<ColumnRenderer items={leftColumn} />
|
||||
</View>
|
||||
<View style={styles.trailingLane}>
|
||||
<ColumnRenderer items={rightColumn} />
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}, []);
|
||||
|
||||
const ListHeaderComponent = useCallback(() => (
|
||||
<Text style={styles.heading}>Content Generation</Text>
|
||||
), []);
|
||||
|
||||
const ListFooterComponent = useCallback(() => {
|
||||
if (hasMore) {
|
||||
return (
|
||||
<View style={styles.loadingMoreContainer}>
|
||||
<ActivityIndicator size="small" color="#FFFFFF" />
|
||||
<Text style={styles.loadingMoreText}>加载更多...</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
if (allGenerations.length > 0) {
|
||||
return (
|
||||
<View style={styles.noMoreContainer}>
|
||||
<Text style={styles.noMoreText}>没有更多内容了</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
}, [hasMore, allGenerations.length]);
|
||||
|
||||
const ListEmptyComponent = useCallback(() => (
|
||||
<View style={styles.emptyContainer}>
|
||||
<Text style={styles.emptyText}>暂无生成记录</Text>
|
||||
</View>
|
||||
), []);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<PageLayout backgroundColor="#050505">
|
||||
<StatusBar style="light" />
|
||||
<View style={styles.loadingContainer}>
|
||||
<ActivityIndicator size="large" color="#FFFFFF" />
|
||||
<Text style={styles.loadingText}>加载中...</Text>
|
||||
</View>
|
||||
</PageLayout>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<PageLayout backgroundColor="#050505">
|
||||
<StatusBar style="light" />
|
||||
<ErrorView message={error} onRetry={() => fetchData(1)} />
|
||||
</PageLayout>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<PageLayout backgroundColor="#050505">
|
||||
<StatusBar style="light" />
|
||||
<FlashList
|
||||
data={flatData}
|
||||
renderItem={renderItem}
|
||||
keyExtractor={(item) => item.date}
|
||||
onEndReached={loadMore}
|
||||
onEndReachedThreshold={0.5}
|
||||
refreshing={refreshing}
|
||||
onRefresh={onRefresh}
|
||||
ListHeaderComponent={ListHeaderComponent}
|
||||
ListFooterComponent={ListFooterComponent}
|
||||
ListEmptyComponent={ListEmptyComponent}
|
||||
contentContainerStyle={styles.flashListContent}
|
||||
/>
|
||||
</PageLayout>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
flashListContent: {
|
||||
paddingTop: 14,
|
||||
paddingBottom: 14,
|
||||
},
|
||||
heading: {
|
||||
fontSize: 24,
|
||||
fontWeight: '600',
|
||||
color: '#FFFFFF',
|
||||
textAlign: 'center',
|
||||
letterSpacing: 0.4,
|
||||
marginBottom: 16,
|
||||
},
|
||||
dateline: {
|
||||
marginTop: 16,
|
||||
marginBottom: 16,
|
||||
fontSize: 16,
|
||||
fontWeight: '500',
|
||||
color: '#EDEDED',
|
||||
},
|
||||
gallery: {
|
||||
flexDirection: 'row',
|
||||
marginBottom: 24,
|
||||
},
|
||||
leadingLane: {
|
||||
flex: 1,
|
||||
paddingRight: 12,
|
||||
},
|
||||
trailingLane: {
|
||||
flex: 1,
|
||||
paddingLeft: 12,
|
||||
},
|
||||
frame: {
|
||||
width: '100%',
|
||||
borderRadius: 16,
|
||||
marginBottom: 16,
|
||||
overflow: 'hidden',
|
||||
backgroundColor: '#1A1A1A',
|
||||
},
|
||||
image: {
|
||||
width: '100%',
|
||||
},
|
||||
placeholderImage: {
|
||||
backgroundColor: '#2A2A2A',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
},
|
||||
placeholderText: {
|
||||
fontSize: 48,
|
||||
},
|
||||
videoPlaceholder: {
|
||||
backgroundColor: '#1A1A1A',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
},
|
||||
videoIcon: {
|
||||
fontSize: 48,
|
||||
marginBottom: 8,
|
||||
},
|
||||
videoText: {
|
||||
fontSize: 14,
|
||||
color: '#9E9E9E',
|
||||
},
|
||||
overlay: {
|
||||
position: 'absolute',
|
||||
bottom: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
padding: 12,
|
||||
backgroundColor: 'rgba(0, 0, 0, 0.7)',
|
||||
borderBottomLeftRadius: 16,
|
||||
borderBottomRightRadius: 16,
|
||||
},
|
||||
statusBadge: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
marginBottom: 4,
|
||||
},
|
||||
statusDot: {
|
||||
width: 8,
|
||||
height: 8,
|
||||
borderRadius: 4,
|
||||
marginRight: 6,
|
||||
},
|
||||
statusText: {
|
||||
fontSize: 12,
|
||||
color: '#FFFFFF',
|
||||
fontWeight: '500',
|
||||
},
|
||||
templateName: {
|
||||
fontSize: 13,
|
||||
color: '#FFFFFF',
|
||||
fontWeight: '600',
|
||||
},
|
||||
loadingContainer: {
|
||||
flex: 1,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
},
|
||||
loadingText: {
|
||||
marginTop: 16,
|
||||
fontSize: 16,
|
||||
color: '#FFFFFF',
|
||||
},
|
||||
emptyContainer: {
|
||||
flex: 1,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
padding: 48,
|
||||
},
|
||||
emptyText: {
|
||||
fontSize: 16,
|
||||
color: '#9E9E9E',
|
||||
textAlign: 'center',
|
||||
},
|
||||
dateGroup: {
|
||||
marginBottom: 24,
|
||||
},
|
||||
loadingMoreContainer: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
paddingVertical: 20,
|
||||
gap: 12,
|
||||
},
|
||||
loadingMoreText: {
|
||||
fontSize: 14,
|
||||
color: '#9E9E9E',
|
||||
},
|
||||
noMoreContainer: {
|
||||
paddingVertical: 20,
|
||||
alignItems: 'center',
|
||||
},
|
||||
noMoreText: {
|
||||
fontSize: 14,
|
||||
color: '#666666',
|
||||
},
|
||||
});
|
||||
376
app/(tabs)/home.tsx
Normal file
376
app/(tabs)/home.tsx
Normal file
@@ -0,0 +1,376 @@
|
||||
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}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
*/
|
||||
3
app/(tabs)/profile.tsx
Normal file
3
app/(tabs)/profile.tsx
Normal file
@@ -0,0 +1,3 @@
|
||||
import { ProfileScreen } from '@/components/profile/profile-screen';
|
||||
|
||||
export default ProfileScreen
|
||||
6
app/(tabs)/todo.md
Normal file
6
app/(tabs)/todo.md
Normal file
@@ -0,0 +1,6 @@
|
||||
/plan 分析并解决下面的问题
|
||||
|
||||
移动端:apps\expo\app\(tabs)\history.tsx
|
||||
pc端:apps\frontend\src\routes\personal.tsx
|
||||
|
||||
请对比pc端实现 进行优化移动端
|
||||
Reference in New Issue
Block a user