bw-expo-app/app/templates/[id].tsx

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',
},
});