expo-popcore-old/app/templates/[id].tsx

302 lines
6.8 KiB
TypeScript

import { GenerateBtn } from '@/components/sker/generate-btn';
import { Header } from '@/components/sker/header';
import { ImgTab } from '@/components/sker/img-tab';
import { Page } from '@/components/sker/page';
import VideoPlayer from '@/components/sker/video-player/video-player';
import { getTagByName } from '@/lib/api/tags';
import Feather from '@expo/vector-icons/Feather';
import { router, useLocalSearchParams } from 'expo-router';
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import {
Image,
ScrollView,
StyleSheet,
Text,
TouchableOpacity,
View,
} from 'react-native';
type TemplateFrame = {
id: string;
uri: string;
avatar?: string;
title?: string;
titleEn?: string;
};
export default function TemplateDetailScreen() {
const { id } = useLocalSearchParams<{ id: string }>();
const [looks, setLooks] = useState<TemplateFrame[]>([]);
const [selectedId, setSelectedId] = useState<string | null>(null);
const abortControllerRef = useRef<AbortController | null>(null);
useEffect(() => {
if (!id || typeof id !== 'string') {
return;
}
abortControllerRef.current?.abort();
abortControllerRef.current = new AbortController();
const signal = abortControllerRef.current.signal;
const hydrateLooks = async () => {
try {
const templateResponse = await getTagByName(id);
if (signal.aborted) return;
if (templateResponse?.success && templateResponse.data) {
const template = templateResponse.data;
const frames: TemplateFrame[] = template.templates.map(it => ({
id: it.id,
uri: it.previewUrl,
title: it.title,
titleEn: it.titleEn,
avatar: it.coverImageUrl
}));
if (signal.aborted) return;
setLooks(frames);
setSelectedId(frames.find(f => f.id === id)?.id ?? frames[0]?.id ?? template.id);
} else {
if (signal.aborted) return;
}
} catch (templateError) {
if (signal.aborted) return;
console.warn('Failed to fetch template detail:', templateError);
} finally { }
};
hydrateLooks();
return () => abortControllerRef.current?.abort();
}, [id]);
const current = useMemo(() => {
if (looks.length === 0) {
return null;
}
if (!selectedId) {
return looks[0];
}
return looks.find(look => look.id === selectedId) ?? looks[0];
}, [looks, selectedId]);
const infoTitle = useMemo(() => {
return current?.titleEn ?? current?.title ?? 'Loading template'
}, [current]);
const handleGenerate = useCallback(() => {
if (current?.id) {
router.push(`/templates/${current.id}/form`);
}
}, [current]);
return (
<Page>
<Header title={infoTitle} />
<ImgTab images={looks} activeId={selectedId} onActiveChange={(id) => {
setSelectedId(id)
}} />
<ScrollView
style={styles.body}
showsVerticalScrollIndicator={false}
contentContainerStyle={styles.scrollContent}
>
<View style={styles.heroStage}>
<VideoPlayer
source={{ uri: current?.uri! }}
style={styles.heroImage}
/>
</View>
</ScrollView>
<GenerateBtn onGenerate={handleGenerate} menuItems={[]}>
<View style={styles.bottomInfoRow}>
<View style={styles.infoCard}>
<Image source={{ uri: current?.avatar }} style={styles.infoThumbnail} />
<Text style={styles.infoTitle} numberOfLines={1}>
{infoTitle}
</Text>
</View>
</View>
</GenerateBtn>
</Page>
);
}
const styles = StyleSheet.create({
canvas: {
flex: 1,
backgroundColor: '#040404',
},
safeArea: {
flex: 1,
},
body: {
flex: 1,
},
scrollContent: {
paddingHorizontal: 40,
paddingBottom: 40,
},
loadingState: {
flexDirection: 'row',
alignItems: 'center',
gap: 12,
paddingHorizontal: 24,
paddingVertical: 8,
},
loadingLabel: {
fontSize: 13,
color: 'rgba(255, 255, 255, 0.7)',
},
errorBanner: {
marginHorizontal: 24,
marginTop: 8,
paddingHorizontal: 16,
paddingVertical: 10,
borderRadius: 16,
backgroundColor: 'rgba(255, 94, 94, 0.12)',
},
errorText: {
color: '#FF8A8A',
fontSize: 13,
fontWeight: '600',
marginBottom: 8,
},
retryButton: {
backgroundColor: 'rgba(255, 94, 94, 0.2)',
paddingHorizontal: 16,
paddingVertical: 8,
borderRadius: 12,
alignSelf: 'flex-start',
},
retryLabel: {
color: '#FF8A8A',
fontSize: 13,
fontWeight: '700',
},
headerBar: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
paddingHorizontal: 24,
paddingTop: 8,
paddingBottom: 8,
gap: 12,
},
headerTitle: {
flex: 1,
textAlign: 'center',
fontSize: 18,
fontWeight: '600',
color: '#FFFFFF',
letterSpacing: 0.4,
},
headerPlaceholder: {
width: 48,
height: 48,
},
topCarousel: {
marginTop: 12,
paddingVertical: 8,
borderRadius: 22,
backgroundColor: 'rgba(255, 255, 255, 0.04)',
},
emptyCarousel: {
alignItems: 'center',
justifyContent: 'center',
height: 80,
},
emptyCarouselText: {
color: 'rgba(255, 255, 255, 0.6)',
fontSize: 13,
},
carouselContent: {
paddingHorizontal: 12,
alignItems: 'center',
gap: 12,
},
previewFrame: {
width: 56,
height: 56,
borderRadius: 18,
overflow: 'hidden',
backgroundColor: 'rgba(255, 255, 255, 0.08)',
},
previewFrameActive: {
transform: [{ scale: 1.05 }],
},
previewImage: {
width: '100%',
height: '100%',
},
heroStage: {
marginTop: 32,
borderRadius: 36,
backgroundColor: '#111111',
overflow: 'hidden',
shadowColor: '#000000',
shadowOpacity: 0.45,
shadowRadius: 32,
shadowOffset: { width: 0, height: 18 },
elevation: 24,
},
heroImage: {
width: '100%',
height: 456,
borderRadius: 36,
}, bottomInfoRow: {
flexDirection: 'row',
alignItems: 'center',
gap: 16,
marginTop: 32,
},
infoCard: {
flex: 1,
flexDirection: 'row',
alignItems: 'center',
paddingHorizontal: 16,
paddingVertical: 12,
borderRadius: 24,
gap: 12,
},
infoThumbnail: {
width: 48,
height: 48,
borderRadius: 10,
marginRight: 0,
rotation: 6
},
infoTitle: {
fontSize: 14,
fontWeight: '600',
color: '#FFFFFF',
flex: 1,
flexShrink: 1,
},
generateButton: {
width: '100%',
height: 56,
borderRadius: 28,
backgroundColor: '#D1FF00',
alignItems: 'center',
justifyContent: 'center',
flexDirection: 'row',
gap: 10,
marginTop: 18,
},
generateButtonDisabled: {
opacity: 0.6,
},
generateLabel: {
fontSize: 16,
fontWeight: '700',
color: '#050505',
letterSpacing: 0.3,
},
});