## 主要修复 - 修复 FullscreenMediaModal 和 FullscreenVideoModal 中的 useEffect 循环依赖 - 重构 VideoPlayer 组件的视频属性管理逻辑 - 优化 useVideoPlayer 的初始化回调机制 ## 新增功能 - 新增标签 API 支持 (lib/api/tags.ts) - 新增内容骨架屏组件 (components/profile/content-skeleton.tsx) - 新增返回按钮组件 (components/ui/back-button.tsx) ## 改进优化 - 优化视频播放器的性能,避免重复初始化 - 修复 useEffect 依赖项导致的无限循环更新 - 完善类型定义和 API 接口 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
701 lines
17 KiB
TypeScript
701 lines
17 KiB
TypeScript
import { Feather } from '@expo/vector-icons';
|
|
import * as ImagePicker from 'expo-image-picker';
|
|
import { Stack, useLocalSearchParams, useRouter } from 'expo-router';
|
|
import { UploadCloud } from 'lucide-react';
|
|
import React, { useEffect, useState } from 'react';
|
|
import {
|
|
ActivityIndicator,
|
|
Alert,
|
|
Image,
|
|
ScrollView,
|
|
StyleSheet,
|
|
Text,
|
|
TextInput,
|
|
TouchableOpacity,
|
|
View,
|
|
} from 'react-native';
|
|
import { SafeAreaView } from 'react-native-safe-area-context';
|
|
|
|
export const unstable_settings = {
|
|
headerShown: false,
|
|
};
|
|
|
|
import { DynamicForm } from '@/components/forms/dynamic-form';
|
|
import { BackButton } from '@/components/ui/back-button';
|
|
import { getTemplateById } from '@/lib/api/templates';
|
|
import { Template } from '@/lib/types/template';
|
|
import { RunFormSchema, RunTemplateData } from '@/lib/types/template-run';
|
|
import { transformWorkflowToFormSchema, validateFormSchema } from '@/lib/utils/form-schema-transformer';
|
|
|
|
interface Step1FormData {
|
|
characterImage: string;
|
|
productImage: string;
|
|
script: string;
|
|
}
|
|
|
|
interface Step2FormData extends RunTemplateData { }
|
|
|
|
interface FormData {
|
|
step1: Step1FormData;
|
|
step2: Step2FormData;
|
|
}
|
|
|
|
type UploadFieldKey = 'characterImage' | 'productImage';
|
|
|
|
const FALLBACK_PREVIEW =
|
|
'https://images.unsplash.com/photo-1542204165-0198c1e21a3b?auto=format&fit=crop&w=1200&q=80';
|
|
const FALLBACK_INSET =
|
|
'https://images.unsplash.com/photo-1524504388940-b1c1722653e1?auto=format&fit=crop&w=640&q=80';
|
|
|
|
export default function TemplateFormScreen() {
|
|
const { id } = useLocalSearchParams<{ id: string }>();
|
|
const router = useRouter();
|
|
|
|
const [currentStep, setCurrentStep] = useState(1);
|
|
const [template, setTemplate] = useState<Template | null>(null);
|
|
const [formSchema, setFormSchema] = useState<RunFormSchema>({ fields: [] });
|
|
const [loading, setLoading] = useState(true);
|
|
|
|
const [formData, setFormData] = useState<FormData>({
|
|
step1: {
|
|
characterImage: '',
|
|
productImage: '',
|
|
script: '',
|
|
},
|
|
step2: {},
|
|
});
|
|
|
|
const [errors, setErrors] = useState<Record<string, string>>({});
|
|
const [isSubmitting, setIsSubmitting] = useState(false);
|
|
|
|
useEffect(() => {
|
|
loadInitialData();
|
|
}, [id]);
|
|
|
|
const loadInitialData = async () => {
|
|
try {
|
|
setLoading(true);
|
|
const templateResponse = await getTemplateById(id);
|
|
|
|
if (templateResponse.success && templateResponse.data) {
|
|
setTemplate(templateResponse.data);
|
|
|
|
if (templateResponse.data.formSchema) {
|
|
try {
|
|
const rawData =
|
|
typeof templateResponse.data.formSchema === 'string'
|
|
? JSON.parse(templateResponse.data.formSchema as string)
|
|
: templateResponse.data.formSchema;
|
|
|
|
const formConfig = transformWorkflowToFormSchema(rawData);
|
|
|
|
if (validateFormSchema(formConfig)) {
|
|
setFormSchema(formConfig);
|
|
}
|
|
} catch (error) {
|
|
console.error('Failed to parse form schema:', error);
|
|
}
|
|
}
|
|
}
|
|
} catch (error) {
|
|
console.error('Failed to load template:', error);
|
|
Alert.alert('Error', 'Failed to load data. Please retry later.');
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
};
|
|
|
|
const validateStep1 = (): boolean => {
|
|
const newErrors: Record<string, string> = {};
|
|
|
|
if (!formData.step1.characterImage) {
|
|
newErrors.characterImage = 'Please upload the character image';
|
|
}
|
|
|
|
if (!formData.step1.productImage) {
|
|
newErrors.productImage = 'Please upload the product image';
|
|
}
|
|
|
|
if (!formData.step1.script.trim()) {
|
|
newErrors.script = 'Please provide the character script';
|
|
}
|
|
|
|
setErrors(newErrors);
|
|
return Object.keys(newErrors).length === 0;
|
|
};
|
|
|
|
const handleStep1Next = () => {
|
|
if (validateStep1()) {
|
|
setCurrentStep(2);
|
|
}
|
|
};
|
|
|
|
const handleStep2Prev = () => {
|
|
setCurrentStep(1);
|
|
};
|
|
|
|
const handleStep2Submit = async () => {
|
|
if (!template) return;
|
|
|
|
setIsSubmitting(true);
|
|
|
|
try {
|
|
const combinedData = {
|
|
...formData.step1,
|
|
...formData.step2,
|
|
};
|
|
|
|
console.log('Submitting template payload:', combinedData);
|
|
|
|
Alert.alert('Success', 'Template configuration saved.', [
|
|
{
|
|
text: 'OK',
|
|
onPress: () => {
|
|
router.push(`/templates/${id}/run`);
|
|
},
|
|
},
|
|
]);
|
|
} catch (error) {
|
|
console.error('Submission failed:', error);
|
|
Alert.alert('Error', 'Submission failed. Please retry later.');
|
|
} finally {
|
|
setIsSubmitting(false);
|
|
}
|
|
};
|
|
|
|
const handleImagePick = async (field: UploadFieldKey) => {
|
|
try {
|
|
const permission = await ImagePicker.requestMediaLibraryPermissionsAsync();
|
|
|
|
if (!permission.granted) {
|
|
Alert.alert(
|
|
'Permission Required',
|
|
'Please allow photo library access in Settings to upload images.',
|
|
);
|
|
return;
|
|
}
|
|
|
|
const result = await ImagePicker.launchImageLibraryAsync({
|
|
mediaTypes: ImagePicker.MediaTypeOptions.Images,
|
|
allowsEditing: true,
|
|
quality: 0.9,
|
|
});
|
|
|
|
if (result.canceled || !result.assets || result.assets.length === 0) {
|
|
return;
|
|
}
|
|
|
|
const uri = result.assets[0].uri;
|
|
|
|
setFormData(prev => ({
|
|
...prev,
|
|
step1: {
|
|
...prev.step1,
|
|
[field]: uri,
|
|
},
|
|
}));
|
|
|
|
if (errors[field]) {
|
|
setErrors(prev => ({
|
|
...prev,
|
|
[field]: '',
|
|
}));
|
|
}
|
|
} catch (error) {
|
|
console.error('Failed to pick image:', error);
|
|
Alert.alert('Error', 'Unable to pick image. Please try again later.');
|
|
}
|
|
};
|
|
|
|
const renderUploadCard = (
|
|
field: UploadFieldKey,
|
|
label: string,
|
|
description: string,
|
|
) => {
|
|
const value = formData.step1[field];
|
|
|
|
return (
|
|
<View style={styles.uploadItem}>
|
|
<TouchableOpacity
|
|
activeOpacity={0.9}
|
|
onPress={() => handleImagePick(field)}
|
|
style={[
|
|
styles.uploadCard,
|
|
value && styles.uploadCardFilled,
|
|
errors[field] && styles.uploadCardError,
|
|
]}
|
|
>
|
|
{value ? (
|
|
<Image source={{ uri: value }} style={styles.uploadImage} />
|
|
) : (
|
|
<>
|
|
<View style={styles.uploadIconWrap}>
|
|
<UploadCloud size={26} color="#D1FF00" strokeWidth={1.4} />
|
|
</View>
|
|
<Text style={styles.uploadLabel}>{label}</Text>
|
|
<Text style={styles.uploadDescription}>{description}</Text>
|
|
</>
|
|
)}
|
|
</TouchableOpacity>
|
|
{errors[field] ? (
|
|
<Text style={styles.inlineError}>{errors[field]}</Text>
|
|
) : null}
|
|
</View>
|
|
);
|
|
};
|
|
|
|
const renderStep1 = () => {
|
|
const heroImage = template?.previewUrl || template?.coverImageUrl || FALLBACK_PREVIEW;
|
|
const insetImage = template?.coverImageUrl || FALLBACK_INSET;
|
|
|
|
return (
|
|
<ScrollView
|
|
style={styles.stepScroll}
|
|
contentContainerStyle={styles.step1Content}
|
|
showsVerticalScrollIndicator={false}
|
|
>
|
|
<View style={styles.topBar}>
|
|
<BackButton onPress={() => router.back()} />
|
|
</View>
|
|
|
|
<Text style={styles.heroTitle}>
|
|
{(template?.title ?? 'Insert your product into an ASMR video').toUpperCase()}
|
|
</Text>
|
|
<Text style={styles.heroSubtitle}>
|
|
{template?.description ||
|
|
'ASMR-Add-On keeps your original image the same while seamlessly adding our uploaded product into the ASMR scene.'}
|
|
</Text>
|
|
|
|
<View style={styles.previewStage}>
|
|
<Image source={{ uri: heroImage }} style={styles.previewImage} />
|
|
<View style={styles.previewInset}>
|
|
<Image source={{ uri: insetImage }} style={styles.previewInsetImage} />
|
|
</View>
|
|
</View>
|
|
|
|
<View style={styles.uploadRow}>
|
|
{renderUploadCard(
|
|
'characterImage',
|
|
'UPLOAD CHARACTER IMAGE',
|
|
'PNG, JPG or Paste from clipboard',
|
|
)}
|
|
{renderUploadCard(
|
|
'productImage',
|
|
'UPLOAD CHARACTER IMAGE',
|
|
'PNG, JPG or Paste from clipboard',
|
|
)}
|
|
</View>
|
|
|
|
<View style={styles.promptField}>
|
|
<TextInput
|
|
style={[styles.promptInput, errors.script && styles.promptInputError]}
|
|
placeholder="What should your character say?"
|
|
placeholderTextColor="rgba(255, 255, 255, 0.5)"
|
|
value={formData.step1.script}
|
|
onChangeText={text => {
|
|
setFormData(prev => ({
|
|
...prev,
|
|
step1: {
|
|
...prev.step1,
|
|
script: text,
|
|
},
|
|
}));
|
|
if (errors.script) {
|
|
setErrors(prev => ({
|
|
...prev,
|
|
script: '',
|
|
}));
|
|
}
|
|
}}
|
|
multiline
|
|
textAlignVertical="top"
|
|
/>
|
|
{errors.script ? <Text style={styles.inlineError}>{errors.script}</Text> : null}
|
|
</View>
|
|
|
|
<TouchableOpacity
|
|
style={styles.generateButton}
|
|
activeOpacity={0.88}
|
|
onPress={handleStep1Next}
|
|
>
|
|
<Feather name="star" size={20} color="#050505" />
|
|
<Text style={styles.generateLabel}>Generate Video</Text>
|
|
</TouchableOpacity>
|
|
</ScrollView>
|
|
);
|
|
};
|
|
|
|
const renderStep2 = () => (
|
|
<View style={styles.step2Wrapper}>
|
|
<View style={styles.topBar}>
|
|
<BackButton onPress={handleStep2Prev} />
|
|
<Text style={styles.stepIndicator}>STEP 2 OF 2</Text>
|
|
</View>
|
|
|
|
<Text style={styles.step2Title}>Run Parameters</Text>
|
|
<Text style={styles.step2Subtitle}>
|
|
Fine-tune the details so the ASMR video delivers the mood you expect.
|
|
</Text>
|
|
|
|
<View style={styles.step2FormShell}>
|
|
{formSchema.fields.length > 0 ? (
|
|
<DynamicForm
|
|
schema={formSchema}
|
|
initialData={formData.step2}
|
|
onDataChange={data => {
|
|
setFormData(prev => ({
|
|
...prev,
|
|
step2: data,
|
|
}));
|
|
}}
|
|
/>
|
|
) : (
|
|
<View style={styles.emptyState}>
|
|
<Text style={styles.emptyIcon}>📋</Text>
|
|
<Text style={styles.emptyText}>No extra parameters</Text>
|
|
<Text style={styles.emptyDescription}>
|
|
This template is ready to go—start generating the ASMR experience instantly.
|
|
</Text>
|
|
</View>
|
|
)}
|
|
</View>
|
|
</View>
|
|
);
|
|
|
|
const renderBottomActions = () => {
|
|
if (currentStep !== 2) {
|
|
return null;
|
|
}
|
|
|
|
return (
|
|
<SafeAreaView edges={['bottom']} style={styles.bottomSafeArea}>
|
|
<View style={styles.bottomActions}>
|
|
<TouchableOpacity
|
|
style={styles.secondaryButton}
|
|
activeOpacity={0.85}
|
|
onPress={handleStep2Prev}
|
|
>
|
|
<Text style={styles.secondaryLabel}>Back</Text>
|
|
</TouchableOpacity>
|
|
<TouchableOpacity
|
|
style={styles.submitButton}
|
|
activeOpacity={0.9}
|
|
onPress={handleStep2Submit}
|
|
disabled={isSubmitting}
|
|
>
|
|
{isSubmitting ? (
|
|
<ActivityIndicator color="#050505" />
|
|
) : (
|
|
<>
|
|
<Feather name="zap" size={18} color="#050505" />
|
|
<Text style={styles.submitLabel}>Generate Video</Text>
|
|
</>
|
|
)}
|
|
</TouchableOpacity>
|
|
</View>
|
|
</SafeAreaView>
|
|
);
|
|
};
|
|
|
|
if (loading) {
|
|
return (
|
|
<>
|
|
<Stack.Screen options={{ headerShown: false }} />
|
|
|
|
<View style={styles.loadingCanvas}>
|
|
<SafeAreaView style={styles.safeArea} edges={['top', 'left', 'right']}>
|
|
<View style={styles.loadingContainer}>
|
|
<ActivityIndicator size="large" color="#D1FF00" />
|
|
<Text style={styles.loadingText}>Loading...</Text>
|
|
</View>
|
|
</SafeAreaView>
|
|
</View>
|
|
</>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<>
|
|
<Stack.Screen options={{ headerShown: false }} />
|
|
|
|
<View style={styles.canvas}>
|
|
<SafeAreaView style={styles.safeArea} edges={['top', 'left', 'right']}>
|
|
{currentStep === 1 ? renderStep1() : renderStep2()}
|
|
</SafeAreaView>
|
|
{renderBottomActions()}
|
|
</View>
|
|
</>
|
|
);
|
|
}
|
|
|
|
const styles = StyleSheet.create({
|
|
canvas: {
|
|
flex: 1,
|
|
backgroundColor: '#050505',
|
|
},
|
|
safeArea: {
|
|
flex: 1,
|
|
},
|
|
loadingCanvas: {
|
|
flex: 1,
|
|
backgroundColor: '#050505',
|
|
},
|
|
loadingContainer: {
|
|
flex: 1,
|
|
justifyContent: 'center',
|
|
alignItems: 'center',
|
|
},
|
|
loadingText: {
|
|
marginTop: 16,
|
|
fontSize: 16,
|
|
color: 'rgba(255, 255, 255, 0.7)',
|
|
letterSpacing: 0.3,
|
|
},
|
|
stepScroll: {
|
|
flex: 1,
|
|
},
|
|
step1Content: {
|
|
paddingHorizontal: 24,
|
|
paddingBottom: 40,
|
|
},
|
|
topBar: {
|
|
flexDirection: 'row',
|
|
alignItems: 'center',
|
|
justifyContent: 'space-between',
|
|
paddingHorizontal: 0,
|
|
paddingTop: 8,
|
|
paddingBottom: 8,
|
|
gap: 12,
|
|
},
|
|
stepIndicator: {
|
|
flex: 1,
|
|
textAlign: 'right',
|
|
fontSize: 13,
|
|
letterSpacing: 2,
|
|
color: 'rgba(255, 255, 255, 0.5)',
|
|
},
|
|
heroTitle: {
|
|
fontSize: 26,
|
|
lineHeight: 32,
|
|
fontWeight: '700',
|
|
color: '#FFFFFF',
|
|
letterSpacing: 0.8,
|
|
textTransform: 'uppercase',
|
|
marginBottom: 16,
|
|
},
|
|
heroSubtitle: {
|
|
fontSize: 14,
|
|
lineHeight: 20,
|
|
color: 'rgba(255, 255, 255, 0.68)',
|
|
marginBottom: 24,
|
|
},
|
|
previewStage: {
|
|
borderRadius: 32,
|
|
overflow: 'hidden',
|
|
backgroundColor: '#111318',
|
|
marginBottom: 28,
|
|
},
|
|
previewImage: {
|
|
width: '100%',
|
|
height: 220,
|
|
},
|
|
previewInset: {
|
|
position: 'absolute',
|
|
bottom: 18,
|
|
left: 18,
|
|
width: 74,
|
|
height: 74,
|
|
borderRadius: 24,
|
|
overflow: 'hidden',
|
|
},
|
|
previewInsetImage: {
|
|
width: '100%',
|
|
height: '100%',
|
|
},
|
|
uploadRow: {
|
|
flexDirection: 'row',
|
|
gap: 18,
|
|
marginBottom: 24,
|
|
},
|
|
uploadItem: {
|
|
flex: 1,
|
|
},
|
|
uploadCard: {
|
|
borderRadius: 28,
|
|
borderWidth: 1,
|
|
borderColor: 'rgba(255, 255, 255, 0.08)',
|
|
backgroundColor: 'rgba(17, 19, 24, 0.95)',
|
|
paddingHorizontal: 20,
|
|
paddingVertical: 28,
|
|
alignItems: 'center',
|
|
justifyContent: 'center',
|
|
minHeight: 188,
|
|
overflow: 'hidden',
|
|
},
|
|
uploadCardFilled: {
|
|
paddingHorizontal: 0,
|
|
paddingVertical: 0,
|
|
},
|
|
uploadCardError: {
|
|
borderColor: '#FF6B6B',
|
|
},
|
|
uploadIconWrap: {
|
|
width: 60,
|
|
height: 60,
|
|
borderRadius: 22,
|
|
backgroundColor: 'rgba(209, 255, 0, 0.12)',
|
|
alignItems: 'center',
|
|
justifyContent: 'center',
|
|
marginBottom: 16,
|
|
},
|
|
uploadLabel: {
|
|
fontSize: 14,
|
|
fontWeight: '700',
|
|
letterSpacing: 0.6,
|
|
color: '#FFFFFF',
|
|
textAlign: 'center',
|
|
textTransform: 'uppercase',
|
|
marginBottom: 6,
|
|
},
|
|
uploadDescription: {
|
|
fontSize: 12,
|
|
lineHeight: 16,
|
|
color: 'rgba(255, 255, 255, 0.55)',
|
|
textAlign: 'center',
|
|
},
|
|
uploadImage: {
|
|
width: '100%',
|
|
height: '100%',
|
|
resizeMode: 'cover',
|
|
},
|
|
promptField: {
|
|
marginBottom: 18,
|
|
},
|
|
promptInput: {
|
|
borderRadius: 28,
|
|
borderWidth: 1,
|
|
borderColor: 'rgba(255, 255, 255, 0.08)',
|
|
backgroundColor: 'rgba(17, 19, 24, 0.95)',
|
|
minHeight: 140,
|
|
paddingHorizontal: 22,
|
|
paddingVertical: 22,
|
|
fontSize: 15,
|
|
lineHeight: 22,
|
|
color: '#FFFFFF',
|
|
},
|
|
promptInputError: {
|
|
borderColor: '#FF6B6B',
|
|
},
|
|
inlineError: {
|
|
marginTop: 8,
|
|
fontSize: 12,
|
|
color: '#FF6B6B',
|
|
letterSpacing: 0.2,
|
|
},
|
|
generateButton: {
|
|
flexDirection: 'row',
|
|
alignItems: 'center',
|
|
justifyContent: 'center',
|
|
height: 58,
|
|
borderRadius: 30,
|
|
backgroundColor: '#D1FF00',
|
|
gap: 10,
|
|
},
|
|
generateLabel: {
|
|
fontSize: 17,
|
|
fontWeight: '700',
|
|
letterSpacing: 0.4,
|
|
color: '#050505',
|
|
},
|
|
step2Wrapper: {
|
|
flex: 1,
|
|
paddingHorizontal: 24,
|
|
paddingBottom: 24,
|
|
},
|
|
step2Title: {
|
|
fontSize: 22,
|
|
fontWeight: '700',
|
|
color: '#FFFFFF',
|
|
letterSpacing: 0.6,
|
|
marginBottom: 10,
|
|
},
|
|
step2Subtitle: {
|
|
fontSize: 13,
|
|
lineHeight: 18,
|
|
color: 'rgba(255, 255, 255, 0.6)',
|
|
marginBottom: 20,
|
|
},
|
|
step2FormShell: {
|
|
flex: 1,
|
|
borderRadius: 32,
|
|
backgroundColor: 'rgba(17, 19, 24, 0.78)',
|
|
overflow: 'hidden',
|
|
},
|
|
emptyState: {
|
|
flex: 1,
|
|
paddingHorizontal: 32,
|
|
alignItems: 'center',
|
|
justifyContent: 'center',
|
|
},
|
|
emptyIcon: {
|
|
fontSize: 48,
|
|
marginBottom: 16,
|
|
color: '#FFFFFF',
|
|
},
|
|
emptyText: {
|
|
fontSize: 18,
|
|
fontWeight: '600',
|
|
color: '#FFFFFF',
|
|
marginBottom: 8,
|
|
},
|
|
emptyDescription: {
|
|
fontSize: 14,
|
|
lineHeight: 20,
|
|
color: 'rgba(255, 255, 255, 0.6)',
|
|
textAlign: 'center',
|
|
},
|
|
bottomSafeArea: {
|
|
backgroundColor: '#050505',
|
|
},
|
|
bottomActions: {
|
|
flexDirection: 'row',
|
|
gap: 16,
|
|
paddingHorizontal: 24,
|
|
paddingTop: 12,
|
|
paddingBottom: 24,
|
|
borderTopWidth: 1,
|
|
borderTopColor: 'rgba(255, 255, 255, 0.08)',
|
|
},
|
|
secondaryButton: {
|
|
flex: 1,
|
|
height: 56,
|
|
borderRadius: 28,
|
|
borderWidth: 1,
|
|
borderColor: 'rgba(255, 255, 255, 0.2)',
|
|
alignItems: 'center',
|
|
justifyContent: 'center',
|
|
},
|
|
secondaryLabel: {
|
|
fontSize: 15,
|
|
fontWeight: '600',
|
|
color: 'rgba(255, 255, 255, 0.78)',
|
|
letterSpacing: 0.2,
|
|
},
|
|
submitButton: {
|
|
flex: 1.6,
|
|
height: 56,
|
|
borderRadius: 28,
|
|
backgroundColor: '#D1FF00',
|
|
flexDirection: 'row',
|
|
alignItems: 'center',
|
|
justifyContent: 'center',
|
|
gap: 10,
|
|
},
|
|
submitLabel: {
|
|
fontSize: 16,
|
|
fontWeight: '700',
|
|
color: '#050505',
|
|
letterSpacing: 0.3,
|
|
},
|
|
});
|