604 lines
16 KiB
TypeScript
604 lines
16 KiB
TypeScript
import { BackButton } from '@/components/ui/back-button';
|
|
import { getTagByName, type TagTemplate } from '@/lib/api/tags';
|
|
import { getTemplateById } from '@/lib/api/templates';
|
|
import Feather from '@expo/vector-icons/Feather';
|
|
import { Stack, router, useLocalSearchParams } from 'expo-router';
|
|
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
|
import {
|
|
ActivityIndicator,
|
|
Image,
|
|
ScrollView,
|
|
StyleSheet,
|
|
Text,
|
|
TouchableOpacity,
|
|
View,
|
|
} from 'react-native';
|
|
import { SafeAreaView } from 'react-native-safe-area-context';
|
|
|
|
type TemplateFrame = {
|
|
id: string;
|
|
title: string;
|
|
uri: string;
|
|
accent: string;
|
|
};
|
|
|
|
type TemplateLike = {
|
|
id: string;
|
|
title?: string | null;
|
|
titleEn?: string | null;
|
|
coverImageUrl?: string | null;
|
|
previewUrl?: string | null;
|
|
};
|
|
|
|
const ACCENT_COLORS = ['#D1FF00', '#FF7A45', '#7A8BFF', '#FF5F6D', '#21D4FD', '#FFAA00'];
|
|
const FALLBACK_PREVIEW =
|
|
'https://images.unsplash.com/photo-1542204165-0198c1e21a3b?auto=format&fit=crop&w=1200&q=80';
|
|
|
|
const createFrame = (template: TemplateLike, index = 0): TemplateFrame => {
|
|
const title = (template.titleEn || template.title || '').trim() || 'Untitled Template';
|
|
const uri = template.coverImageUrl || template.previewUrl || FALLBACK_PREVIEW;
|
|
|
|
return {
|
|
id: template.id,
|
|
title,
|
|
uri,
|
|
accent: ACCENT_COLORS[index % ACCENT_COLORS.length],
|
|
};
|
|
};
|
|
|
|
const mapTagTemplates = (templates: TagTemplate[]): TemplateFrame[] =>
|
|
templates.map((template, index) => createFrame(template, index));
|
|
|
|
const quickActions = [
|
|
{ id: 'upscale', label: 'Upscale' },
|
|
{ id: 'collect', label: 'Collect' },
|
|
{ id: 'download', label: 'Download' },
|
|
];
|
|
|
|
const PreviewFrame = React.memo<{
|
|
frame: TemplateFrame;
|
|
isActive: boolean;
|
|
onPress: () => void;
|
|
}>(({ frame, isActive, onPress }) => {
|
|
return (
|
|
<TouchableOpacity
|
|
activeOpacity={0.85}
|
|
onPress={onPress}
|
|
style={[
|
|
styles.previewFrame,
|
|
{ borderColor: isActive ? frame.accent : 'rgba(255,255,255,0.08)' },
|
|
isActive && styles.previewFrameActive,
|
|
]}
|
|
>
|
|
<Image source={{ uri: frame.uri }} style={styles.previewImage} />
|
|
</TouchableOpacity>
|
|
);
|
|
});
|
|
|
|
PreviewFrame.displayName = 'PreviewFrame';
|
|
|
|
const CACHE_DURATION = 5 * 60 * 1000;
|
|
const cache = new Map<string, { data: TemplateFrame[]; timestamp: number }>();
|
|
|
|
export default function TemplateDetailScreen() {
|
|
const { id } = useLocalSearchParams<{ id: string }>();
|
|
const [looks, setLooks] = useState<TemplateFrame[]>([]);
|
|
const [selectedId, setSelectedId] = useState<string | null>(null);
|
|
const [menuVisible, setMenuVisible] = useState(false);
|
|
const [loadingLooks, setLoadingLooks] = useState(true);
|
|
const [errorMessage, setErrorMessage] = useState<string | null>(null);
|
|
const [retryTrigger, setRetryTrigger] = useState(0);
|
|
const abortControllerRef = useRef<AbortController | null>(null);
|
|
const imageCache = useRef(new Set<string>());
|
|
|
|
const getCachedData = useCallback((key: string) => {
|
|
const cached = cache.get(key);
|
|
if (cached && Date.now() - cached.timestamp < CACHE_DURATION) {
|
|
return cached.data;
|
|
}
|
|
return null;
|
|
}, []);
|
|
|
|
const setCachedData = useCallback((key: string, data: TemplateFrame[]) => {
|
|
cache.set(key, { data, timestamp: Date.now() });
|
|
}, []);
|
|
|
|
const preloadImage = useCallback((uri: string) => {
|
|
if (imageCache.current.has(uri)) {
|
|
return;
|
|
}
|
|
Image.prefetch(uri).then(() => {
|
|
imageCache.current.add(uri);
|
|
}).catch(() => {
|
|
});
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
abortControllerRef.current?.abort();
|
|
abortControllerRef.current = new AbortController();
|
|
const signal = abortControllerRef.current.signal;
|
|
|
|
let isMounted = true;
|
|
|
|
const loadByTagName = async (tagName?: string | null): Promise<TemplateFrame[]> => {
|
|
if (!tagName) {
|
|
return [];
|
|
}
|
|
|
|
const cacheKey = `tag:${tagName}`;
|
|
const cached = getCachedData(cacheKey);
|
|
if (cached) {
|
|
return cached;
|
|
}
|
|
|
|
try {
|
|
const response = await getTagByName(tagName);
|
|
if (response.success && response.data?.templates?.length) {
|
|
const templates = mapTagTemplates(response.data.templates);
|
|
setCachedData(cacheKey, templates);
|
|
return templates;
|
|
}
|
|
} catch (tagError) {
|
|
console.warn('Failed to load tag templates:', tagError);
|
|
}
|
|
|
|
return [];
|
|
};
|
|
|
|
const hydrateLooks = async () => {
|
|
if (!id || typeof id !== 'string') {
|
|
setLooks([]);
|
|
setSelectedId(null);
|
|
setErrorMessage('Missing template identifier');
|
|
setLoadingLooks(false);
|
|
return;
|
|
}
|
|
|
|
setLoadingLooks(true);
|
|
setErrorMessage(null);
|
|
|
|
try {
|
|
const templateResponse = await getTemplateById(id);
|
|
if (!signal.aborted && templateResponse.success && templateResponse.data) {
|
|
const template = templateResponse.data;
|
|
const primaryTagName = template.tags?.[0]?.nameEn || template.tags?.[0]?.name;
|
|
|
|
const [relatedFrames, mainFrame] = await Promise.allSettled([
|
|
loadByTagName(primaryTagName),
|
|
Promise.resolve(createFrame(template, 0))
|
|
]);
|
|
|
|
const frames: TemplateFrame[] = [];
|
|
|
|
if (relatedFrames.status === 'fulfilled' && relatedFrames.value.length > 0) {
|
|
frames.push(...relatedFrames.value);
|
|
} else {
|
|
frames.push(mainFrame.status === 'fulfilled' ? mainFrame.value : createFrame(template, 0));
|
|
}
|
|
|
|
if (!isMounted || signal.aborted) {
|
|
return;
|
|
}
|
|
|
|
setLooks(frames);
|
|
setCachedData(`template:${id}`, frames);
|
|
|
|
const preferredId =
|
|
frames.find(frame => frame.id === id)?.id ??
|
|
frames[0]?.id ??
|
|
template.id;
|
|
|
|
setSelectedId(preferredId);
|
|
|
|
frames.slice(0, 3).forEach(frame => preloadImage(frame.uri));
|
|
} else {
|
|
if (!isMounted || signal.aborted) {
|
|
return;
|
|
}
|
|
setErrorMessage('Template not found');
|
|
setLooks([]);
|
|
setSelectedId(null);
|
|
}
|
|
} catch (templateError) {
|
|
if (!isMounted || signal.aborted) {
|
|
return;
|
|
}
|
|
console.warn('Failed to fetch template detail:', templateError);
|
|
setErrorMessage('Failed to load template');
|
|
setLooks([]);
|
|
setSelectedId(null);
|
|
} finally {
|
|
if (isMounted && !signal.aborted) {
|
|
setLoadingLooks(false);
|
|
}
|
|
}
|
|
};
|
|
|
|
hydrateLooks();
|
|
|
|
return () => {
|
|
isMounted = false;
|
|
abortControllerRef.current?.abort();
|
|
};
|
|
}, [id, getCachedData, setCachedData, preloadImage]);
|
|
|
|
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 resolvedTemplateId = current?.id ?? (typeof id === 'string' ? id : '');
|
|
const heroImageUri = current?.uri ?? FALLBACK_PREVIEW;
|
|
const infoTitle = current?.title ?? 'Loading template';
|
|
|
|
const handleRetry = useCallback(() => {
|
|
cache.delete(`template:${id}`);
|
|
cache.delete(`tag:${current?.title}`);
|
|
setErrorMessage(null);
|
|
setLoadingLooks(true);
|
|
setRetryTrigger(prev => prev + 1);
|
|
}, [id, current?.title]);
|
|
|
|
useEffect(() => {
|
|
if (retryTrigger > 0) {
|
|
const newController = new AbortController();
|
|
abortControllerRef.current = newController;
|
|
}
|
|
}, [retryTrigger]);
|
|
|
|
const handleGenerate = () => {
|
|
if (!resolvedTemplateId) {
|
|
return;
|
|
}
|
|
|
|
router.push(`/templates/${resolvedTemplateId}/form`);
|
|
};
|
|
|
|
return (
|
|
<>
|
|
<Stack.Screen options={{ headerShown: false }} />
|
|
|
|
<View style={styles.canvas}>
|
|
<SafeAreaView style={styles.safeArea} edges={['top', 'left', 'right']}>
|
|
<View style={styles.headerBar}>
|
|
<BackButton onPress={() => router.back()} />
|
|
|
|
<Text style={styles.headerTitle} numberOfLines={1}>
|
|
{infoTitle}
|
|
</Text>
|
|
|
|
<View style={styles.headerPlaceholder} />
|
|
</View>
|
|
|
|
<ScrollView
|
|
style={styles.body}
|
|
showsVerticalScrollIndicator={false}
|
|
contentContainerStyle={styles.scrollContent}
|
|
>
|
|
{loadingLooks && (
|
|
<View style={styles.loadingState}>
|
|
<ActivityIndicator color="#D1FF00" />
|
|
<Text style={styles.loadingLabel}>Loading templates...</Text>
|
|
</View>
|
|
)}
|
|
|
|
{!loadingLooks && errorMessage && (
|
|
<View style={styles.errorBanner}>
|
|
<Text style={styles.errorText}>{errorMessage}</Text>
|
|
<TouchableOpacity style={styles.retryButton} onPress={handleRetry}>
|
|
<Text style={styles.retryLabel}>Retry</Text>
|
|
</TouchableOpacity>
|
|
</View>
|
|
)}
|
|
|
|
<View style={styles.topCarousel}>
|
|
{looks.length > 0 ? (
|
|
<ScrollView
|
|
horizontal
|
|
showsHorizontalScrollIndicator={false}
|
|
contentContainerStyle={styles.carouselContent}
|
|
>
|
|
{looks.map(look => (
|
|
<PreviewFrame
|
|
key={look.id}
|
|
frame={look}
|
|
isActive={look.id === selectedId}
|
|
onPress={() => setSelectedId(look.id)}
|
|
/>
|
|
))}
|
|
</ScrollView>
|
|
) : (
|
|
!loadingLooks && (
|
|
<View style={styles.emptyCarousel}>
|
|
<Text style={styles.emptyCarouselText}>Nothing to show yet</Text>
|
|
</View>
|
|
)
|
|
)}
|
|
</View>
|
|
|
|
<View style={styles.heroStage}>
|
|
<Image source={{ uri: heroImageUri }} style={styles.heroImage} />
|
|
</View>
|
|
|
|
<View style={styles.bottomInfoRow}>
|
|
<View style={styles.infoCard}>
|
|
<Image source={{ uri: heroImageUri }} style={styles.infoThumbnail} />
|
|
<Text style={styles.infoTitle} numberOfLines={1}>
|
|
{infoTitle}
|
|
</Text>
|
|
</View>
|
|
|
|
<TouchableOpacity
|
|
style={styles.menuTrigger}
|
|
activeOpacity={0.85}
|
|
onPress={() => setMenuVisible(prev => !prev)}
|
|
>
|
|
<View style={styles.menuDots}>
|
|
<View style={styles.dot} />
|
|
<View style={[styles.dot, styles.dotMiddle]} />
|
|
<View style={styles.dot} />
|
|
</View>
|
|
</TouchableOpacity>
|
|
</View>
|
|
|
|
<TouchableOpacity
|
|
style={[
|
|
styles.generateButton,
|
|
!resolvedTemplateId && styles.generateButtonDisabled,
|
|
]}
|
|
activeOpacity={0.9}
|
|
onPress={handleGenerate}
|
|
disabled={!resolvedTemplateId}
|
|
>
|
|
<Feather name="star" size={20} color="#050505" />
|
|
<Text style={styles.generateLabel}>Generate Video</Text>
|
|
</TouchableOpacity>
|
|
</ScrollView>
|
|
|
|
{menuVisible && (
|
|
<View style={styles.menuPanel}>
|
|
{quickActions.map(action => (
|
|
<TouchableOpacity
|
|
key={action.id}
|
|
activeOpacity={0.85}
|
|
style={styles.menuItem}
|
|
onPress={() => setMenuVisible(false)}
|
|
>
|
|
<Text style={styles.menuLabel}>{action.label}</Text>
|
|
</TouchableOpacity>
|
|
))}
|
|
</View>
|
|
)}
|
|
</SafeAreaView>
|
|
</View>
|
|
</>
|
|
);
|
|
}
|
|
|
|
const styles = StyleSheet.create({
|
|
canvas: {
|
|
flex: 1,
|
|
backgroundColor: '#040404',
|
|
},
|
|
safeArea: {
|
|
flex: 1,
|
|
},
|
|
body: {
|
|
flex: 1,
|
|
},
|
|
scrollContent: {
|
|
paddingHorizontal: 24,
|
|
paddingBottom: 180,
|
|
},
|
|
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)',
|
|
borderWidth: 1,
|
|
borderColor: 'rgba(255, 94, 94, 0.3)',
|
|
},
|
|
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,
|
|
borderWidth: 1,
|
|
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,
|
|
backgroundColor: 'rgba(255, 255, 255, 0.08)',
|
|
gap: 12,
|
|
},
|
|
infoThumbnail: {
|
|
width: 32,
|
|
height: 32,
|
|
borderRadius: 10,
|
|
marginRight: 0,
|
|
},
|
|
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,
|
|
},
|
|
menuTrigger: {
|
|
width: 56,
|
|
height: 56,
|
|
borderRadius: 28,
|
|
backgroundColor: 'rgba(255, 255, 255, 0.08)',
|
|
alignItems: 'center',
|
|
justifyContent: 'center',
|
|
},
|
|
menuDots: {
|
|
justifyContent: 'space-between',
|
|
height: 24,
|
|
},
|
|
dot: {
|
|
width: 6,
|
|
height: 6,
|
|
borderRadius: 3,
|
|
backgroundColor: '#FFFFFF',
|
|
},
|
|
dotMiddle: {
|
|
opacity: 0.7,
|
|
},
|
|
menuPanel: {
|
|
position: 'absolute',
|
|
right: 24,
|
|
bottom: 200,
|
|
zIndex: 50,
|
|
elevation: 24,
|
|
paddingVertical: 12,
|
|
paddingHorizontal: 16,
|
|
borderRadius: 20,
|
|
backgroundColor: 'rgba(10, 10, 10, 0.94)',
|
|
borderWidth: 1,
|
|
borderColor: 'rgba(255, 255, 255, 0.08)',
|
|
gap: 12,
|
|
},
|
|
menuItem: {
|
|
paddingVertical: 4,
|
|
},
|
|
menuLabel: {
|
|
fontSize: 15,
|
|
fontWeight: '600',
|
|
color: '#FFFFFF',
|
|
},
|
|
});
|