feat: 登录 #1

Merged
txcjh merged 2 commits from v0.4.0_front into main 2025-06-26 16:06:38 +08:00
98 changed files with 4627 additions and 571 deletions

4
.gitignore vendored
View File

@ -37,3 +37,7 @@ yarn-error.*
*.tsbuildinfo *.tsbuildinfo
app-example app-example
# Expo prebuild generated files
android/
ios/

View File

@ -4,7 +4,7 @@
"slug": "memowake", "slug": "memowake",
"version": "1.0.0", "version": "1.0.0",
"orientation": "portrait", "orientation": "portrait",
"icon": "./assets/images/icon.png", "icon": "",
"scheme": "memowake", "scheme": "memowake",
"userInterfaceStyle": "automatic", "userInterfaceStyle": "automatic",
"newArchEnabled": true, "newArchEnabled": true,
@ -13,7 +13,7 @@
}, },
"android": { "android": {
"adaptiveIcon": { "adaptiveIcon": {
"foregroundImage": "./assets/images/adaptive-icon.png", "foregroundImage": "",
"backgroundColor": "#ffffff" "backgroundColor": "#ffffff"
}, },
"edgeToEdgeEnabled": true, "edgeToEdgeEnabled": true,
@ -22,24 +22,26 @@
"web": { "web": {
"bundler": "metro", "bundler": "metro",
"output": "static", "output": "static",
"favicon": "./assets/images/favicon.png" "favicon": ""
}, },
"plugins": [ "plugins": [
"expo-router", "expo-router",
"expo-secure-store",
[ [
"expo-splash-screen", "expo-font",
{ {
"image": "./assets/images/splash-icon.png", "fonts": [
"imageWidth": 200, "./assets/fonts/[font-file.ttf]"
"resizeMode": "contain", ]
"backgroundColor": "#ffffff"
} }
], ],
[ [
"expo-secure-store", "expo-splash-screen",
{ {
"configureAndroidBackup": true, "image": "",
"faceIDPermission": "Allow $(PRODUCT_NAME) to access your Face ID biometric data." "imageWidth": 200,
"resizeMode": "contain",
"backgroundColor": "#ffffff"
} }
] ]
], ],

View File

@ -3,7 +3,6 @@ import React from 'react';
import { Platform } from 'react-native'; import { Platform } from 'react-native';
import { HapticTab } from '@/components/HapticTab'; import { HapticTab } from '@/components/HapticTab';
import { IconSymbol } from '@/components/ui/IconSymbol';
import TabBarBackground from '@/components/ui/TabBarBackground'; import TabBarBackground from '@/components/ui/TabBarBackground';
import { Colors } from '@/constants/Colors'; import { Colors } from '@/constants/Colors';
import { useColorScheme } from '@/hooks/useColorScheme'; import { useColorScheme } from '@/hooks/useColorScheme';
@ -26,11 +25,58 @@ export default function TabLayout() {
default: {}, default: {},
}), }),
}}> }}>
{/* 落地页 */}
<Tabs.Screen <Tabs.Screen
name="index" name="index"
options={{ options={{
title: 'Memo', title: 'Memo',
tabBarIcon: ({ color }) => <IconSymbol size={28} name="house.fill" color={color} />, tabBarButton: () => null, // 隐藏底部标签栏
headerShown: false, // 隐藏导航栏
tabBarStyle: { display: 'none' } // 确保在标签栏中不显示
}}
/>
{/* 登录 */}
<Tabs.Screen
name="login"
options={{
title: 'Login',
href: '/login',
// tabBarButton: () => null, // 隐藏底部标签栏
headerShown: false, // 隐藏导航栏
tabBarStyle: { display: 'none' } // 确保在标签栏中不显示
}}
/>
{/* 重置密码 */}
<Tabs.Screen
name="reset-password"
options={{
title: 'reset-password',
href: '/reset-password',
// tabBarButton: () => null, // 隐藏底部标签栏
headerShown: false, // 隐藏导航栏
tabBarStyle: { display: 'none' } // 确保在标签栏中不显示
}}
/>
{/* loading页面 */}
<Tabs.Screen
name="loading"
options={{
title: 'loading',
href: '/loading',
// tabBarButton: () => null, // 隐藏底部标签栏
headerShown: false, // 隐藏导航栏
tabBarStyle: { display: 'none' } // 确保在标签栏中不显示
}}
/>
{/* 用户信息收集 */}
<Tabs.Screen
name="user-message"
options={{
title: 'user-message',
href: '/user-message',
// tabBarButton: () => null, // 隐藏底部标签栏
headerShown: false, // 隐藏导航栏
tabBarStyle: { display: 'none' } // 确保在标签栏中不显示
}} }}
/> />
</Tabs> </Tabs>

View File

@ -1,82 +1,63 @@
import { ThemedText } from '@/components/ThemedText'; import MemoChat from '@/assets/icons/svg/memo-chat.svg';
import { ThemedView } from '@/components/ThemedView'; import MemoIP from '@/assets/icons/svg/memo-ip.svg';
import { useAuth } from '@/contexts/auth-context'; import { useRouter } from 'expo-router';
import { fetchApi } from '@/lib/server-api-util';
import { store } from '@/store';
import { User } from '@/types/user';
import { Ionicons } from '@expo/vector-icons';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { TouchableOpacity, View } from 'react-native'; import { Text, TouchableOpacity, View } from 'react-native';
export default function HomeScreen() { export default function HomeScreen() {
const { login } = useAuth(); const router = useRouter();
const token = store.getState().auth.token;
console.log(token);
const { t } = useTranslation(); const { t } = useTranslation();
const handleApi = () => {
fetchApi('/iam/login/password-login', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
account: "jinyaqiu@fairclip.cn",
password: "111111",
}),
})
.then((data) => {
login(data as User, data.access_token || '')
})
.catch((err) => console.log(err));
// fetch('http://192.168.31.42/api/v1/iam/login/password-login', {
// method: 'POST',
// headers: {
// 'Content-Type': 'application/json',
// },
// body: JSON.stringify({
// account: "jinyaqiu@fairclip.cn",
// password: "111111",
// }),
// })
// .then((res) => res.json())
// .then((data) => console.log(data))
// .catch((err) => console.log(err));
}
return ( return (
<ThemedView className="flex-1 bg-beige"> <View className="flex-1 bg-bgPrimary px-[1rem] h-screen overflow-auto py-[2rem]">
<View className="flex-1 items-center pt-[60px] px-5"> {/* 标题区域 */}
<ThemedText className="text-3xl font-bold mb-8 text-saddlebrown" onPress={handleApi}> <View className="items-start mb-10 w-full px-5">
{t('title', { ns: "example" })} <Text className="text-white text-3xl font-bold mb-3 text-left">
</ThemedText> {t('auth.welcomeAwaken.awaken', { ns: 'login' })}
<br />
{t('auth.welcomeAwaken.your', { ns: 'login' })}
<br />
{t('auth.welcomeAwaken.pm', { ns: 'login' })}
</Text>
<Text className="text-white/85 text-base text-left">
{t('auth.welcomeAwaken.slogan', { ns: 'login' })}
</Text>
</View>
<View className="mb-8 items-center"> {/* Memo 形象区域 */}
<View className="w-[150px] h-[150px] rounded-full bg-white/80 justify-center items-center border-8 border-saddlebrown shadow-lg"> <View className="items-center w-full relative">
<View className="items-center"> {/* 气泡对话框 */}
<Ionicons name="person" size={60} color="#8B4513" /> <View className="absolute -top-[7rem] right-[1rem] z-10">
<ThemedText className="mt-1 text-lg font-semibold text-saddlebrown"> <View className="relative top-[12rem] -right-[3rem] z-10 w-3 h-3 bg-white rounded-full" />
MeMo <View className="relative top-[9.5rem] -right-[3.5rem] z-10 w-4 h-4 bg-white rounded-full" />
</ThemedText> <Text className="text-[#AC7E35] font-bold text-lg text-center leading-6 relative top-[4rem] left-0">
</View> {t('auth.welcomeAwaken.hi', { ns: 'login' })}
</View> <br />
{t('auth.welcomeAwaken.memo', { ns: 'login' })}
</Text>
<MemoChat />
</View> </View>
{/* Memo 形象 */}
<ThemedText className="text-base text-center mb-10 text-saddlebrown px-5 leading-6"> <View className="justify-center items-center">
Ready to wake up your memories? Just ask! Let MeMo bring them back to life! <MemoIP />
</ThemedText>
<TouchableOpacity
className="w-20 h-20 rounded-full bg-white/90 justify-center items-center mb-8 shadow-lg"
>
<Ionicons name="mic" size={32} color="#8B0000" />
</TouchableOpacity>
<View className="w-full items-center mt-auto mb-8">
<View className="h-px w-4/5 bg-saddlebrown/50 mb-4" />
<ThemedText className="text-center text-saddlebrown text-sm">
<ThemedText className="font-bold text-darkred">Explore More</ThemedText> Memory Reel Made Just For You!
</ThemedText>
</View> </View>
</View> </View>
</ThemedView >
{/* 介绍文本 */}
<Text className="text-white text-base text-center mb-[1rem] leading-6 opacity-90 px-10 -mt-[4rem]">
{t('auth.welcomeAwaken.gallery', { ns: 'login' })}
<br />
{t('auth.welcomeAwaken.back', { ns: 'login' })}
</Text>
{/* 唤醒按钮 */}
<TouchableOpacity
className="bg-white rounded-full px-10 py-4 shadow-[0_2px_4px_rgba(0,0,0,0.1)] w-full items-center"
onPress={() => router.push('/login')}
activeOpacity={0.8}
>
<Text className="text-[#4C320C] font-bold text-lg">
{t('auth.welcomeAwaken.awake', { ns: 'login' })}
</Text>
</TouchableOpacity>
</View>
); );
} }

13
app/(tabs)/loading.tsx Normal file
View File

@ -0,0 +1,13 @@
import LoadingIP from '@/assets/icons/svg/loadingIP.svg';
import { View } from 'react-native';
export default function LoadingScreen() {
return (
<View className="flex-1 bg-white justify-center items-center p-5">
<View className="items-center">
<LoadingIP />
<View className="text-[#AC7E35]">Loading...</View>
</View>
</View>
);
}

136
app/(tabs)/login.tsx Normal file
View File

@ -0,0 +1,136 @@
import Handers from '@/assets/icons/svg/handers.svg';
import LoginIP1 from '@/assets/icons/svg/loginIp1.svg';
import LoginIP2 from '@/assets/icons/svg/loginIp2.svg';
import ForgetPwd from '@/components/login/forgetPwd';
import PhoneLogin from '@/components/login/phoneLogin';
import SignUp from '@/components/login/signUp';
import { ThemedText } from '@/components/ThemedText';
import { ThemedView } from '@/components/ThemedView';
import { useLocalSearchParams, useRouter } from 'expo-router';
import React, { useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { LayoutChangeEvent, TouchableOpacity, View, ViewStyle, useWindowDimensions } from 'react-native';
const LoginScreen = () => {
const router = useRouter();
const { t } = useTranslation();
const { status } = useLocalSearchParams();
const [error, setError] = useState<string>('123');
const [containerHeight, setContainerHeight] = useState(0);
const { height: windowHeight } = useWindowDimensions();
// 密码可视
const [showPassword, setShowPassword] = useState(false);
const handleLayout = (event: LayoutChangeEvent) => {
const { height } = event.nativeEvent.layout;
setContainerHeight(height);
};
// 更新URL参数而不刷新页面
const updateUrlParam = (key: string, value: string) => {
router.setParams({ [key]: value });
}
// 初始化
useEffect(() => {
setError('123')
}, [])
return (
<ThemedView className="flex-1 bg-bgPrimary justify-end">
<View className="flex-1">
<View
className="absolute left-1/2 z-10"
style={{
top: containerHeight > 0 ? windowHeight - containerHeight - 210 : 0,
transform: [{ translateX: -200 }]
}}
>
{
showPassword
?
<LoginIP2 />
:
<LoginIP1 />
}
</View>
<View
className="absolute left-1/2 z-[1000] -translate-x-[39.5px] -translate-y-[4px]"
style={{
top: containerHeight > 0 ? windowHeight - containerHeight - 1 : 0
}}
>
<Handers />
</View>
</View>
<ThemedView
className="w-full bg-white pt-12 px-6 relative z-20 shadow-lg pb-5"
style={{
borderTopLeftRadius: 24,
borderTopRightRadius: 24,
shadowColor: '#000',
shadowOffset: { width: 0, height: 2 },
shadowOpacity: 0.1,
shadowRadius: 8,
elevation: 5
} as ViewStyle}
onLayout={handleLayout}
>
{/* 错误提示 */}
<View className={`${error !== "123" ? 'opacity-100' : 'opacity-0'} w-full flex justify-center items-center text-primary-500 text-sm`}>
<ThemedText className="text-sm !text-textPrimary">
{error}
</ThemedText>
</View>
{(() => {
const commonProps = {
updateUrlParam,
setError,
};
const components = {
signUp: (
<SignUp
{...commonProps}
setShowPassword={setShowPassword}
showPassword={showPassword}
/>
),
forgetPwd: (
<ForgetPwd
{...commonProps}
/>
),
login: (
<PhoneLogin />
)
};
return components[status as keyof typeof components] || components.login;
})()}
{status == 'login' || !status &&
<View className="flex-row justify-center mt-2">
<ThemedText className="text-sm !text-textPrimary">
{status === 'login' || !status ? t('auth.agree.logintext', { ns: 'login' }) : t('auth.agree.singupText', { ns: 'login' })}
</ThemedText>
<TouchableOpacity onPress={() => { }}>
<ThemedText className="text-sm font-semibold ml-1 !text-textPrimary underline">
{t('auth.agree.terms', { ns: 'login' })}
</ThemedText>
</TouchableOpacity>
<ThemedText className="text-sm !text-textPrimary">
{t('auth.agree.join', { ns: 'login' })}
</ThemedText>
<TouchableOpacity onPress={() => { }}>
<ThemedText className="!text-textPrimary underline text-sm font-semibold ml-1">
{t('auth.agree.privacyPolicy', { ns: 'login' })}
</ThemedText>
</TouchableOpacity>
</View>
}
</ThemedView>
</ThemedView>
);
}
export default LoginScreen

View File

@ -0,0 +1,162 @@
import { ThemedText } from '@/components/ThemedText';
import { ThemedView } from '@/components/ThemedView';
import { useAuth } from '@/contexts/auth-context';
import { fetchApi } from '@/lib/server-api-util';
import { User } from '@/types/user';
import { Ionicons } from '@expo/vector-icons';
import { useLocalSearchParams, useRouter } from 'expo-router';
import React, { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { ActivityIndicator, KeyboardAvoidingView, Platform, ScrollView, TextInput, TouchableOpacity, View } from 'react-native';
const resetPassword = () => {
const { t } = useTranslation();
const router = useRouter();
const { session_id: resetPasswordSessionId, token } = useLocalSearchParams<{ session_id: string; token: string }>();
// 使用 auth context 登录
const { login } = useAuth();
const [password, setPassword] = useState('');
const [confirmPassword, setConfirmPassword] = useState('');
const [loading, setLoading] = useState(false);
const [showPassword, setShowPassword] = useState(false);
const [error, setError] = useState('');
const validatePassword = (pwd: string) => {
const passwordRegex = /^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[@$!%*?&])[A-Za-z\d@$!%*?&]{8,}$/;
return passwordRegex.test(pwd);
};
const handleSubmit = async () => {
if (!password) {
setError(t('auth.login.passwordPlaceholder', { ns: 'login' }));
return;
}
if (password !== confirmPassword) {
setError(t('auth.signup.passwordNotMatch', { ns: 'login' }));
return;
}
if (!validatePassword(password)) {
setError(t('auth.signup.passwordAuth', { ns: 'login' }));
return;
}
setLoading(true);
setError('');
try {
const body = {
new_password: password,
reset_password_session_id: resetPasswordSessionId,
token
};
const response = await fetchApi<User>('/iam/reset-password', {
method: 'POST',
body: JSON.stringify(body),
headers: {
'Content-Type': 'application/json'
}
});
if (login) {
login(response, response.access_token || '');
}
} catch (error) {
console.error('Reset password error:', error);
setError(t('auth.resetPwd.error', { ns: 'login' }) || 'Failed to reset password');
} finally {
setLoading(false);
}
};
return (
<KeyboardAvoidingView
behavior={Platform.OS === 'ios' ? 'padding' : 'height'}
className="flex-1 bg-white"
>
<ScrollView contentContainerClassName="flex-grow justify-center p-5">
<ThemedView className="w-full max-w-[400px] self-center p-5 rounded-xl bg-white">
<ThemedText className="text-2xl font-bold mb-6 text-center text-gray-800">
{t('auth.resetPwd.title', { ns: 'login' })}
</ThemedText>
{error ? (
<ThemedText className="text-red-500 mb-4 text-center">
{error}
</ThemedText>
) : null}
<View className="mb-6">
<View className="flex-row items-center border border-gray-200 rounded-lg px-3">
<TextInput
className="flex-1 h-12 text-gray-800"
placeholder={t('auth.login.passwordPlaceholder', { ns: 'login' })}
placeholderTextColor="#999"
value={password}
onChangeText={setPassword}
secureTextEntry={!showPassword}
autoCapitalize="none"
autoCorrect={false}
/>
<TouchableOpacity
onPress={() => setShowPassword(!showPassword)}
className="p-2"
>
<Ionicons
name={showPassword ? 'eye-off' : 'eye'}
size={20}
color="#666"
/>
</TouchableOpacity>
</View>
<View className="flex-row items-center border border-gray-200 rounded-lg px-3 mt-4">
<TextInput
className="flex-1 h-12 text-gray-800"
placeholder={t('auth.signup.confirmPasswordPlaceholder', { ns: 'login' })}
placeholderTextColor="#999"
value={confirmPassword}
onChangeText={setConfirmPassword}
secureTextEntry={!showPassword}
autoCapitalize="none"
autoCorrect={false}
returnKeyType="done"
onSubmitEditing={handleSubmit}
/>
<TouchableOpacity
onPress={() => setShowPassword(!showPassword)}
className="p-2"
>
<Ionicons
name={showPassword ? 'eye-off' : 'eye'}
size={20}
color="#666"
/>
</TouchableOpacity>
</View>
</View>
<TouchableOpacity
className={`w-full py-4 rounded-lg items-center justify-center ${loading ? 'bg-orange-400' : 'bg-[#E2793F]'}`}
onPress={handleSubmit}
disabled={loading}
>
{loading ? (
<ActivityIndicator color="#fff" />
) : (
<ThemedText className="text-white text-base font-semibold">
{t('auth.resetPwd.resetButton', { ns: 'login' })}
</ThemedText>
)}
</TouchableOpacity>
</ThemedView>
</ScrollView>
</KeyboardAvoidingView>
);
}
export default resetPassword

View File

@ -0,0 +1,79 @@
import { FileStatus } from '@/components/file-upload/file-uploader';
import Choice from '@/components/user-message.tsx/choice';
import Done from '@/components/user-message.tsx/done';
import Look from '@/components/user-message.tsx/look';
import UserName from '@/components/user-message.tsx/userName';
import { fetchApi } from '@/lib/server-api-util';
import { User } from '@/types/user';
import { useEffect, useState } from 'react';
import { View } from 'react-native';
export type Steps = "userName" | "look" | "choice" | "done";
export default function UserMessage() {
// 步骤
const [steps, setSteps] = useState<Steps>("userName")
const [username, setUsername] = useState('')
const [avatar, setAvatar] = useState('')
const [fileData, setFileData] = useState<FileStatus[]>([])
const [isLoading, setIsLoading] = useState(false);
const [userInfo, setUserInfo] = useState<User | null>(null);
// 获取用户信息
const getUserInfo = async () => {
const res = await fetchApi<User>("/iam/user-info");
setUserInfo(res);
setUsername(res?.nickname || '');
setAvatar(res?.avatar_file_url || '');
}
const handleUser = () => {
setIsLoading(true);
fetchApi("/iam/user/info", {
method: "POST",
body: JSON.stringify({
username,
avatar_file_id: fileData?.[0]?.id
})
}).then(() => {
setIsLoading(false);
getUserInfo();
setSteps('done');
}).catch(() => {
setIsLoading(false);
});
};
useEffect(() => {
getUserInfo();
}, []);
return (
<View className="h-screen" key={steps}>
<View className="h-screen" key={steps}>
{(() => {
const components = {
userName: (
<UserName
setSteps={setSteps}
username={username}
setUsername={setUsername}
/>
),
look: (
<Look
setSteps={setSteps}
fileData={fileData}
setFileData={setFileData}
isLoading={isLoading}
handleUser={handleUser}
avatar={avatar}
/>
),
choice: <Choice setSteps={setSteps} />,
done: <Done setSteps={setSteps} />
};
return components[steps as keyof typeof components] || null;
})()}
</View>
</View>
);
}

View File

@ -1,12 +1,12 @@
import { Link, Stack } from 'expo-router'; import { Link, Stack } from 'expo-router';
import { StyleSheet } from 'react-native'; import { StyleSheet, View } from 'react-native';
import { ThemedText } from '@/components/ThemedText'; import { ThemedText } from '@/components/ThemedText';
import { ThemedView } from '@/components/ThemedView'; import { ThemedView } from '@/components/ThemedView';
export default function NotFoundScreen() { export default function NotFoundScreen() {
return ( return (
<> <View>
<Stack.Screen options={{ title: 'Oops!' }} /> <Stack.Screen options={{ title: 'Oops!' }} />
<ThemedView style={styles.container}> <ThemedView style={styles.container}>
<ThemedText type="title">This screen does not exist.</ThemedText> <ThemedText type="title">This screen does not exist.</ThemedText>
@ -14,7 +14,7 @@ export default function NotFoundScreen() {
<ThemedText type="link">Go to home screen!</ThemedText> <ThemedText type="link">Go to home screen!</ThemedText>
</Link> </Link>
</ThemedView> </ThemedView>
</> </View>
); );
} }

View File

@ -14,6 +14,13 @@ export default function RootLayout() {
<Provider> <Provider>
<Stack> <Stack>
<Stack.Screen name="(tabs)" options={{ headerShown: false }} /> <Stack.Screen name="(tabs)" options={{ headerShown: false }} />
<Stack.Screen
name="login"
options={{
headerShown: false,
animation: 'fade'
}}
/>
<Stack.Screen name="+not-found" /> <Stack.Screen name="+not-found" />
</Stack> </Stack>
</Provider> </Provider>

View File

@ -1,3 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-activity-icon lucide-activity">
<path d="M22 12h-2.48a2 2 0 0 0-1.93 1.46l-2.35 8.36a.25.25 0 0 1-.48 0L9.24 2.18a.25.25 0 0 0-.48 0l-2.35 8.36A2 2 0 0 1 4.49 12H2" />
</svg>

Before

Width:  |  Height:  |  Size: 381 B

View File

@ -1,6 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-ai-icon lucide-ai">
<path d="M20.2 6 3 11l-.9-2.4c-.3-1.1.3-2.2 1.3-2.5l13.5-4c1.1-.3 2.2.3 2.5 1.3Z" />
<path d="m6.2 5.3 3.1 3.9" />
<path d="m12.4 3.4 3.1 4" />
<path d="M3 11h18v8a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2Z" />
</svg>

Before

Width:  |  Height:  |  Size: 444 B

View File

@ -1,4 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-album-icon lucide-album">
<rect width="18" height="18" x="3" y="3" rx="2" ry="2" />
<polyline points="11 3 11 11 14 8 17 11 17 3" />
</svg>

Before

Width:  |  Height:  |  Size: 350 B

View File

@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-arrow-big-left-icon lucide-arrow-big-left"><path d="M18 15h-6v4l-7-7 7-7v4h6v6z"/></svg>

Before

Width:  |  Height:  |  Size: 290 B

View File

@ -1,4 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M5 12H19M19 12L12 5M19 12L12 19" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

Before

Width:  |  Height:  |  Size: 274 B

View File

@ -1,4 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M5 12H19M19 12L12 5M19 12L12 19" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

Before

Width:  |  Height:  |  Size: 274 B

View File

@ -1,3 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<polyline points="9 18 15 12 9 6" />
</svg>

Before

Width:  |  Height:  |  Size: 230 B

View File

@ -0,0 +1,4 @@
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M1 10C1 14.9706 5.02944 19 10 19C14.9706 19 19 14.9706 19 10C19 5.02944 14.9706 1 10 1C5.02944 1 1 5.02944 1 10Z" fill="white" />
<path d="M15.2166 17.3323C13.9349 15.9008 12.0727 15 10 15C7.92734 15 6.06492 15.9008 4.7832 17.3323M10 19C5.02944 19 1 14.9706 1 10C1 5.02944 5.02944 1 10 1C14.9706 1 19 5.02944 19 10C19 14.9706 14.9706 19 10 19ZM10 12C8.34315 12 7 10.6569 7 9C7 7.34315 8.34315 6 10 6C11.6569 6 13 7.34315 13 9C13 10.6569 11.6569 12 10 12Z" stroke="#4C320C" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
</svg>

After

Width:  |  Height:  |  Size: 659 B

View File

@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-chevron-left-icon lucide-chevron-left"><path d="m15 18-6-6 6-6"/></svg>

Before

Width:  |  Height:  |  Size: 273 B

View File

@ -1,4 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-badge-check-icon lucide-badge-check">
<path d="M3.85 8.62a4 4 0 0 1 4.78-4.77 4 4 0 0 1 6.74 0 4 4 0 0 1 4.78 4.78 4 4 0 0 1 0 6.74 4 4 0 0 1-4.77 4.78 4 4 0 0 1-6.75 0 4 4 0 0 1-4.78-4.77 4 4 0 0 1 0-6.76Z" />
<path d="m9 12 2 2 4-4" />
</svg>

Before

Width:  |  Height:  |  Size: 455 B

View File

@ -1,9 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-bot-off-icon lucide-bot-off">
<path d="M13.67 8H18a2 2 0 0 1 2 2v4.33" />
<path d="M2 14h2" />
<path d="M20 14h2" />
<path d="M22 22 2 2" />
<path d="M8 8H6a2 2 0 0 0-2 2v8a2 2 0 0 0 2 2h12a2 2 0 0 0 1.414-.586" />
<path d="M9 13v2" />
<path d="M9.67 4H12v2.33" />
</svg>

Before

Width:  |  Height:  |  Size: 502 B

View File

@ -1,17 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-brain-cog-icon lucide-brain-cog">
<path d="m10.852 14.772-.383.923" />
<path d="m10.852 9.228-.383-.923" />
<path d="m13.148 14.772.382.924" />
<path d="m13.531 8.305-.383.923" />
<path d="m14.772 10.852.923-.383" />
<path d="m14.772 13.148.923.383" />
<path d="M17.598 6.5A3 3 0 1 0 12 5a3 3 0 0 0-5.63-1.446 3 3 0 0 0-.368 1.571 4 4 0 0 0-2.525 5.771" />
<path d="M17.998 5.125a4 4 0 0 1 2.525 5.771" />
<path d="M19.505 10.294a4 4 0 0 1-1.5 7.706" />
<path d="M4.032 17.483A4 4 0 0 0 11.464 20c.18-.311.892-.311 1.072 0a4 4 0 0 0 7.432-2.516" />
<path d="M4.5 10.291A4 4 0 0 0 6 18" />
<path d="M6.002 5.125a3 3 0 0 0 .4 1.375" />
<path d="m9.228 10.852-.923-.383" />
<path d="m9.228 13.148-.923.383" />
<circle cx="12" cy="12" r="3" />
</svg>

Before

Width:  |  Height:  |  Size: 1009 B

View File

@ -1,3 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" className="h-5 w-5" viewBox="0 0 20 20" fill="currentColor">
<path fillRule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 111.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clipRule="evenodd" />
</svg>

Before

Width:  |  Height:  |  Size: 278 B

View File

@ -1,4 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg" stroke="currentColor">
<path d="m15 18-6-6 6-6" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

Before

Width:  |  Height:  |  Size: 257 B

View File

@ -0,0 +1,44 @@
<svg width="92" height="92" viewBox="0 0 92 92" fill="none" xmlns="http://www.w3.org/2000/svg">
<circle cx="46" cy="46" r="40" fill="white" stroke="#FFB645" stroke-width="11" />
<mask id="mask0_33_184" style="maskType: alpha" maskUnits="userSpaceOnUse" x="6" y="6" width="80" height="80">
<circle cx="46" cy="46" r="40" fill="white" />
</mask>
<g mask="url(#mask0_33_184)">
<path d="M-10.4027 27.6923C14.6652 -15.7265 77.3348 -15.7265 102.403 27.6923L119.501 57.3077C144.569 100.727 113.234 155 63.0984 155H28.9016C-21.2342 155 -52.569 100.726 -27.5011 57.3077L-10.4027 27.6923Z" fill="#FFD18A" />
<rect x="42.1543" y="24.8718" width="2.5641" height="3.58974" rx="1.28205" transform="rotate(-180 42.1543 24.8718)" fill="#4C320C" />
<rect x="51.3848" y="24.8718" width="2.5641" height="3.58974" rx="1.28205" transform="rotate(-180 51.3848 24.8718)" fill="#4C320C" />
<path d="M9.24226 51.13C26.9712 25.3565 65.0303 25.3565 82.7593 51.13L102.951 80.4839C123.313 110.085 102.121 150.385 66.1926 150.385H25.8088C-10.1197 150.385 -31.3117 110.085 -10.9496 80.4839L9.24226 51.13Z" fill="#FFF8DE" />
<g filter="url(#filter0_i_33_184)">
<ellipse cx="81.6411" cy="46.6667" rx="42.0513" ry="30" fill="#FFF8DE" />
</g>
<g filter="url(#filter1_i_33_184)">
<ellipse cx="10.1025" cy="46.6667" rx="41.7949" ry="30" fill="#FFF8DE" />
</g>
<ellipse cx="45.7434" cy="31.795" rx="3.07692" ry="2.30769" transform="rotate(180 45.7434 31.795)" fill="#FFB8B9" />
<ellipse cx="7.32634" cy="2.92265" rx="7.32634" ry="2.92265" transform="matrix(0.912659 0.408721 0.408721 -0.912659 61.4287 82.0015)" fill="#FFD38D" />
<ellipse cx="22.3426" cy="82.3285" rx="7.32634" ry="2.92265" transform="rotate(155.875 22.3426 82.3285)" fill="#FFD38D" />
<path d="M45.2994 34.359C45.4968 34.0171 45.9903 34.0171 46.1877 34.359L46.6318 35.1282C46.8292 35.4701 46.5824 35.8975 46.1877 35.8975H45.2994C44.9047 35.8975 44.6579 35.4701 44.8553 35.1282L45.2994 34.359Z" fill="#4C320C" />
</g>
<defs>
<filter id="filter0_i_33_184" x="34.9745" y="16.6667" width="88.7179" height="61.5385" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
<feFlood flood-opacity="0" result="BackgroundImageFix" />
<feBlend mode="normal" in="SourceGraphic" in2="BackgroundImageFix" result="shape" />
<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha" />
<feOffset dx="-4.61538" dy="1.53846" />
<feGaussianBlur stdDeviation="4.23077" />
<feComposite in2="hardAlpha" operator="arithmetic" k2="-1" k3="1" />
<feColorMatrix type="matrix" values="0 0 0 0 1 0 0 0 0 0.713726 0 0 0 0 0.270588 0 0 0 1 0" />
<feBlend mode="normal" in2="shape" result="effect1_innerShadow_33_184" />
</filter>
<filter id="filter1_i_33_184" x="-31.6924" y="16.6667" width="88.718" height="60" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
<feFlood flood-opacity="0" result="BackgroundImageFix" />
<feBlend mode="normal" in="SourceGraphic" in2="BackgroundImageFix" result="shape" />
<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha" />
<feOffset dx="5.1282" />
<feGaussianBlur stdDeviation="2.82051" />
<feComposite in2="hardAlpha" operator="arithmetic" k2="-1" k3="1" />
<feColorMatrix type="matrix" values="0 0 0 0 1 0 0 0 0 0.713974 0 0 0 0 0.272498 0 0 0 1 0" />
<feBlend mode="normal" in2="shape" result="effect1_innerShadow_33_184" />
</filter>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 3.7 KiB

View File

@ -0,0 +1,4 @@
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M1 10C1 14.9706 5.02944 19 10 19C14.9706 19 19 14.9706 19 10C19 5.02944 14.9706 1 10 1C5.02944 1 1 5.02944 1 10Z" fill="white" />
<path d="M15.2166 17.3323C13.9349 15.9008 12.0727 15 10 15C7.92734 15 6.06492 15.9008 4.7832 17.3323M10 19C5.02944 19 1 14.9706 1 10C1 5.02944 5.02944 1 10 1C14.9706 1 19 5.02944 19 10C19 14.9706 14.9706 19 10 19ZM10 12C8.34315 12 7 10.6569 7 9C7 7.34315 8.34315 6 10 6C11.6569 6 13 7.34315 13 9C13 10.6569 11.6569 12 10 12Z" stroke="#4C320C" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
</svg>

After

Width:  |  Height:  |  Size: 659 B

View File

@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-cloud-upload-icon lucide-cloud-upload"><path d="M12 13v8"/><path d="M4 14.899A7 7 0 1 1 15.71 8h1.79a4.5 4.5 0 0 1 2.5 8.242"/><path d="m8 17 4-4 4 4"/></svg>

Before

Width:  |  Height:  |  Size: 360 B

View File

@ -1,5 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-user-circle-icon lucide-user-circle">
<path d="M7.9 20A9 9 0 1 0 4 16.1L2 22Z" />
<path d="m10 15-3-3 3-3" />
<path d="M7 12h7a2 2 0 0 1 2 2v1" />
</svg>

Before

Width:  |  Height:  |  Size: 368 B

View File

@ -1,9 +0,0 @@
<svg viewBox="0 0 63 55" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect x="38.7098" y="17.6353" width="8.3012" height="37.3927" rx="3" transform="rotate(58.8959 38.7098 17.6353)" stroke="#FFBB00" stroke-width="2" />
<line x1="30.0343" y1="21.7041" x2="35.3559" y2="30.5243" stroke="#FFBB00" stroke-width="2" />
<path d="M41.8246 30.6299L45.5694 36.8367" stroke="#FFBB00" stroke-width="2" stroke-linecap="round" />
<path d="M29.4093 10.0507L33.1541 16.2575" stroke="#FFBB00" stroke-width="2" stroke-linecap="round" />
<path d="M40.8665 6.25525L40.0611 14.0031" stroke="#FFBB00" stroke-width="2" stroke-linecap="round" />
<path d="M53.9637 27.9646L46.813 24.947" stroke="#FFBB00" stroke-width="2" stroke-linecap="round" />
<path d="M54.1488 13.3939L46.3335 18.1092" stroke="#FFBB00" stroke-width="2" stroke-linecap="round" />
</svg>

Before

Width:  |  Height:  |  Size: 866 B

View File

@ -1,3 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
</svg>

Before

Width:  |  Height:  |  Size: 325 B

42
assets/icons/svg/done.svg Normal file
View File

@ -0,0 +1,42 @@
<svg width="402" height="658" viewBox="0 0 402 658" fill="none" xmlns="http://www.w3.org/2000/svg" className='!w-full !h-full'>
<path d="M188.942 376.831C189.985 369.965 206.061 373.848 213.969 376.647L197.429 388.107C193.075 388.588 187.898 383.698 188.942 376.831Z" fill="#FFDBA3" />
<path d="M192.525 377.316C192.829 371.596 203.633 375.761 208.997 378.558L200.667 383.979C197.827 384.142 192.221 383.036 192.525 377.316Z" fill="#AC7E35" />
<path d="M290.119 318.416C283.651 315.886 278.975 331.75 277.446 339.999L295.64 331.404C298.234 327.874 296.588 320.946 290.119 318.416Z" fill="#FFDBA3" />
<path d="M288.748 321.762C283.643 319.165 281.847 330.604 281.588 336.647L290.448 332.145C292.008 329.766 293.853 324.359 288.748 321.762Z" fill="#AC7E35" />
<path d="M153.808 500.814C153.808 367.119 298.537 283.56 414.32 350.407L493.295 396.003C609.078 462.851 609.078 629.97 493.295 696.817L414.32 742.413C298.537 809.261 153.808 725.701 153.808 592.006L153.808 500.814Z" fill="#FFD18A" />
<rect x="271.421" y="424.225" width="6.83761" height="9.57265" rx="3.4188" transform="rotate(150 271.421 424.225)" fill="#4C320C" />
<rect x="292.738" y="411.917" width="6.83761" height="9.57265" rx="3.4188" transform="rotate(150 292.738 411.917)" fill="#4C320C" />
<path d="M230.424 528.749C237.002 445.589 324.896 394.844 400.204 430.726L485.973 471.594C572.466 512.806 577.258 634.129 494.284 682.034L401.022 735.879C318.049 783.784 215.375 718.973 222.931 623.462L230.424 528.749Z" fill="#FFF8DE" />
<g filter="url(#filter0_i_35_262)">
<ellipse cx="391.673" cy="421.909" rx="112.137" ry="80" transform="rotate(-30 391.673 421.909)" fill="#FFF8DE" />
</g>
<g filter="url(#filter1_i_35_262)">
<ellipse cx="226.462" cy="517.293" rx="111.453" ry="80" transform="rotate(-30 226.462 517.293)" fill="#FFF8DE" />
</g>
<ellipse cx="288.942" cy="435.427" rx="8.20513" ry="6.15385" transform="rotate(150 288.942 435.427)" fill="#FFB8B9" />
<ellipse cx="19.5369" cy="7.79375" rx="19.5369" ry="7.79375" transform="matrix(0.994747 -0.102367 -0.102367 -0.994747 385.179 534.461)" fill="#FFD38D" />
<ellipse cx="302.278" cy="583.331" rx="19.5369" ry="7.79375" transform="rotate(125.875 302.278 583.331)" fill="#FFD38D" />
<path d="M291.335 441.941C291.335 440.888 292.475 440.23 293.386 440.756L295.438 441.941C296.349 442.467 296.349 443.783 295.438 444.309L293.386 445.494C292.475 446.02 291.335 445.362 291.335 444.309L291.335 441.941Z" fill="#4C320C" />
<defs>
<filter id="filter0_i_35_262" x="274.309" y="332.779" width="222.419" height="182.363" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
<feFlood flood-opacity="0" result="BackgroundImageFix" />
<feBlend mode="normal" in="SourceGraphic" in2="BackgroundImageFix" result="shape" />
<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha" />
<feOffset dx="-12.3077" dy="4.10256" />
<feGaussianBlur stdDeviation="11.2821" />
<feComposite in2="hardAlpha" operator="arithmetic" k2="-1" k3="1" />
<feColorMatrix type="matrix" values="0 0 0 0 1 0 0 0 0 0.713726 0 0 0 0 0.270588 0 0 0 1 0" />
<feBlend mode="normal" in2="shape" result="effect1_innerShadow_35_262" />
</filter>
<filter id="filter1_i_35_262" x="121.953" y="428.378" width="222.692" height="177.831" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
<feFlood flood-opacity="0" result="BackgroundImageFix" />
<feBlend mode="normal" in="SourceGraphic" in2="BackgroundImageFix" result="shape" />
<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha" />
<feOffset dx="13.6752" />
<feGaussianBlur stdDeviation="7.52137" />
<feComposite in2="hardAlpha" operator="arithmetic" k2="-1" k3="1" />
<feColorMatrix type="matrix" values="0 0 0 0 1 0 0 0 0 0.713974 0 0 0 0 0.272498 0 0 0 1 0" />
<feBlend mode="normal" in2="shape" result="effect1_innerShadow_35_262" />
</filter>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 4.1 KiB

View File

@ -1,5 +0,0 @@
<svg width="16" height="16" viewBox="0 0 12 13" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M11.3694 8.14966V10.5459C11.3694 10.8636 11.2474 11.1684 11.0302 11.393C10.8131 11.6177 10.5185 11.744 10.2114 11.744H2.10528C1.79816 11.744 1.50361 11.6177 1.28644 11.393C1.06927 11.1684 0.947266 10.8636 0.947266 10.5459V8.14966" stroke="currentColor" stroke-width="1.15531" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M3.26416 5.1543L6.1592 8.14955L9.05423 5.1543" stroke="currentColor" stroke-width="1.15531" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M6.16064 8.14953V0.960938" stroke="currentColor" stroke-width="1.15531" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

Before

Width:  |  Height:  |  Size: 723 B

View File

@ -0,0 +1,4 @@
<svg width="18" height="18" viewBox="0 0 18 18" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M1.5 9C1.5 13.1421 4.85786 16.5 9 16.5C13.1421 16.5 16.5 13.1421 16.5 9C16.5 4.85786 13.1421 1.5 9 1.5C4.85786 1.5 1.5 4.85786 1.5 9Z" fill="#E2793F" />
<path d="M9 6.04183V9.37516M9 16.5C4.85786 16.5 1.5 13.1421 1.5 9C1.5 4.85786 4.85786 1.5 9 1.5C13.1421 1.5 16.5 4.85786 16.5 9C16.5 13.1421 13.1421 16.5 9 16.5ZM9.0415 11.8752V11.9585L8.9585 11.9582V11.8752H9.0415Z" stroke="white" stroke-width="1.66667" stroke-linecap="round" stroke-linejoin="round" />
</svg>

After

Width:  |  Height:  |  Size: 577 B

View File

@ -1,5 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg width="16" height="16" viewBox="0 0 15 15" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M0.625 7.5C0.625 7.5 3.125 2.5 7.5 2.5C11.875 2.5 14.375 7.5 14.375 7.5C14.375 7.5 11.875 12.5 7.5 12.5C3.125 12.5 0.625 7.5 0.625 7.5Z" stroke="currentColor" stroke-width="1.16" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M7.5 9.375C8.53553 9.375 9.375 8.53553 9.375 7.5C9.375 6.46447 8.53553 5.625 7.5 5.625C6.46447 5.625 5.625 6.46447 5.625 7.5C5.625 8.53553 6.46447 9.375 7.5 9.375Z" stroke="currentColor" stroke-width="1.16" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

Before

Width:  |  Height:  |  Size: 648 B

View File

@ -1,3 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" className="h-5 w-5 text-gray-500" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M15 10l4.553-2.276A1 1 0 0121 8.618v6.764a1 1 0 01-1.447.894L15 14M5 18h8a2 2 0 002-2V8a2 2 0 00-2-2H5a2 2 0 00-2 2v8a2 2 0 002 2z" />
</svg>

Before

Width:  |  Height:  |  Size: 345 B

View File

@ -1,5 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-image-icon lucide-image">
<rect width="18" height="18" x="3" y="3" rx="2" ry="2" />
<circle cx="9" cy="9" r="2" />
<path d="m21 15-3.086-3.086a2 2 0 0 0-2.828 0L6 21" />
</svg>

Before

Width:  |  Height:  |  Size: 391 B

View File

@ -0,0 +1,4 @@
<svg width="79" height="8" viewBox="0 0 79 8" fill="none" xmlns="http://www.w3.org/2000/svg" style="transform: scale(0.8)">
<ellipse cx="9.76846" cy="3.89687" rx="9.76846" ry="3.89687" transform="matrix(1 0 0 -1 59.3115 7.79376)" fill="#FFD38D" />
<ellipse cx="9.76865" cy="3.89689" rx="9.76846" ry="3.89687" transform="rotate(-180 9.76865 3.89689)" fill="#FFD38D" />
</svg>

After

Width:  |  Height:  |  Size: 382 B

View File

@ -1,7 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-image-plus-icon lucide-image-plus">
<path d="M16 5h6" />
<path d="M19 2v6" />
<path d="M21 11.5V19a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h7.5" />
<path d="m21 15-3.086-3.086a2 2 0 0 0-2.828 0L6 21" />
<circle cx="9" cy="9" r="2" />
</svg>

Before

Width:  |  Height:  |  Size: 468 B

View File

@ -0,0 +1,44 @@
<svg width="300" height="300" viewBox="0 0 300 300" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M128.254 64.0152C129.88 61.9809 134.373 66.2562 136.417 68.6482L128.896 69.2685C127.392 68.6084 126.628 66.0496 128.254 64.0152Z" fill="#FFDBA3" />
<path d="M129.327 64.8447C130.498 63.0441 133.226 66.4222 134.443 68.3363L130.721 68.5351C129.769 68.0552 128.155 66.6452 129.327 64.8447Z" fill="#AC7E35" />
<path d="M172.065 64.0152C170.439 61.9809 165.946 66.2562 163.903 68.6482L171.423 69.2685C172.927 68.6084 173.692 66.0496 172.065 64.0152Z" fill="#FFDBA3" />
<path d="M170.993 64.8447C169.822 63.0441 167.094 66.4222 165.876 68.3363L169.598 68.5351C170.551 68.0552 172.164 66.6452 170.993 64.8447Z" fill="#AC7E35" />
<ellipse cx="110.257" cy="221.795" rx="7.69231" ry="3.33333" fill="#AC7E35" />
<ellipse cx="193.333" cy="220.256" rx="7.69231" ry="3.33333" fill="#AC7E35" />
<path d="M93.5973 97.6923C118.665 54.2735 181.335 54.2735 206.403 97.6923L223.501 127.308C248.569 170.727 217.234 225 167.098 225H132.902C82.7658 225 51.431 170.726 76.4989 127.308L93.5973 97.6923Z" fill="#FFD18A" />
<rect x="146.154" y="94.8718" width="2.5641" height="3.58974" rx="1.28205" transform="rotate(-180 146.154 94.8718)" fill="#4C320C" />
<rect x="155.385" y="94.8718" width="2.5641" height="3.58974" rx="1.28205" transform="rotate(-180 155.385 94.8718)" fill="#4C320C" />
<path d="M113.241 121.13C130.97 95.3566 169.029 95.3565 186.758 121.13L206.95 150.484C227.312 180.085 206.12 220.385 170.192 220.385H129.808C93.8793 220.385 72.6873 180.085 93.0495 150.484L113.241 121.13Z" fill="#FFF8DE" />
<g filter="url(#filter0_i_33_78)">
<ellipse cx="185.641" cy="116.667" rx="42.0513" ry="30" fill="#FFF8DE" />
</g>
<g filter="url(#filter1_i_33_78)">
<ellipse cx="114.102" cy="116.667" rx="41.7949" ry="30" fill="#FFF8DE" />
</g>
<ellipse cx="149.743" cy="101.795" rx="3.07692" ry="2.30769" transform="rotate(180 149.743 101.795)" fill="#FFB8B9" />
<ellipse cx="7.32634" cy="2.92265" rx="7.32634" ry="2.92265" transform="matrix(0.912659 0.408721 0.408721 -0.912659 162.429 152.001)" fill="#FFD38D" />
<ellipse cx="126.343" cy="152.329" rx="7.32634" ry="2.92265" transform="rotate(155.875 126.343 152.329)" fill="#FFD38D" />
<path d="M149.299 104.359C149.497 104.017 149.99 104.017 150.188 104.359L150.632 105.128C150.829 105.47 150.582 105.897 150.188 105.897H149.299C148.905 105.897 148.658 105.47 148.855 105.128L149.299 104.359Z" fill="#4C320C" />
<defs>
<filter id="filter0_i_33_78" x="138.974" y="86.6667" width="88.7179" height="61.5385" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
<feFlood flood-opacity="0" result="BackgroundImageFix" />
<feBlend mode="normal" in="SourceGraphic" in2="BackgroundImageFix" result="shape" />
<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha" />
<feOffset dx="-4.61538" dy="1.53846" />
<feGaussianBlur stdDeviation="4.23077" />
<feComposite in2="hardAlpha" operator="arithmetic" k2="-1" k3="1" />
<feColorMatrix type="matrix" values="0 0 0 0 1 0 0 0 0 0.713726 0 0 0 0 0.270588 0 0 0 1 0" />
<feBlend mode="normal" in2="shape" result="effect1_innerShadow_33_78" />
</filter>
<filter id="filter1_i_33_78" x="72.3076" y="86.6667" width="88.718" height="60" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
<feFlood flood-opacity="0" result="BackgroundImageFix" />
<feBlend mode="normal" in="SourceGraphic" in2="BackgroundImageFix" result="shape" />
<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha" />
<feOffset dx="5.1282" />
<feGaussianBlur stdDeviation="2.82051" />
<feComposite in2="hardAlpha" operator="arithmetic" k2="-1" k3="1" />
<feColorMatrix type="matrix" values="0 0 0 0 1 0 0 0 0 0.713974 0 0 0 0 0.272498 0 0 0 1 0" />
<feBlend mode="normal" in2="shape" result="effect1_innerShadow_33_78" />
</filter>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 4.2 KiB

View File

@ -0,0 +1,42 @@
<svg width="400" height="391" viewBox="0 0 400 391" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M171.005 76.3536C173.174 73.6412 179.164 79.3416 181.889 82.5309L171.862 83.3579C169.856 82.4779 168.837 79.0661 171.005 76.3536Z" fill="#FFDBA3"/>
<path d="M172.437 77.4596C173.998 75.0588 177.635 79.5629 179.258 82.1151L174.296 82.3802C173.026 81.7403 170.875 79.8603 172.437 77.4596Z" fill="#AC7E35"/>
<path d="M229.421 76.3536C227.253 73.6412 221.262 79.3416 218.538 82.5309L228.565 83.3579C230.571 82.4779 231.59 79.0661 229.421 76.3536Z" fill="#FFDBA3"/>
<path d="M227.99 77.4596C226.429 75.0588 222.792 79.5629 221.168 82.1151L226.131 82.3802C227.401 81.7403 229.552 79.8603 227.99 77.4596Z" fill="#AC7E35"/>
<ellipse cx="147.008" cy="286.727" rx="10.2564" ry="4.44444" fill="#AC7E35"/>
<ellipse cx="257.778" cy="284.675" rx="10.2564" ry="4.44444" fill="#AC7E35"/>
<path d="M124.796 121.256C158.22 63.3647 241.78 63.3647 275.204 121.256L298.001 160.744C331.425 218.635 289.646 291 222.798 291H177.202C110.354 291 68.5747 218.635 101.998 160.744L124.796 121.256Z" fill="#FFD18A"/>
<rect x="194.872" y="117.496" width="3.4188" height="4.78632" rx="1.7094" transform="rotate(-180 194.872 117.496)" fill="#4C320C"/>
<rect x="207.18" y="117.496" width="3.4188" height="4.78632" rx="1.7094" transform="rotate(-180 207.18 117.496)" fill="#4C320C"/>
<path d="M150.989 152.507C174.628 118.142 225.373 118.142 249.012 152.507L275.934 191.645C303.084 231.114 274.828 284.846 226.923 284.846H173.078C125.173 284.846 96.9171 231.114 124.067 191.645L150.989 152.507Z" fill="#FFF8DE"/>
<g filter="url(#filter0_i_95_1557)">
<ellipse cx="247.521" cy="146.556" rx="56.0684" ry="40" fill="#FFF8DE"/>
</g>
<g filter="url(#filter1_i_95_1557)">
<ellipse cx="152.137" cy="146.556" rx="55.7265" ry="40" fill="#FFF8DE"/>
</g>
<ellipse cx="199.658" cy="126.726" rx="4.10256" ry="3.07692" transform="rotate(180 199.658 126.726)" fill="#FFB8B9"/>
<path d="M199.066 130.145C199.329 129.689 199.987 129.689 200.25 130.145L200.842 131.171C201.105 131.627 200.776 132.197 200.25 132.197H199.066C198.539 132.197 198.21 131.627 198.473 131.171L199.066 130.145Z" fill="#4C320C"/>
<defs>
<filter id="filter0_i_95_1557" x="185.299" y="106.556" width="118.291" height="82.0513" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
<feFlood flood-opacity="0" result="BackgroundImageFix"/>
<feBlend mode="normal" in="SourceGraphic" in2="BackgroundImageFix" result="shape"/>
<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/>
<feOffset dx="-6.15385" dy="2.05128"/>
<feGaussianBlur stdDeviation="5.64103"/>
<feComposite in2="hardAlpha" operator="arithmetic" k2="-1" k3="1"/>
<feColorMatrix type="matrix" values="0 0 0 0 1 0 0 0 0 0.713726 0 0 0 0 0.270588 0 0 0 1 0"/>
<feBlend mode="normal" in2="shape" result="effect1_innerShadow_95_1557"/>
</filter>
<filter id="filter1_i_95_1557" x="96.4102" y="106.556" width="118.291" height="80" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
<feFlood flood-opacity="0" result="BackgroundImageFix"/>
<feBlend mode="normal" in="SourceGraphic" in2="BackgroundImageFix" result="shape"/>
<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/>
<feOffset dx="6.83761"/>
<feGaussianBlur stdDeviation="3.76068"/>
<feComposite in2="hardAlpha" operator="arithmetic" k2="-1" k3="1"/>
<feColorMatrix type="matrix" values="0 0 0 0 1 0 0 0 0 0.713974 0 0 0 0 0.272498 0 0 0 1 0"/>
<feBlend mode="normal" in2="shape" result="effect1_innerShadow_95_1557"/>
</filter>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 3.6 KiB

View File

@ -0,0 +1,42 @@
<svg width="400" height="400" viewBox="0 0 400 400" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M171.005 85.3536C173.174 82.6412 179.164 88.3416 181.889 91.5309L171.862 92.3579C169.856 91.4779 168.837 88.0661 171.005 85.3536Z" fill="#FFDBA3" />
<path d="M172.437 86.4596C173.998 84.0588 177.635 88.5629 179.258 91.1151L174.296 91.3802C173.026 90.7403 170.875 88.8603 172.437 86.4596Z" fill="#AC7E35" />
<path d="M229.421 85.3536C227.253 82.6412 221.262 88.3416 218.538 91.5309L228.565 92.3579C230.571 91.4779 231.59 88.0661 229.421 85.3536Z" fill="#FFDBA3" />
<path d="M227.99 86.4596C226.429 84.0588 222.792 88.5629 221.168 91.1151L226.131 91.3802C227.401 90.7403 229.552 88.8603 227.99 86.4596Z" fill="#AC7E35" />
<ellipse cx="147.008" cy="295.727" rx="10.2564" ry="4.44444" fill="#AC7E35" />
<ellipse cx="257.778" cy="293.675" rx="10.2564" ry="4.44444" fill="#AC7E35" />
<path d="M124.796 130.256C158.22 72.3647 241.78 72.3647 275.204 130.256L298.001 169.744C331.425 227.635 289.646 300 222.798 300H177.202C110.354 300 68.5747 227.635 101.998 169.744L124.796 130.256Z" fill="#FFD18A" />
<path d="M150.989 161.507C174.628 127.142 225.373 127.142 249.012 161.507L275.934 200.645C303.084 240.114 274.828 293.846 226.923 293.846H173.078C125.173 293.846 96.9171 240.114 124.067 200.645L150.989 161.507Z" fill="#FFF8DE" />
<g filter="url(#filter0_i_28_495)">
<ellipse cx="247.521" cy="155.556" rx="56.0684" ry="40" fill="#FFF8DE" />
</g>
<rect x="204" y="124.667" width="1.66681" height="4.78632" rx="0.833407" transform="rotate(-90 204 124.667)" fill="#4C320C" />
<rect x="191" y="124.667" width="1.66681" height="4.78632" rx="0.833407" transform="rotate(-90 191 124.667)" fill="#4C320C" />
<g filter="url(#filter1_i_28_495)">
<ellipse cx="152.137" cy="155.556" rx="55.7265" ry="40" fill="#FFF8DE" />
</g>
<ellipse cx="199.658" cy="135.726" rx="4.10256" ry="3.07692" transform="rotate(180 199.658 135.726)" fill="#FFB8B9" />
<path d="M199.066 139.145C199.329 138.689 199.987 138.689 200.25 139.145L200.842 140.171C201.105 140.627 200.776 141.197 200.25 141.197H199.066C198.539 141.197 198.21 140.627 198.473 140.171L199.066 139.145Z" fill="#4C320C" />
<defs>
<filter id="filter0_i_28_495" x="185.299" y="115.556" width="118.291" height="82.0513" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
<feFlood flood-opacity="0" result="BackgroundImageFix" />
<feBlend mode="normal" in="SourceGraphic" in2="BackgroundImageFix" result="shape" />
<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha" />
<feOffset dx="-6.15385" dy="2.05128" />
<feGaussianBlur stdDeviation="5.64103" />
<feComposite in2="hardAlpha" operator="arithmetic" k2="-1" k3="1" />
<feColorMatrix type="matrix" values="0 0 0 0 1 0 0 0 0 0.713726 0 0 0 0 0.270588 0 0 0 1 0" />
<feBlend mode="normal" in2="shape" result="effect1_innerShadow_28_495" />
</filter>
<filter id="filter1_i_28_495" x="96.4102" y="115.556" width="118.291" height="80" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
<feFlood flood-opacity="0" result="BackgroundImageFix" />
<feBlend mode="normal" in="SourceGraphic" in2="BackgroundImageFix" result="shape" />
<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha" />
<feOffset dx="6.83761" />
<feGaussianBlur stdDeviation="3.76068" />
<feComposite in2="hardAlpha" operator="arithmetic" k2="-1" k3="1" />
<feColorMatrix type="matrix" values="0 0 0 0 1 0 0 0 0 0.713974 0 0 0 0 0.272498 0 0 0 1 0" />
<feBlend mode="normal" in2="shape" result="effect1_innerShadow_28_495" />
</filter>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 4.8 KiB

View File

@ -1,5 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4" />
<polyline points="16 17 21 12 16 7" />
<line x1="21" y1="12" x2="9" y2="12" />
</svg>

Before

Width:  |  Height:  |  Size: 333 B

View File

@ -0,0 +1,17 @@
<svg width="141" height="90" viewBox="0 0 141 90" fill="none" xmlns="http://www.w3.org/2000/svg">
<g filter="url(#filter0_d_27_1126)">
<path d="M78.0607 8.049C55.6607 -8.751 41.0607 8.38232 36.5607 19.049C11.0607 8.049 -6.43929 33.0493 11.0607 49.5491C-1.43929 61.5491 25.0607 81.0491 36.5607 65.0491C41.5607 91.5491 67.0607 84.5491 76.5607 68.5491C80.0607 81.5491 118.061 82.5491 118.061 62.0491C142.061 70.0491 143.561 22.0491 118.061 22.0491C121.561 -1.4509 93.0607 -1.95093 78.0607 8.049Z" fill="white" />
</g>
<defs>
<filter id="filter0_d_27_1126" x="0.3" y="0.3" width="140.028" height="89.1634" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
<feFlood flood-opacity="0" result="BackgroundImageFix" />
<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha" />
<feOffset dy="3" />
<feGaussianBlur stdDeviation="1.85" />
<feComposite in2="hardAlpha" operator="out" />
<feColorMatrix type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.25 0" />
<feBlend mode="normal" in2="BackgroundImageFix" result="effect1_dropShadow_27_1126" />
<feBlend mode="normal" in="SourceGraphic" in2="effect1_dropShadow_27_1126" result="shape" />
</filter>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

View File

@ -0,0 +1,43 @@
<svg width="350" height="350" viewBox="0 0 350 350" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M149.63 74.6845C151.528 72.3111 156.769 77.2989 159.153 80.0896L150.38 80.8132C148.624 80.0432 147.733 77.0579 149.63 74.6845Z" fill="#FFDBA3" />
<path d="M150.882 75.6521C152.248 73.5515 155.431 77.4925 156.851 79.7257L152.509 79.9577C151.398 79.3978 149.516 77.7528 150.882 75.6521Z" fill="#AC7E35" />
<path d="M200.744 74.6845C198.846 72.3111 193.605 77.2989 191.221 80.0896L199.994 80.8132C201.75 80.0432 202.641 77.0579 200.744 74.6845Z" fill="#FFDBA3" />
<path d="M199.491 75.6521C198.125 73.5515 194.942 77.4925 193.522 79.7257L197.864 79.9577C198.975 79.3978 200.857 77.7528 199.491 75.6521Z" fill="#AC7E35" />
<ellipse cx="128.633" cy="258.761" rx="8.97436" ry="3.88889" fill="#AC7E35" />
<ellipse cx="225.555" cy="256.966" rx="8.97436" ry="3.88889" fill="#AC7E35" />
<path d="M109.197 113.974C138.443 63.319 211.557 63.3191 240.803 113.974L260.751 148.526C289.997 199.181 253.44 262.5 194.948 262.5H155.052C96.5601 262.5 60.0029 199.181 89.2487 148.526L109.197 113.974Z" fill="#FFD18A" />
<rect x="170.513" y="110.684" width="2.99145" height="4.18803" rx="1.49573" transform="rotate(-180 170.513 110.684)" fill="#4C320C" />
<rect x="181.282" y="110.684" width="2.99145" height="4.18803" rx="1.49573" transform="rotate(-180 181.282 110.684)" fill="#4C320C" />
<path d="M132.116 141.318C152.799 111.249 197.202 111.249 217.886 141.318L241.443 175.565C265.198 210.1 240.474 257.115 198.558 257.115H151.443C109.527 257.115 84.8027 210.1 108.559 175.565L132.116 141.318Z" fill="#FFF8DE" />
<g filter="url(#filter0_i_68_1022)">
<ellipse cx="216.581" cy="136.111" rx="49.0598" ry="35" fill="#FFF8DE" />
</g>
<g filter="url(#filter1_i_68_1022)">
<ellipse cx="133.12" cy="136.111" rx="48.7607" ry="35" fill="#FFF8DE" />
</g>
<ellipse cx="174.701" cy="118.761" rx="3.58974" ry="2.69231" transform="rotate(180 174.701 118.761)" fill="#FFB8B9" />
<ellipse cx="8.5474" cy="3.40976" rx="8.5474" ry="3.40976" transform="matrix(0.912659 0.408721 0.408721 -0.912659 189.5 177.335)" fill="#FFD38D" />
<ellipse cx="147.399" cy="177.717" rx="8.5474" ry="3.40976" transform="rotate(155.875 147.399 177.717)" fill="#FFD38D" />
<defs>
<filter id="filter0_i_68_1022" x="162.137" y="101.111" width="103.505" height="71.7949" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
<feFlood flood-opacity="0" result="BackgroundImageFix" />
<feBlend mode="normal" in="SourceGraphic" in2="BackgroundImageFix" result="shape" />
<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha" />
<feOffset dx="-5.38462" dy="1.79487" />
<feGaussianBlur stdDeviation="4.9359" />
<feComposite in2="hardAlpha" operator="arithmetic" k2="-1" k3="1" />
<feColorMatrix type="matrix" values="0 0 0 0 1 0 0 0 0 0.713726 0 0 0 0 0.270588 0 0 0 1 0" />
<feBlend mode="normal" in2="shape" result="effect1_innerShadow_68_1022" />
</filter>
<filter id="filter1_i_68_1022" x="84.3594" y="101.111" width="103.504" height="70" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
<feFlood flood-opacity="0" result="BackgroundImageFix" />
<feBlend mode="normal" in="SourceGraphic" in2="BackgroundImageFix" result="shape" />
<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha" />
<feOffset dx="5.98291" />
<feGaussianBlur stdDeviation="3.2906" />
<feComposite in2="hardAlpha" operator="arithmetic" k2="-1" k3="1" />
<feColorMatrix type="matrix" values="0 0 0 0 1 0 0 0 0 0.713974 0 0 0 0 0.272498 0 0 0 1 0" />
<feBlend mode="normal" in2="shape" result="effect1_innerShadow_68_1022" />
</filter>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 3.9 KiB

View File

@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-computer-icon lucide-computer"><rect width="14" height="8" x="5" y="2" rx="2"/><rect width="20" height="8" x="2" y="14" rx="2"/><path d="M6 18h2"/><path d="M12 18h6"/></svg>

Before

Width:  |  Height:  |  Size: 375 B

View File

@ -1,5 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-vibrate-icon lucide-vibrate">
<path d="m2 8 2 2-2 2 2 2-2 2" />
<path d="m22 8-2 2 2 2-2 2 2 2" />
<rect width="8" height="14" x="8" y="5" rx="1" />
</svg>

Before

Width:  |  Height:  |  Size: 370 B

View File

@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" className="lucide lucide-image-icon lucide-image"><rect width="18" height="18" x="3" y="3" rx="2" ry="2" /><circle cx="9" cy="9" r="2" /><path d="m21 15-3.086-3.086a2 2 0 0 0-2.828 0L6 21" /></svg>

Before

Width:  |  Height:  |  Size: 378 B

View File

@ -1,3 +0,0 @@
<svg width="22" height="22" viewBox="0 0 22 22">
<polygon points="6,4 18,11 6,18" fill="#fff" />
</svg>

Before

Width:  |  Height:  |  Size: 108 B

View File

@ -1,3 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M12 6v6m0 0v6m0-6h6m-6 0H6" />
</svg>

Before

Width:  |  Height:  |  Size: 233 B

View File

@ -1,5 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-share-2-icon lucide-share-2">
<path d="M13.5 19.5 12 21l-7-7c-1.5-1.45-3-3.2-3-5.5A5.5 5.5 0 0 1 7.5 3c1.76 0 3 .5 4.5 2 1.5-1.5 2.74-2 4.5-2a5.5 5.5 0 0 1 5.402 6.5" />
<path d="M15 15h6" />
<path d="M18 12v6" />
</svg>

Before

Width:  |  Height:  |  Size: 435 B

View File

@ -1,4 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-send-icon lucide-send">
<path d="M14.536 21.686a.5.5 0 0 0 .937-.024l6.5-19a.496.496 0 0 0-.635-.635l-19 6.5a.5.5 0 0 0-.024.937l7.93 3.18a2 2 0 0 1 1.112 1.11z" />
<path d="m21.854 2.147-10.94 10.939" />
</svg>

Before

Width:  |  Height:  |  Size: 422 B

View File

@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-cog-icon lucide-cog"><path d="M12 20a8 8 0 1 0 0-16 8 8 0 0 0 0 16Z"/><path d="M12 14a2 2 0 1 0 0-4 2 2 0 0 0 0 4Z"/><path d="M12 2v2"/><path d="M12 22v-2"/><path d="m17 20.66-1-1.73"/><path d="M11 10.27 7 3.34"/><path d="m20.66 17-1.73-1"/><path d="m3.34 7 1.73 1"/><path d="M14 12h8"/><path d="M2 12h2"/><path d="m20.66 7-1.73 1"/><path d="m3.34 17 1.73-1"/><path d="m17 3.34-1 1.73"/><path d="m11 13.73-4 6.93"/></svg>

Before

Width:  |  Height:  |  Size: 623 B

View File

@ -1,7 +0,0 @@
<svg width="26" height="28" viewBox="0 0 26 28" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M20.4237 9.10858C22.4634 9.10858 24.1169 7.45507 24.1169 5.41537C24.1169 3.37567 22.4634 1.72217 20.4237 1.72217C18.384 1.72217 16.7305 3.37567 16.7305 5.41537C16.7305 7.45507 18.384 9.10858 20.4237 9.10858Z" stroke="currentColor" stroke-width="2.46215" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M5.65317 17.728C7.69287 17.728 9.34637 16.0745 9.34637 14.0348C9.34637 11.9951 7.69287 10.3416 5.65317 10.3416C3.61347 10.3416 1.95996 11.9951 1.95996 14.0348C1.95996 16.0745 3.61347 17.728 5.65317 17.728Z" stroke="currentColor" stroke-width="2.46215" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M20.4237 26.3434C22.4634 26.3434 24.1169 24.6899 24.1169 22.6502C24.1169 20.6105 22.4634 18.957 20.4237 18.957C18.384 18.957 16.7305 20.6105 16.7305 22.6502C16.7305 24.6899 18.384 26.3434 20.4237 26.3434Z" stroke="currentColor" stroke-width="2.46215" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M8.84277 15.8914L17.251 20.791" stroke="currentColor" stroke-width="2.46215" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M17.2387 7.27588L8.84277 12.1755" stroke="currentColor" stroke-width="2.46215" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

Before

Width:  |  Height:  |  Size: 1.3 KiB

View File

@ -1,4 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-shield-check-icon lucide-shield-check">
<path d="M20 13c0 5-3.5 7.5-7.66 8.95a1 1 0 0 1-.67-.01C7.5 20.5 4 18 4 13V6a1 1 0 0 1 1-1c2 0 4.5-1.2 6.24-2.72a1.17 1.17 0 0 1 1.52 0C14.51 3.81 17 5 19 5a1 1 0 0 1 1 1z" />
<path d="m9 12 2 2 4-4" />
</svg>

Before

Width:  |  Height:  |  Size: 460 B

View File

@ -1,6 +0,0 @@
<svg viewBox="0 0 26 26" fill="none" xmlns="http://www.w3.org/2000/svg">
<line x1="4.72803" y1="12.7279" x2="20.728" y2="12.7279" stroke="#FFBB00" stroke-width="2" stroke-linecap="round" />
<line x1="12.728" y1="4.72791" x2="12.728" y2="20.7279" stroke="#FFBB00" stroke-width="2" stroke-linecap="round" />
<line x1="7.07107" y1="7.07104" x2="18.3848" y2="18.3848" stroke="#FFBB00" stroke-width="2" stroke-linecap="round" />
<line x1="18.3848" y1="7.07107" x2="7.07106" y2="18.3848" stroke="#FFBB00" stroke-width="2" stroke-linecap="round" />
</svg>

Before

Width:  |  Height:  |  Size: 565 B

View File

@ -1,7 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-star-icon lucide-star">
<path d="M9.937 15.5A2 2 0 0 0 8.5 14.063l-6.135-1.582a.5.5 0 0 1 0-.962L8.5 9.936A2 2 0 0 0 9.937 8.5l1.582-6.135a.5.5 0 0 1 .963 0L14.063 8.5A2 2 0 0 0 15.5 9.937l6.135 1.581a.5.5 0 0 1 0 .964L15.5 14.063a2 2 0 0 0-1.437 1.437l-1.582 6.135a.5.5 0 0 1-.963 0z" />
<path d="M20 3v4" />
<path d="M22 5h-4" />
<path d="M4 17v2" />
<path d="M5 18H3" />
</svg>

Before

Width:  |  Height:  |  Size: 603 B

View File

@ -1,5 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-sync-icon lucide-sync">
<rect width="10" height="14" x="3" y="8" rx="2" />
<path d="M5 4a2 2 0 0 1 2-2h12a2 2 0 0 1 2 2v16a2 2 0 0 1-2 2h-2.4" />
<path d="M8 18h.01" />
</svg>

Before

Width:  |  Height:  |  Size: 390 B

View File

@ -1,3 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
<path d="M7 16a4 4 0 0 1-.88-7.903A5 5 0 1 1 15.9 6L16 6a5 5 0 0 1 1 9.9M15 13l-3-3m0 0l-3 3m3-3v12" />
</svg>

Before

Width:  |  Height:  |  Size: 299 B

View File

@ -1,6 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg" stroke="currentColor">
<path d="M2 21a8 8 0 0 1 10.821-7.487" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M21.378 16.626a1 1 0 0 0-3.004-3.004l-4.01 4.012a2 2 0 0 0-.506.854l-.837 2.87a.5.5 0 0 0 .62.62l2.87-.837a2 2 0 0 0 .854-.506z" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
<circle cx="10" cy="8" r="5" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

Before

Width:  |  Height:  |  Size: 574 B

View File

@ -1,6 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-book-lock-icon lucide-book-lock">
<path d="M18 6V4a2 2 0 1 0-4 0v2" />
<path d="M20 15v6a1 1 0 0 1-1 1H6.5a1 1 0 0 1 0-5H20" />
<path d="M4 19.5v-15A2.5 2.5 0 0 1 6.5 2H10" />
<rect x="12" y="6" width="8" height="5" rx="1" />
</svg>

Before

Width:  |  Height:  |  Size: 451 B

View File

@ -1,5 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<circle cx="12" cy="4" r="2" />
<path d="M10.2 3.2C5.5 4 2 8.1 2 13a2 2 0 0 0 4 0v-1a2 2 0 0 1 4 0v4a2 2 0 0 0 4 0v-4a2 2 0 0 1 4 0v1a2 2 0 0 0 4 0c0-4.9-3.5-9-8.2-9.8" />
<path d="M3.2 14.8a9 9 0 0 0 17.6 0" />
</svg>

Before

Width:  |  Height:  |  Size: 413 B

View File

@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-target-icon lucide-target"><circle cx="12" cy="12" r="10"/><circle cx="12" cy="12" r="6"/><circle cx="12" cy="12" r="2"/></svg>

Before

Width:  |  Height:  |  Size: 329 B

View File

@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-monitor-play-icon lucide-monitor-play"><path d="M10 7.75a.75.75 0 0 1 1.142-.638l3.664 2.249a.75.75 0 0 1 0 1.278l-3.664 2.25a.75.75 0 0 1-1.142-.64z"/><path d="M12 17v4"/><path d="M8 21h8"/><rect x="2" y="3" width="20" height="14" rx="2"/></svg>

Before

Width:  |  Height:  |  Size: 448 B

View File

@ -1,3 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-video-icon lucide-video">
<path d="M4.5 4.5a3 3 0 0 0-3 3v9a3 3 0 0 0 3 3h8.25a3 3 0 0 0 3-3v-9a3 3 0 0 0-3-3H4.5ZM19.94 18.75l-2.69-2.69V7.94l2.69-2.69c.944-.945 2.56-.276 2.56 1.06v11.38c0 1.336-1.616 2.005-2.56 1.06Z" />
</svg>

Before

Width:  |  Height:  |  Size: 437 B

View File

@ -1,3 +0,0 @@
<svg viewBox="0 0 113 152" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M25.8559 151C12.8609 139.722 -9.23051 107.241 6.36346 67.5413C25.8559 17.9171 91.25 30.8873 97.5379 49.4961C100.053 56.9397 103.197 67.5413 91.25 74.3084C82.2279 79.4187 66.1175 77.9572 61.6969 71.4888C57.2763 65.0204 42.8332 32.0151 112 1" stroke="#FFBB00" stroke-width="2" stroke-dasharray="4 4" />
</svg>

Before

Width:  |  Height:  |  Size: 396 B

View File

@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-x-icon lucide-x"><path d="M18 6 6 18"/><path d="m6 6 12 12"/></svg>

Before

Width:  |  Height:  |  Size: 269 B

View File

@ -5,5 +5,8 @@ module.exports = function (api) {
["babel-preset-expo", { jsxImportSource: "nativewind" }], ["babel-preset-expo", { jsxImportSource: "nativewind" }],
"nativewind/babel", "nativewind/babel",
], ],
plugins: [
'expo-router/babel',
],
}; };
}; };

View File

@ -0,0 +1,117 @@
import SvgIcon from "@/components/svg-icon";
import React from 'react';
import { useTranslation } from "react-i18next";
import { Animated, StyleSheet, Text, TouchableOpacity, View } from 'react-native';
import { FileStatus } from "./file-uploader";
interface FileItemProps {
fileStatus: FileStatus;
index: number;
onRemove: (file: File) => void;
formatFileSize: (bytes: number) => string;
disabled?: boolean;
}
export default function FileItem({
fileStatus,
index,
onRemove,
formatFileSize,
disabled = false
}: FileItemProps) {
const { t } = useTranslation();
const fadeAnim = React.useRef(new Animated.Value(0)).current;
const translateY = React.useRef(new Animated.Value(10)).current;
React.useEffect(() => {
Animated.parallel([
Animated.timing(fadeAnim, {
toValue: 1,
duration: 200,
delay: index * 50,
useNativeDriver: true,
}),
Animated.timing(translateY, {
toValue: 0,
duration: 200,
delay: index * 50,
useNativeDriver: true,
})
]).start();
}, []);
return (
<Animated.View
style={[
styles.card,
{
opacity: fadeAnim,
transform: [{ translateY }]
}
]}
>
<View style={styles.cardBody}>
<View style={styles.fileInfo}>
<Text style={styles.fileName}>{fileStatus.file.name}</Text>
<Text style={styles.fileSize}>{formatFileSize(fileStatus.file.size)}</Text>
</View>
{!disabled && (
<TouchableOpacity
onPress={() => onRemove(fileStatus.file)}
style={styles.removeButton}
>
<SvgIcon name="close" size={16} color="#666" />
</TouchableOpacity>
)}
</View>
{fileStatus.progress !== undefined && fileStatus.progress < 100 && (
<View style={styles.progressContainer}>
<View style={[styles.progressBar, { width: `${fileStatus.progress}%` }]} />
</View>
)}
</Animated.View>
);
}
const styles = StyleSheet.create({
card: {
backgroundColor: '#fff',
borderRadius: 8,
marginBottom: 8,
shadowColor: '#000',
shadowOffset: { width: 0, height: 1 },
shadowOpacity: 0.1,
shadowRadius: 2,
elevation: 2,
},
cardBody: {
padding: 12,
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
},
fileInfo: {
flex: 1,
},
fileName: {
fontSize: 14,
color: '#333',
marginBottom: 4,
},
fileSize: {
fontSize: 12,
color: '#666',
},
removeButton: {
padding: 4,
},
progressContainer: {
height: 2,
backgroundColor: '#e9ecef',
width: '100%',
},
progressBar: {
height: '100%',
backgroundColor: '#007bff',
},
});

View File

@ -0,0 +1,609 @@
import { fetchApi } from "@/lib/server-api-util";
import { useCallback, useEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { v4 as uuidv4 } from 'uuid';
// 导入子组件
import { ConfirmUpload } from "@/types/upload";
import { View } from "react-native";
import MultiFileUploader from "./multi-file-uploader";
import SingleFileUploader from "./single-file-uploader";
import UploadDropzone from "./upload-dropzone";
import { extractVideoFirstFrame, getVideoDuration } from "./utils/videoUtils";
// 默认允许的文件类型
export const DEFAULT_ALLOWED_FILE_TYPES = ["video/mp4", "video/quicktime", "video/x-msvideo", "video/x-matroska"];
export const DEFAULT_MAX_FILE_SIZE = 500 * 1024 * 1024; // 500MB
// 上传URL响应接口
export interface UploadUrlResponse {
upload_url: string;
file_id: string;
}
// 文件状态接口
export interface FileStatus {
file: File;
id?: string;
progress: number;
error?: string;
status: 'pending' | 'uploading' | 'success' | 'error';
url?: string;
thumbnailUrl?: string; // 添加缩略图URL
}
interface FileUploaderProps {
onFilesUploaded?: (files: FileStatus[]) => void;
maxFiles?: number;
allowMultiple?: boolean;
disabled?: boolean;
className?: string;
allowedFileTypes?: string[]; // 外部传入的允许文件类型
maxFileSize?: number; // 外部传入的最大文件大小
thumbnailPropsUrl?: string; // 注册后返回ai处理展示缩略图
}
export default function FileUploader({
onFilesUploaded,
maxFiles = 1,
allowMultiple = false,
disabled = false,
className = "",
allowedFileTypes = DEFAULT_ALLOWED_FILE_TYPES,
maxFileSize = DEFAULT_MAX_FILE_SIZE,
thumbnailPropsUrl = ""
}: FileUploaderProps) {
const { t } = useTranslation();
const [files, setFiles] = useState<FileStatus[]>([]);
const [isUploading, setIsUploading] = useState(false);
const fileInputRef = useRef<HTMLInputElement>(null);
// 图片的最小值
const MIN_IMAGE_SIZE = 300;
// 校验图片尺寸(异步)
function validateImageDimensions(file: File): Promise<string | null> {
return new Promise((resolve) => {
const img = new window.Image();
img.onload = () => {
if (img.width < MIN_IMAGE_SIZE || img.height < MIN_IMAGE_SIZE) {
resolve(`图片尺寸不能小于${MIN_IMAGE_SIZE}px当前为${img.width}x${img.height}`);
} else {
resolve(null);
}
};
img.onerror = () => resolve("无法读取图片尺寸");
img.src = URL.createObjectURL(file);
});
}
// 格式化文件大小
const formatFileSize = (bytes: number): string => {
if (bytes < 1024) return bytes + ' B';
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB';
return (bytes / (1024 * 1024)).toFixed(1) + ' MB';
};
// 验证文件
const validateFile = (file: File): string | null => {
// 验证文件类型
if (!allowedFileTypes.includes(file.type)) {
const errorMsg = t('validation.file.invalidType');
// addToast({
// title: t('fileUploader.invalidFileTitle'),
// description: errorMsg,
// color: "danger",
// });
return errorMsg;
}
// 验证文件大小
// if (file.size > maxFileSize) {
// const errorMsg = t('validation.file.tooLarge');
// addToast({
// title: t('fileUploader.fileTooLargeTitle'),
// description: `${errorMsg} ${formatFileSize(maxFileSize)}`,
// color: "danger",
// });
// return errorMsg;
// }
return null;
};
// 创建缩略图
const createThumbnail = (file: File): Promise<string> => {
return new Promise((resolve, reject) => {
// 如果是图片文件,直接使用图片作为缩略图
if (file.type.startsWith('image/')) {
const reader = new FileReader();
reader.onload = (e) => {
if (e.target && typeof e.target.result === 'string') {
resolve(e.target.result);
} else {
reject(new Error('图片加载失败'));
}
};
reader.onerror = () => {
reject(new Error('图片读取失败'));
};
reader.readAsDataURL(file);
return;
}
// 如果是视频文件,创建视频缩略图
const videoUrl = URL.createObjectURL(file);
const video = document.createElement('video');
video.src = videoUrl;
video.crossOrigin = 'anonymous';
video.muted = true;
video.preload = 'metadata';
video.onloadeddata = () => {
try {
// 设置视频时间到第一帧
video.currentTime = 0.1;
} catch (e) {
URL.revokeObjectURL(videoUrl);
reject(e);
}
};
video.onseeked = () => {
try {
// 创建canvas并绘制视频帧
const canvas = document.createElement('canvas');
canvas.width = video.videoWidth;
canvas.height = video.videoHeight;
const ctx = canvas.getContext('2d');
if (ctx) {
ctx.drawImage(video, 0, 0, canvas.width, canvas.height);
const thumbnailUrl = canvas.toDataURL('image/jpeg', 0.7);
URL.revokeObjectURL(videoUrl);
resolve(thumbnailUrl);
} else {
reject(new Error('无法创建canvas上下文'));
}
} catch (e) {
URL.revokeObjectURL(videoUrl);
reject(e);
}
};
video.onerror = () => {
URL.revokeObjectURL(videoUrl);
reject(new Error('视频加载失败'));
};
});
};
// 压缩图片函数
async function compressImageToFile(
file: File,
maxWidth = 600,
quality = 0.7,
outputType = 'image/png'
): Promise<File> {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = (event: ProgressEvent<FileReader>) => {
const target = event.target as FileReader;
if (!target || !target.result) {
reject(new Error('Failed to read file'));
return;
}
const img = new Image();
img.onload = () => {
// 计算压缩尺寸
const canvas = document.createElement('canvas');
let width = img.width;
let height = img.height;
if (width > maxWidth) {
height = Math.round((height * maxWidth) / width);
width = maxWidth;
}
canvas.width = width;
canvas.height = height;
// 绘制压缩图片
const ctx = canvas.getContext('2d');
if (!ctx) {
throw new Error('Could not get 2D context from canvas');
}
ctx.drawImage(img, 0, 0, width, height);
// 转为 Blob 并生成 File 对象
canvas.toBlob(
(blob: Blob | null) => {
if (!blob) {
reject(new Error('Failed to create blob from canvas'));
return;
}
let file_name = uuidv4() + ".png"
const compressedFile = new File([blob], file_name, {
type: outputType,
lastModified: Date.now()
});
resolve(compressedFile);
},
outputType,
quality
);
};
img.onerror = reject;
img.src = target.result as string;
};
reader.onerror = reject;
reader.readAsDataURL(file);
});
}
// 新增素材
const addMaterial = async (file: string, compressFile: string) => {
await fetchApi('/material', {
method: 'POST',
body: JSON.stringify([{
"file_id": file,
"preview_file_id": compressFile
}])
}).catch((error) => {
console.log(error);
})
}
// 上传单个文件
const uploadFile = async (fileStatus: FileStatus, fileIndex: number, compressedFile: File) => {
// 创建新的文件状态对象,而不是修改原来的数组
// 这样可以避免多个异步操作对同一个数组的并发修改
let currentFileStatus: FileStatus = {
...fileStatus,
status: 'uploading' as const,
progress: 0
};
// 使用函数更新文件状态,确保每次更新都是原子的
const updateFileStatus = (updates: Partial<FileStatus>) => {
currentFileStatus = { ...currentFileStatus, ...updates };
setFiles(prevFiles => {
const newFiles = [...prevFiles];
newFiles[fileIndex] = currentFileStatus;
return newFiles;
});
};
// 初始化上传状态
updateFileStatus({ status: 'uploading', progress: 0 });
// 添加小延迟,确保初始状态能被看到
await new Promise(resolve => setTimeout(resolve, 300));
try {
// 获取视频时长
let metadata = {};
if (fileStatus.file.type.startsWith('video/')) {
metadata = {
duration: (await getVideoDuration(fileStatus.file)).toString()
}
}
// 获取上传URL
updateFileStatus({ progress: 10 });
const uploadUrlData = await getUploadUrl(fileStatus.file, metadata);
const compressedFileData = await getUploadUrl(compressedFile, {})
// 确保正确更新文件ID
updateFileStatus({ id: uploadUrlData.file_id, progress: 20 });
// 上传文件到URL
await uploadFileToUrl(
compressedFile,
compressedFileData.upload_url,
(progress) => {
// 将实际进度映射到 60%-90% 区间
const mappedProgress = 60 + (progress * 0.3);
updateFileStatus({ progress: Math.round(mappedProgress) });
}
);
await uploadFileToUrl(
fileStatus.file,
uploadUrlData.upload_url,
(progress) => {
// 将实际进度映射到 60%-90% 区间
const mappedProgress = 60 + (progress * 0.3);
updateFileStatus({ progress: Math.round(mappedProgress) });
}
);
// 向服务端confirm上传
await fetchApi<ConfirmUpload>('/file/confirm-upload', {
method: 'POST',
body: JSON.stringify({
file_id: uploadUrlData.file_id
})
});
await fetchApi<ConfirmUpload>('/file/confirm-upload', {
method: 'POST',
body: JSON.stringify({
file_id: compressedFileData.file_id
})
});
// 等待一些时间再标记为成功
await new Promise(resolve => setTimeout(resolve, 300));
// 更新状态为成功
updateFileStatus({ status: 'success', progress: 100, id: uploadUrlData.file_id });
await addMaterial(uploadUrlData.file_id, compressedFileData.file_id)
// 打印最终状态以进行调试
// console.log('最终文件状态:', currentFileStatus);
// 调用回调函数
if (onFilesUploaded) {
// 使用当前文件状态创建一个新的数组传递给回调函数
const updatedFiles = [...files];
updatedFiles[fileIndex] = {
...currentFileStatus,
id: uploadUrlData.file_id, // 确保 ID 正确传递
};
// 延迟调用回调函数,确保状态已更新
setTimeout(() => {
// console.log('传递给回调的文件:', updatedFiles);
onFilesUploaded(updatedFiles);
}, 100);
}
} catch (error) {
console.error('Upload error:', error);
// 更新状态为错误
updateFileStatus({
status: 'error',
error: error instanceof Error ? error.message : '上传失败'
});
}
};
// 处理文件选择
const handleFileSelect = useCallback(async (selectedFiles: FileList | null) => {
if (!selectedFiles) return;
setIsUploading(true);
const newFiles: FileStatus[] = [];
for (let i = 0; i < selectedFiles.length; i++) {
const file = selectedFiles[i];
const error = validateFile(file);
let dimensionError: string | null = null;
if (!error && file.type.startsWith('image/')) {
dimensionError = await validateImageDimensions(file);
}
let thumbnailUrl = '';
// 只在文件验证通过且尺寸合格时创建缩略图
if (!error && !dimensionError) {
try {
// 创建缩略图,支持图片和视频
thumbnailUrl = await createThumbnail(file);
newFiles.push({
file,
progress: 0,
error: error ?? undefined,
status: error ? 'error' : 'pending',
thumbnailUrl
});
} catch (e) {
console.error('缩略图创建失败:', e);
}
} else {
// 添加警告
// addToast({
// title: t('fileUploader.fileTooSmallTitle'),
// description: t('fileUploader.fileTooSmall'),
// color: "warning",
// });
}
}
// 更新文件列表
setFiles(prev => {
// 单文件模式下且已有文件时,替换现有文件
if (maxFiles === 1 && prev.length > 0 && newFiles.length > 0) {
return [newFiles[0]]; // 只保留新选择的第一个文件
} else {
// 多文件模式,合并并限制数量
const combinedFiles = [...prev, ...newFiles];
return combinedFiles.length > maxFiles
? combinedFiles.slice(0, maxFiles)
: combinedFiles;
}
});
// 在状态更新后,使用 useEffect 来处理上传
setIsUploading(false);
}, [maxFiles]);
// 获取上传URL
const getUploadUrl = async (file: File, metadata: { [key: string]: string }): Promise<UploadUrlResponse> => {
const body = {
filename: file.name,
content_type: file.type,
file_size: file.size,
metadata
}
return await fetchApi<UploadUrlResponse>("/file/generate-upload-url", {
method: 'POST',
body: JSON.stringify(body)
});
};
// 上传文件到URL
const uploadFileToUrl = async (file: File, uploadUrl: string, onProgress: (progress: number) => void): Promise<void> => {
return new Promise((resolve, reject) => {
const xhr = new XMLHttpRequest();
xhr.open('PUT', uploadUrl);
xhr.setRequestHeader('Content-Type', file.type);
// 进度监听
xhr.upload.onprogress = (event) => {
if (event.lengthComputable) {
const progress = Math.round((event.loaded / event.total) * 100);
onProgress(progress);
}
};
xhr.onload = () => {
if (xhr.status >= 200 && xhr.status < 300) {
resolve();
} else {
reject(new Error(`Upload failed with status ${xhr.status}`));
}
};
xhr.onerror = () => {
reject(new Error('Network error during upload'));
};
xhr.send(file);
});
};
// 移除文件
const removeFile = (fileToRemove: File) => {
setFiles(prev => prev.filter(fileStatus => fileStatus.file !== fileToRemove));
};
// 清除所有文件
const clearFiles = () => {
setFiles([]);
};
// 打开文件选择器
const openFileSelector = () => {
if (!disabled) {
if (fileInputRef.current) {
fileInputRef.current.click();
} else {
const input = document.createElement('input');
input.type = 'file';
input.accept = allowedFileTypes.join(',');
input.multiple = allowMultiple;
input.onchange = (e) => handleFileSelect((e.target as HTMLInputElement).files);
input.click();
}
}
};
// 使用 useEffect 监听 files 变化,处理待上传的文件
useEffect(() => {
const processFiles = async () => {
// Only process files that are in 'pending' status
const pendingFiles = files
.filter(f => f.status === 'pending')
.filter((fileStatus, index, self) =>
index === self.findIndex(f => f.file === fileStatus.file)
); // Remove duplicates
if (pendingFiles.length === 0) return;
// Create a new array with updated status to prevent infinite loops
setFiles(prevFiles =>
prevFiles.map(file =>
pendingFiles.some(pf => pf.file === file.file)
? { ...file, status: 'uploading' as const }
: file
)
);
// Process each file sequentially to avoid race conditions
for (const fileStatus of pendingFiles) {
try {
const fileIndex = files.findIndex(f => f.file === fileStatus.file);
if (fileIndex === -1) continue;
let compressedFile: File;
if (fileStatus.file.type?.includes('video')) {
const frameFile = await extractVideoFirstFrame(fileStatus.file);
compressedFile = await compressImageToFile(frameFile, 600, 0.7);
} else {
compressedFile = fileStatus.file;
}
await uploadFile(
{ ...fileStatus, status: 'uploading' as const },
fileIndex,
compressedFile
);
} catch (error) {
console.error('Error processing file:', error);
setFiles(prevFiles =>
prevFiles.map(f =>
f.file === fileStatus.file
? {
...f,
status: 'error' as const,
error: error instanceof Error ? error.message : '处理文件失败'
}
: f
)
);
}
}
};
processFiles();
}, [files]); // Only run when files array changes
return (
<View className={className}
aria-label="文件上传"
>
{/* 隐藏的文件输入 */}
<input
ref={fileInputRef}
type="file"
accept={allowedFileTypes.join(',')}
multiple={allowMultiple}
onChange={(e) => handleFileSelect(e.target.files)}
className="hidden"
/>
{/* 文件上传区域 - 始终可见 */}
<View className="space-y-6 h-full">
{/* 上传区域 */}
{maxFiles === 1 && files.length === 1 ? (
/* 单文件模式且已有文件 - 不添加外层的onClick事件 */
<SingleFileUploader
file={files[0]}
onReplace={openFileSelector}
disabled={disabled}
/>
) : thumbnailPropsUrl ? <img
src={thumbnailPropsUrl}
className="w-full h-full object-cover"
/> : (
/* 多文件模式或无文件 - 只在组件上添加一个onClick事件 */
<UploadDropzone
onClick={openFileSelector}
disabled={disabled}
allowedFileTypes={allowedFileTypes}
// maxFileSize={maxFileSize}
/>
)}
{/* 文件列表区域 - 仅在多文件模式下显示 */}
{maxFiles !== 1 && files.length > 0 && (
<MultiFileUploader
files={files}
onRemove={removeFile}
onClearAll={clearFiles}
formatFileSize={formatFileSize}
disabled={disabled}
/>
)}
</View>
</View>
);
}

View File

@ -0,0 +1,65 @@
import useWindowSize from "@/hooks/useWindowSize";
import { useTranslation } from "react-i18next";
import { TouchableOpacity, View } from "react-native";
import { ThemedText } from "../ThemedText";
import FileItemPhone from "./file-item-phone";
import { FileStatus } from "./file-uploader";
interface MultiFileUploaderProps {
files: FileStatus[];
onRemove: (file: File) => void;
onClearAll: () => void;
formatFileSize: (bytes: number) => string;
disabled?: boolean;
}
/**
* -
*/
export default function MultiFileUploader({
files,
onRemove,
onClearAll,
formatFileSize,
disabled = false
}: MultiFileUploaderProps) {
const { t } = useTranslation();
// 获取当前屏幕尺寸
const { isMobile } = useWindowSize();
return (
<View className="space-y-4">
<View className="flex justify-between items-center">
<ThemedText className="text-md font-medium">
{t('fileUploader.uploadedFiles')}
</ThemedText>
<View className="p-6 w-full">
<TouchableOpacity
className={`w-full bg-white rounded-full p-4 items-center `}
onPress={onClearAll}
disabled={disabled}
>
<ThemedText className="text-textTertiary text-lg font-semibold">
{t('fileUploader.clearAll')}
</ThemedText>
</TouchableOpacity>
</View>
</View>
<View className="grid grid-cols-3 xl:grid-cols-4 gap-4 sm:max-h-[15rem] overflow-y-auto w-full">
{files.map((fileStatus, index) => (
(
<FileItemPhone
key={index}
fileStatus={fileStatus}
index={index}
onRemove={onRemove}
formatFileSize={formatFileSize}
disabled={disabled}
/>
)
))}
</View>
</View>
);
}

View File

@ -0,0 +1,174 @@
import React from 'react';
import { View, Text, TouchableOpacity, Image, StyleSheet } from 'react-native';
import { useTranslation } from 'react-i18next';
import { FileStatus } from './file-uploader';
import Icon from 'react-native-vector-icons/MaterialIcons';
interface SingleFileUploaderProps {
file: FileStatus;
onReplace: () => void;
disabled?: boolean;
formatFileSize?: (bytes: number) => string;
}
export default function SingleFileUploader({
file,
onReplace,
disabled = false,
formatFileSize = (bytes) => `${bytes} B`
}: SingleFileUploaderProps) {
const { t } = useTranslation();
return (
<View style={styles.container}>
{/* 缩略图容器 */}
<View style={styles.thumbnailContainer}>
{file.thumbnailUrl ? (
<>
<Image
source={{ uri: file.thumbnailUrl }}
style={styles.thumbnailImage}
resizeMode="cover"
/>
{/* 错误信息显示 */}
{file.error && (
<View style={styles.errorContainer}>
<Text style={styles.errorText}>
{file.error}
</Text>
</View>
)}
</>
) : (
<View style={styles.placeholderContainer}>
<Icon name="videocam" size={40} color="#9CA3AF" />
</View>
)}
{/* 显示替换按钮 */}
{file.thumbnailUrl && !disabled && (
<TouchableOpacity
style={styles.replaceButton}
onPress={(e) => {
e.stopPropagation();
onReplace();
}}
disabled={disabled}
>
<View style={styles.replaceButtonContent}>
<Icon name="upload" size={24} color="white" />
<Text style={styles.replaceButtonText}>
{t('common.replace')}
</Text>
</View>
</TouchableOpacity>
)}
</View>
{/* 文件信息 */}
<View style={styles.fileInfo}>
<View style={styles.fileInfoText}>
<Text style={styles.fileName} numberOfLines={1}>
{file.file.name}
</Text>
<Text style={styles.fileSize}>
{formatFileSize(file.file.size)} {file.status}
</Text>
</View>
{file.status === 'uploading' && (
<View style={styles.progressContainer}>
<View style={[styles.progressBar, { width: `${file.progress}%` }]} />
</View>
)}
</View>
</View>
);
}
const styles = StyleSheet.create({
container: {
width: '100%',
height: '100%',
},
thumbnailContainer: {
width: '100%',
aspectRatio: 16/9,
backgroundColor: '#F3F4F6',
borderRadius: 6,
overflow: 'hidden',
position: 'relative',
},
thumbnailImage: {
width: '100%',
height: '100%',
},
errorContainer: {
position: 'absolute',
bottom: 0,
left: 0,
right: 0,
backgroundColor: 'rgba(220, 38, 38, 0.8)',
padding: 4,
},
errorText: {
color: 'white',
fontSize: 12,
},
placeholderContainer: {
width: '100%',
height: '100%',
justifyContent: 'center',
alignItems: 'center',
},
replaceButton: {
position: 'absolute',
top: 0,
left: 0,
right: 0,
bottom: 0,
backgroundColor: 'rgba(0, 0, 0, 0.5)',
justifyContent: 'center',
alignItems: 'center',
opacity: 0,
},
replaceButtonContent: {
justifyContent: 'center',
alignItems: 'center',
},
replaceButtonText: {
color: 'white',
fontSize: 14,
fontWeight: '500',
},
fileInfo: {
marginTop: 8,
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
},
fileInfoText: {
flex: 1,
minWidth: 0,
},
fileName: {
fontSize: 14,
fontWeight: '500',
color: '#111827',
},
fileSize: {
fontSize: 12,
color: '#6B7280',
},
progressContainer: {
width: 96,
height: 8,
backgroundColor: '#E5E7EB',
borderRadius: 4,
marginLeft: 8,
overflow: 'hidden',
},
progressBar: {
height: '100%',
backgroundColor: '#3B82F6',
},
});

View File

@ -0,0 +1,77 @@
import SvgIcon from "@/components/svg-icon";
import { useTranslation } from "react-i18next";
import { DEFAULT_ALLOWED_FILE_TYPES, DEFAULT_MAX_FILE_SIZE } from "./file-uploader";
interface UploadDropzoneProps {
onClick: () => void;
disabled?: boolean;
allowedFileTypes?: string[];
maxFileSize?: number;
}
/**
* -
*/
export default function UploadDropzone({
onClick,
disabled = false,
allowedFileTypes = DEFAULT_ALLOWED_FILE_TYPES,
maxFileSize = DEFAULT_MAX_FILE_SIZE
}: UploadDropzoneProps) {
const { t } = useTranslation();
// 格式化文件类型显示
const formatFileTypes = (types: string[]): string => {
return types.map(type => {
// 从 MIME 类型提取文件扩展名
const extensions: Record<string, string> = {
'video/mp4': 'MP4',
'video/quicktime': 'MOV',
'video/x-msvideo': 'AVI',
'video/x-matroska': 'MKV',
'image/jpeg': 'JPG',
'image/png': 'PNG',
'image/gif': 'GIF',
'image/webp': 'WEBP'
};
return extensions[type] || type.split('/')[1]?.toUpperCase() || type;
}).join(', ');
};
// 格式化文件大小显示
const formatFileSize = (bytes: number): string => {
if (bytes < 1024) return bytes + ' B';
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB';
if (bytes < 1024 * 1024 * 1024) return (bytes / (1024 * 1024)).toFixed(1) + ' MB';
return (bytes / (1024 * 1024 * 1024)).toFixed(1) + ' GB';
};
return (
<div
className={`border-2 border-dashed rounded-lg p-6 text-center cursor-pointer transition-all
${disabled ? 'opacity-50 cursor-not-allowed' : 'hover:border-primary/50'} h-full`}
onClick={disabled ? undefined : onClick}
>
<div className="flex flex-col items-center justify-center gap-3 h-full">
<div className="w-12 h-12 bg-gray-100 rounded-full flex items-center justify-center">
<SvgIcon name="upload" className="h-6 w-6 text-gray-500" />
</div>
<div>
<h3 className="text-base font-medium text-gray-900">
{t('fileUploader.dragAndDropFiles')}
</h3>
<p className="mt-1 text-sm text-gray-500">
{t('fileUploader.orClickToUpload')}
</p>
<p className="mt-1 text-xs text-gray-500">
{t('fileUploader.supportedFormats')}: {formatFileTypes(allowedFileTypes)}
</p>
<p className="text-xs text-gray-500">
{t('fileUploader.maxFileSize')}: {formatFileSize(maxFileSize)}
</p>
</div>
</div>
</div>
);
}

View File

@ -0,0 +1,138 @@
/**
* File对象
* @param videoFile
* @returns File对象
*/
export const extractVideoFirstFrame = (videoFile: File): Promise<File> => {
return new Promise((resolve, reject) => {
const videoUrl = URL.createObjectURL(videoFile);
const video = document.createElement('video');
video.src = videoUrl;
video.crossOrigin = 'anonymous';
video.muted = true;
video.preload = 'metadata';
video.onloadeddata = () => {
try {
// 设置视频时间到第一帧
video.currentTime = 0.1;
} catch (e) {
URL.revokeObjectURL(videoUrl);
reject(e);
}
};
video.onseeked = () => {
try {
const canvas = document.createElement('canvas');
canvas.width = video.videoWidth;
canvas.height = video.videoHeight;
const ctx = canvas.getContext('2d');
if (!ctx) {
throw new Error('无法获取canvas上下文');
}
// 绘制视频帧到canvas
ctx.drawImage(video, 0, 0, canvas.width, canvas.height);
// 将canvas转换为DataURL
const dataUrl = canvas.toDataURL('image/jpeg');
// 将DataURL转换为Blob
const byteString = atob(dataUrl.split(',')[1]);
const mimeString = dataUrl.split(',')[0].split(':')[1].split(';')[0];
const ab = new ArrayBuffer(byteString.length);
const ia = new Uint8Array(ab);
for (let i = 0; i < byteString.length; i++) {
ia[i] = byteString.charCodeAt(i);
}
const blob = new Blob([ab], { type: mimeString });
// 创建File对象
const frameFile = new File(
[blob],
`${videoFile.name.replace(/\.[^/.]+$/, '')}_frame.jpg`,
{ type: 'image/jpeg' }
);
// 清理URL对象
URL.revokeObjectURL(videoUrl);
resolve(frameFile);
} catch (e) {
URL.revokeObjectURL(videoUrl);
reject(e);
}
};
video.onerror = () => {
URL.revokeObjectURL(videoUrl);
reject(new Error('视频加载失败'));
};
});
};
// 获取视频时长
export const getVideoDuration = (file: File): Promise<number> => {
return new Promise((resolve) => {
const video = document.createElement('video');
video.preload = 'metadata';
video.onloadedmetadata = () => {
URL.revokeObjectURL(video.src);
resolve(video.duration);
};
video.onerror = () => {
URL.revokeObjectURL(video.src);
resolve(0); // Return 0 if we can't get the duration
};
video.src = URL.createObjectURL(file);
});
};
// 根据 mp4 的url来获取视频时长
/**
* URL获取视频时长
* @param videoUrl URL
* @returns Promise
*/
export const getVideoDurationFromUrl = async (videoUrl: string): Promise<number> => {
return await new Promise((resolve, reject) => {
// 创建临时的video元素
const video = document.createElement('video');
// 设置为只加载元数据,不加载整个视频
video.preload = 'metadata';
// 处理加载成功
video.onloadedmetadata = () => {
// 释放URL对象
URL.revokeObjectURL(video.src);
// 返回视频时长(秒)
resolve(video.duration);
};
// 处理加载错误
video.onerror = () => {
URL.revokeObjectURL(video.src);
reject(new Error('无法加载视频'));
};
// 处理网络错误
video.onabort = () => {
URL.revokeObjectURL(video.src);
reject(new Error('视频加载被中止'));
};
// 设置视频源
video.src = videoUrl;
// 添加跨域属性(如果需要)
video.setAttribute('crossOrigin', 'anonymous');
// 开始加载元数据
video.load();
});
};

195
components/login/code.tsx Normal file
View File

@ -0,0 +1,195 @@
import Error from "@/assets/icons/svg/error.svg";
import { fetchApi } from "@/lib/server-api-util";
import { User } from "@/types/user";
import { router } from "expo-router";
import { useEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { ActivityIndicator, Animated, TextInput as RNTextInput, TextInput, TouchableOpacity, View } from "react-native";
import { useAuth } from "../../contexts/auth-context";
import { ThemedText } from "../ThemedText";
import { Steps } from "./phoneLogin";
interface LoginProps {
setSteps: (steps: Steps) => void;
phone: string;
}
const Code = ({ setSteps, phone }: LoginProps) => {
const { t } = useTranslation();
const { login } = useAuth();
const [isLoading, setIsLoading] = useState(false);
const refs = useRef<Array<RNTextInput | null>>(Array(6).fill(null));
const shakeAnim = useRef(new Animated.Value(0)).current;
const [code, setCode] = useState<string[]>(['', '', '', '', '', '']);
const [error, setError] = useState<string>('');
const focusNext = (index: number, value: string) => {
if (value && index < 5) {
refs?.current?.[index + 1]?.focus();
}
};
const focusPrevious = (index: number, key: string) => {
if (key === 'Backspace' && index > 0 && !code[index]) {
refs?.current?.[index - 1]?.focus();
}
};
const handleCodeChange = (text: string, index: number) => {
setError('');
const newCode = [...code];
newCode[index] = text;
setCode(newCode);
focusNext(index, text);
};
const sendVerificationCode = async () => {
try {
// 发送验证码
await fetchApi(`/iam/veritification-code`, {
method: 'POST',
body: JSON.stringify({ phone: phone }),
})
} catch (error) {
console.error(t("auth.telLogin.sendCodeError", { ns: 'login' }), error);
}
}
const handleTelLogin = async () => {
setError('');
if (!code.join('')) {
setError(t("auth.telLogin.codeRequired", { ns: 'login' }));
return;
}
// 如果验证码不是六位,提示错误
if (code.join('').length !== 6) {
setError(t("auth.telLogin.codeInvalid", { ns: 'login' }));
return;
}
setIsLoading(true);
setCountdown(60);
try {
await fetchApi<User>(`/iam/login/phone-login`, {
method: 'POST',
body: JSON.stringify({ phone: phone, code: code.join('') }),
}).then((res) => {
login(res, res.access_token || '')
router.replace('/user-message')
}).catch((error) => {
console.log(error);
setError(t("auth.telLogin.codeVaild", { ns: 'login' }));
})
setIsLoading(false);
} catch (error) {
setIsLoading(false);
console.error(t("auth.telLogin.codeVaild", { ns: 'login' }), error);
}
}
// 60s倒计时
const [countdown, setCountdown] = useState(0);
useEffect(() => {
if (countdown > 0) {
const timer = setTimeout(() => setCountdown(countdown - 1), 1000);
return () => clearTimeout(timer);
}
}, [countdown]);
return (
<View className="flex-1 bg-white p-6">
<View className="flex-1 justify-center">
<View className="items-center mb-8">
<ThemedText className="text-2xl font-semibold mb-2 text-gray-900">
{t("auth.telLogin.title", { ns: 'login' })}
</ThemedText>
<ThemedText className="text-base text-gray-600 text-center mb-1">
{t("auth.telLogin.secondTitle", { ns: 'login' })}
</ThemedText>
<ThemedText className="text-base font-medium !text-buttonFill">
{phone}
</ThemedText>
</View>
<Animated.View
style={{
transform: [{ translateX: shakeAnim }],
display: 'flex',
flexDirection: 'row',
gap: 24,
marginBottom: 16,
alignItems: 'center',
justifyContent: 'center',
}}
>
{code.map((digit, index) => (
<TextInput
key={index}
ref={(ref) => {
if (ref) {
refs.current[index] = ref;
}
}}
style={{ width: 40, height: 40 }}
className="bg-[#FFF8DE] rounded-xl text-textTertiary text-3xl text-center"
keyboardType="number-pad"
maxLength={1}
value={digit}
onChangeText={text => handleCodeChange(text, index)}
onKeyPress={({ nativeEvent }) => focusPrevious(index, nativeEvent.key)}
selectTextOnFocus
caretHidden={true}
/>
))}
</Animated.View>
<View className={`w-full flex-row justify-end mb-[1rem] items-center ${error ? 'opacity-100' : 'opacity-0'}`}>
<Error />
<ThemedText className="text-base font-medium !text-buttonFill ml-2">
{error}
</ThemedText>
</View>
<TouchableOpacity
className="bg-buttonFill py-3 rounded-full items-center justify-center"
onPress={handleTelLogin}
disabled={isLoading}
>
{isLoading ? (
<ActivityIndicator color="#ffffff" />
) : (
<ThemedText className="!text-white font-medium text-base">
{t("auth.telLogin.continue", { ns: 'login' })}
</ThemedText>
)}
</TouchableOpacity>
<View className="flex-row justify-center mt-4">
<ThemedText className="!text-textPrimary">
{t("auth.telLogin.sendAgain", { ns: 'login' })}
</ThemedText>
<TouchableOpacity onPress={() => {
if (countdown > 0) {
return
} else {
sendVerificationCode()
}
}}>
<ThemedText className={`!text-buttonFill font-medium ml-1 ${countdown > 0 ? '!text-gray-400' : ''}`}>
{countdown > 0 ? `${countdown}s${t("auth.telLogin.resend", { ns: 'login' })}` : t("auth.telLogin.resend", { ns: 'login' })}
</ThemedText>
</TouchableOpacity>
</View>
</View>
<View className="py-4">
<TouchableOpacity
className="py-3 items-center"
onPress={() => setSteps('phone')}
>
<ThemedText className="!text-buttonFill font-medium">
{t("auth.telLogin.goBack", { ns: 'login' })}
</ThemedText>
</TouchableOpacity>
</View>
</View>
)
}
export default Code

View File

@ -0,0 +1,121 @@
import { fetchApi } from "@/lib/server-api-util";
import { User } from "@/types/user";
import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { ActivityIndicator, TextInput, TouchableOpacity, View } from "react-native";
import { ThemedText } from "../ThemedText";
interface LoginProps {
setIsSignUp?: (isSignUp: string) => void;
closeModal?: () => void;
updateUrlParam?: (status: string, value: string) => void;
setError: (error: string) => void;
}
const ForgetPwd = ({ setIsSignUp, updateUrlParam, setError }: LoginProps) => {
const { t } = useTranslation();
const [loading, setLocading] = useState(false);
// 发送邮箱后把按钮变为disabled
const [isDisabled, setIsDisabled] = useState(false);
const [email, setEmail] = useState('');
const [countdown, setCountdown] = useState(0);
// 倒计时效果
useEffect(() => {
if (countdown > 0) {
const timer = setTimeout(() => setCountdown(countdown - 1), 1000);
return () => clearTimeout(timer);
} else if (countdown === 0 && isDisabled) {
setIsDisabled(false);
}
}, [countdown, isDisabled]);
// 发送邮件
const handleSubmit = () => {
if (!email) {
setError(t('auth.forgetPwd.emailPlaceholder', { ns: 'login' }));
return;
}
setLocading(true);
const body = {
email: email,
}
// 调接口确定邮箱是否正确,是否有该用户邮箱权限
fetchApi<User>('/iam/reset-password-session', {
method: 'POST',
body: JSON.stringify(body),
headers: {
'Content-Type': 'application/json'
}
})
.then((_) => {
console.log("Password reset email sent successfully");
setIsDisabled(true);
setCountdown(60); // 开始60秒倒计时
})
.catch((error) => {
console.error('Failed to send reset email:', error);
setError(t('auth.forgetPwd.sendEmailError', { ns: 'login' }));
})
.finally(() => {
setLocading(false);
});
};
// 返回登陆
const handleBackToLogin = () => {
if (setIsSignUp) {
setIsSignUp('login');
}
if (updateUrlParam) {
updateUrlParam('status', 'login');
}
}
return <View>
{/* 邮箱输入框 */}
<View className="mb-5">
<ThemedText className="text-base !text-textPrimary mb-2 ml-2">
{t('auth.forgetPwd.title', { ns: 'login' })}
</ThemedText>
<TextInput
className="border border-gray-300 rounded-2xl p-3 text-base bg-inputBackground"
placeholder={t('auth.forgetPwd.emailPlaceholder', { ns: 'login' })}
placeholderTextColor="#ccc"
value={email}
onChangeText={setEmail}
keyboardType="email-address"
autoCapitalize="none"
/>
</View>
{/* 发送重置密码邮件 */}
<TouchableOpacity
className={`w-full bg-[#E2793F] rounded-full p-4 items-center ${isDisabled ? 'opacity-50' : ''}`}
onPress={handleSubmit}
disabled={isDisabled || loading}
>
{loading ? (
<ActivityIndicator color="#fff" />
) : (
<ThemedText className="!text-white font-semibold">
{isDisabled
? `${t("auth.forgetPwd.sendEmailBtnDisabled", { ns: "login" })} (${countdown}s)`
: t("auth.forgetPwd.sendEmailBtn", { ns: "login" })}
</ThemedText>
)}
</TouchableOpacity>
{/* 返回登陆 */}
<TouchableOpacity
className="self-center mt-6"
onPress={handleBackToLogin}
>
<ThemedText className="!text-textPrimary text-sm">
{t('auth.forgetPwd.goback', { ns: 'login' })}
</ThemedText>
</TouchableOpacity>
</View>
}
export default ForgetPwd

152
components/login/login.tsx Normal file
View File

@ -0,0 +1,152 @@
import { Ionicons } from "@expo/vector-icons";
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { ActivityIndicator, TextInput, TouchableOpacity, View } from "react-native";
import { useAuth } from "../../contexts/auth-context";
import { fetchApi } from "../../lib/server-api-util";
import { User } from "../../types/user";
import { ThemedText } from "../ThemedText";
const REMEMBER_ACCOUNT_KEY = 'fairclip_remembered_account';
interface LoginProps {
updateUrlParam: (status: string, value: string) => void;
setError: (error: string) => void;
setShowPassword: (showPassword: boolean) => void;
showPassword: boolean;
}
const Login = ({ updateUrlParam, setError, setShowPassword, showPassword }: LoginProps) => {
const { t } = useTranslation();
const { login } = useAuth();
const [isLoading, setIsLoading] = useState(false);
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [rememberMe, setRememberMe] = useState(false);
const handleLogin = async () => {
if (!email) {
setError(t('auth.login.emailPlaceholder', { ns: 'login' }));
return;
};
if (!password) {
setError(t('auth.login.passwordPlaceholder', { ns: 'login' }));
return;
}
setIsLoading(true);
try {
const body = {
account: email,
password: password,
};
const res = await fetchApi<User>('/iam/login/password-login', {
method: 'POST',
body: JSON.stringify(body),
});
const userInfo = await fetchApi<User>('/iam/user-info');
login({ ...res, email: res?.account }, res.access_token || '');
} catch (error) {
console.error('Login failed', error);
} finally {
setIsLoading(false);
}
};
const handleForgotPassword = () => {
updateUrlParam('status', 'forgetPwd');
};
const handleSignUp = () => {
updateUrlParam('status', 'signUp');
};
return <View>
{/* 邮箱输入框 */}
<View className="mb-5">
<ThemedText className="text-base !text-textPrimary mb-2 ml-2">
{t('auth.login.email', { ns: 'login' })}
</ThemedText>
<TextInput
className="border border-gray-300 rounded-2xl p-3 text-base bg-inputBackground"
placeholder={t('auth.login.accountPlaceholder', { ns: 'login' })}
placeholderTextColor="#ccc"
value={email}
onChangeText={(text) => {
setEmail(text);
setError('123');
}}
keyboardType="email-address"
autoCapitalize="none"
/>
</View>
{/* 密码输入框 */}
<View className="mb-2">
<ThemedText className="text-base !text-textPrimary mb-2 ml-2">
{t('auth.login.password', { ns: 'login' })}
</ThemedText>
<View className="relative">
<TextInput
className="border border-gray-300 rounded-2xl p-3 text-base bg-inputBackground pr-12"
placeholder={t('auth.login.passwordPlaceholder', { ns: 'login' })}
placeholderTextColor="#ccc"
value={password}
onChangeText={(text) => {
setPassword(text);
setError('123');
}}
secureTextEntry={!showPassword}
/>
<TouchableOpacity
className="absolute right-3 top-3.5"
onPress={() => setShowPassword(!showPassword)}
>
<Ionicons
name={showPassword ? 'eye' : 'eye-off'}
size={20}
color="#666"
/>
</TouchableOpacity>
</View>
</View>
{/* 忘记密码链接 */}
<TouchableOpacity
className="self-end mb-6"
onPress={handleForgotPassword}
>
<ThemedText className="!text-textPrimary text-sm">
{t('auth.login.forgotPassword', { ns: 'login' })}
</ThemedText>
</TouchableOpacity>
{/* 登录按钮 */}
<TouchableOpacity
className={`w-full bg-[#E2793F] rounded-full text-[#fff] p-4 items-center mb-6 ${isLoading ? 'opacity-70' : ''}`}
onPress={handleLogin}
disabled={isLoading}
>
{isLoading ? (
<ActivityIndicator color="#fff" />
) : (
<ThemedText className="!text-white font-semibold">
{t('auth.login.loginButton', { ns: 'login' })}
</ThemedText>
)}
</TouchableOpacity>
{/* 注册链接 */}
<View className="flex-row justify-center mt-2">
<ThemedText className="!text-textPrimary text-sm">
{t('auth.login.signUpMessage', { ns: 'login' })}
</ThemedText>
<TouchableOpacity onPress={handleSignUp}>
<ThemedText className="!text-[#E2793F] text-sm font-semibold ml-1">
{t('auth.login.signUp', { ns: 'login' })}
</ThemedText>
</TouchableOpacity>
</View>
</View>
}
export default Login

View File

@ -0,0 +1,85 @@
import { fetchApi } from "@/lib/server-api-util";
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { ActivityIndicator, TextInput, TouchableOpacity, View } from "react-native";
import { ThemedText } from "../ThemedText";
import { Steps } from "./phoneLogin";
interface LoginProps {
setSteps: (steps: Steps) => void;
setPhone: (phone: string) => void;
phone: string;
}
const Phone = ({ setSteps, setPhone, phone }: LoginProps) => {
const { t } = useTranslation();
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string>('');
// 发送验证码
const sendVerificationCode = async () => {
if (!/^1[3-9]\d{9}$/.test(phone)) {
setError(t("auth.telLogin.phoneInvalid", { ns: 'login' }));
return;
}
try {
setIsLoading(true);
// 发送验证码
await fetchApi(`/iam/veritification-code`, {
method: 'POST',
body: JSON.stringify({ phone: phone }),
})
setSteps('code')
setIsLoading(false);
} catch (error) {
setPhone("")
setIsLoading(false);
console.error(t("auth.telLogin.sendCodeError", { ns: 'login' }), error);
}
};
return <View>
{/* 手机号输入框 */}
<View className="mb-5">
<View className="w-full flex flex-row justify-between">
<ThemedText className="text-base !text-textPrimary mb-2 ml-2">
{t('auth.telLogin.title', { ns: 'login' })}
</ThemedText>
<ThemedText className="text-sm !text-textPrimary mb-2 ml-2">
{error}
</ThemedText>
</View>
<TextInput
className="border border-gray-300 rounded-2xl p-3 text-base bg-inputBackground"
placeholder={t('auth.telLogin.phoneRequired', { ns: 'login' })}
placeholderTextColor="#ccc"
value={phone}
onChangeText={(text) => {
setPhone(text);
setError('');
}}
keyboardType="email-address"
autoCapitalize="none"
/>
</View>
{/* 发送验证码 */}
<TouchableOpacity
className={`w-full bg-[#E2793F] rounded-full text-[#fff] p-4 items-center mb-6 ${isLoading ? 'opacity-70' : ''}`}
onPress={sendVerificationCode}
disabled={isLoading}
>
{isLoading ? (
<ActivityIndicator color="#fff" />
) : (
<ThemedText className="!text-white font-semibold">
{t('auth.telLogin.sendCode', { ns: 'login' })}
</ThemedText>
)}
</TouchableOpacity>
</View>
}
export default Phone

View File

@ -0,0 +1,20 @@
import { useState } from "react";
import { View } from "react-native";
import Code from "./code";
import Phone from "./phone";
export type Steps = "phone" | "code";
const PhoneLogin = () => {
const [steps, setSteps] = useState<Steps>("phone");
const [phone, setPhone] = useState('');
return <View>
{
steps === "phone" ? <Phone setSteps={setSteps} setPhone={setPhone} phone={phone} /> : <Code setSteps={setSteps} phone={phone} />
}
</View>
}
export default PhoneLogin

325
components/login/signUp.tsx Normal file
View File

@ -0,0 +1,325 @@
import { Ionicons } from "@expo/vector-icons";
import { useLocalSearchParams, useRouter } from "expo-router";
import { useEffect, useState } from 'react';
import { useTranslation } from "react-i18next";
import { ActivityIndicator, TextInput, TouchableOpacity, View } from 'react-native';
import { useAuth } from "../../contexts/auth-context";
import { fetchApi } from "../../lib/server-api-util";
import { User } from "../../types/user";
import { ThemedText } from "../ThemedText";
interface LoginProps {
updateUrlParam: (status: string, value: string) => void;
setError: (error: string) => void;
setShowPassword: (showPassword: boolean) => void;
showPassword: boolean;
}
const SignUp = ({ updateUrlParam, setError, setShowPassword, showPassword }: LoginProps) => {
const { t } = useTranslation();
const { login } = useAuth();
const router = useRouter();
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [confirmPassword, setConfirmPassword] = useState('');
const [passwordsMatch, setPasswordsMatch] = useState(true);
const [loading, setLoading] = useState(false);
const [checked, setChecked] = useState(false);
// 从 URL 参数中获取 task_id 和 steps
const params = useLocalSearchParams<{ task_id?: string; steps?: string }>();
const taskId = params.task_id;
const steps = params.steps;
const handlePasswordChange = (value: string) => {
setPassword(value);
// 当密码或确认密码变化时,检查是否匹配
if (confirmPassword && value !== confirmPassword) {
setPasswordsMatch(false);
} else {
setPasswordsMatch(true);
}
};
const handleConfirmPasswordChange = (value: string) => {
setConfirmPassword(value);
// 当密码或确认密码变化时,检查是否匹配
if (password && value !== password) {
setPasswordsMatch(false);
} else {
setPasswordsMatch(true);
}
};
const handleSubmit = async () => {
if (!email) {
setError(t('auth.signup.emailPlaceholder', { ns: 'login' }));
return;
}
if (!password) {
setError(t('auth.signup.passwordPlaceholder', { ns: 'login' }));
return;
}
// 验证两次密码是否一致
if (password !== confirmPassword) {
setPasswordsMatch(false);
setError(t('auth.signup.passwordNotMatch', { ns: 'login' }));
return;
}
if (!checked) {
setError(t('auth.signup.checkedRequired', { ns: 'login' }));
return;
}
if (email) {
// 校验是否符合邮箱规范
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
if (!emailRegex.test(email)) {
setError(t('auth.signup.emailAuth', { ns: 'login' }));
return;
}
}
if (password) {
// 校验密码是否符合规范
const passwordRegex = /^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[@$!%*?&])[A-Za-z\d@$!%*?&]{8,}$/;
if (!passwordRegex.test(password)) {
setError(t('auth.signup.passwordAuth', { ns: 'login' }));
return;
}
}
setLoading(true);
try {
const body = {
email: email,
password: password
};
// 这里调用实际的注册API
const response = await fetchApi<User>('/iam/register/email', {
method: 'POST',
body: JSON.stringify(body),
headers: {
'Content-Type': 'application/json'
}
}, true, false);
// 存储token
login(response as User, response.access_token || '');
// 如果有任务ID跳转到上传页面
if (taskId) {
// 使用字符串路径格式传递参数
// router.push(`/upload?steps=${encodeURIComponent(steps || '')}&task_id=${encodeURIComponent(taskId)}`);
} else {
// 注册成功后跳转到首页
router.replace('/user-message');
}
setLoading(false);
} catch (error) {
console.error('Registration failed:', error);
// 这里可以添加错误处理逻辑
setLoading(false);
}
};
useEffect(() => {
if (!passwordsMatch) {
setError(t('auth.login.passwordNotMatch', { ns: 'login' }));
}
}, [passwordsMatch])
// 初始化
useEffect(() => {
setShowPassword(false)
}, [])
return <View className="w-full">
{/* 邮箱输入 */}
<View className="mb-4">
<ThemedText className="text-base !text-textPrimary mb-2 ml-2">
{t('auth.login.email', { ns: 'login' })}
</ThemedText>
<View className="border border-gray-300 rounded-2xl bg-inputBackground overflow-hidden">
<TextInput
className="p-3 text-base flex-1"
placeholder={t('auth.login.accountPlaceholder', { ns: 'login' })}
placeholderTextColor="#ccc"
value={email}
onChangeText={(value) => {
setEmail(value)
setError('123')
}}
keyboardType="email-address"
autoCapitalize="none"
/>
</View>
</View>
{/* 密码输入 */}
<View className="mb-4">
<ThemedText className="text-base !text-textPrimary mb-2 ml-2">
{t('auth.login.password', { ns: 'login' })}
</ThemedText>
<View className="border border-gray-300 rounded-2xl bg-inputBackground overflow-hidden flex-row items-center">
<TextInput
className="p-3 text-base flex-1"
placeholder={t('auth.login.passwordPlaceholder', { ns: 'login' })}
placeholderTextColor="#ccc"
value={password}
onChangeText={(value) => {
handlePasswordChange(value)
setError('123')
}}
secureTextEntry={!showPassword}
/>
<TouchableOpacity
onPress={() => setShowPassword(!showPassword)}
className="px-3 py-2"
>
<Ionicons
name={showPassword ? 'eye' : 'eye-off'}
size={20}
color="#666"
/>
</TouchableOpacity>
</View>
</View>
{/* 确认密码 */}
<View className="mb-6">
<ThemedText className="text-base !text-textPrimary mb-2 ml-2">
{t('auth.signup.confirmPassword', { ns: 'login' })}
</ThemedText>
<View className="border border-gray-300 rounded-2xl bg-inputBackground overflow-hidden flex-row items-center">
<TextInput
className="p-3 text-base flex-1"
placeholder={t('auth.signup.confirmPasswordPlaceholder', { ns: 'login' })}
placeholderTextColor="#ccc"
value={confirmPassword}
onChangeText={(value) => {
handleConfirmPasswordChange(value)
setError('123')
}}
secureTextEntry={!showPassword}
/>
<TouchableOpacity
onPress={() => setShowPassword(!showPassword)}
className="px-3 py-2"
>
<Ionicons
name={showPassword ? 'eye' : 'eye-off'}
size={20}
color="#666"
/>
</TouchableOpacity>
</View>
</View>
{/* 注册按钮 */}
<TouchableOpacity
className={`w-full bg-[#E2793F] rounded-full p-4 items-center ${loading ? 'opacity-70' : ''}`}
onPress={handleSubmit}
disabled={loading}
>
{loading ? (
<ActivityIndicator color="#fff" />
) : (
<ThemedText className="!text-white font-semibold">
{t("auth.signup.signupButton", { ns: 'login' })}
</ThemedText>
)}
</TouchableOpacity>
<View style={{ flexDirection: 'row', alignItems: 'center', marginVertical: 10 }}>
<TouchableOpacity
onPress={() => {
const newValue = !checked;
setChecked(newValue);
if (!newValue) {
setError(t('auth.signup.checkedRequired', { ns: 'login' }));
return
} else {
setError("123")
}
}}
style={{
width: 20,
height: 20,
borderRadius: 10,
borderWidth: 2,
borderColor: checked ? '#E2793F' : '#ccc',
backgroundColor: checked ? '#E2793F' : 'transparent',
justifyContent: 'center',
alignItems: 'center',
marginRight: 8,
}}
>
{checked && (
<Ionicons name="checkmark" size={14} color="white" />
)}
</TouchableOpacity>
<View style={{ flexDirection: 'row', flexWrap: 'wrap', flex: 1 }}>
<ThemedText className="text-sm !text-textPrimary">
{t("auth.telLogin.agree", { ns: 'login' })}
</ThemedText>
<TouchableOpacity onPress={() => router.push({
pathname: '/agreement',
params: { type: 'service' }
} as any)}>
<ThemedText className="text-sm !text-[#E2793F]">
{t("auth.telLogin.terms", { ns: 'login' })}
</ThemedText>
</TouchableOpacity>
<ThemedText className="text-sm !text-textPrimary">
{t("auth.telLogin.and", { ns: 'login' })}
</ThemedText>
<TouchableOpacity onPress={() => router.push({
pathname: '/agreement',
params: { type: 'privacy' }
} as any)}>
<ThemedText className="text-sm !text-[#E2793F]">
{t("auth.telLogin.privacyPolicy", { ns: 'login' })}
</ThemedText>
</TouchableOpacity>
<ThemedText className="text-sm !text-textPrimary">
{t("auth.telLogin.and", { ns: 'login' })}
</ThemedText>
<TouchableOpacity onPress={() => router.push({
pathname: '/agreement',
params: { type: 'user' }
} as any)}>
<ThemedText className="text-sm !text-[#E2793F]">
{t("auth.telLogin.userAgreement", { ns: 'login' })}
</ThemedText>
</TouchableOpacity>
<ThemedText className="text-sm !text-textPrimary">
{t("auth.telLogin.agreement", { ns: 'login' })}
</ThemedText>
<ThemedText className="text-sm !text-[#E2793F]">
{t("common.name")}
</ThemedText>
<ThemedText className="text-sm !text-textPrimary">
{t("auth.telLogin.getPhone", { ns: 'login' })}
</ThemedText>
</View>
</View>
{/* 已有账号 */}
<View className="flex-row justify-center mt-6">
<ThemedText className="text-sm !text-textPrimary">
{t("auth.signup.haveAccount", { ns: 'login' })}
</ThemedText>
<TouchableOpacity
onPress={() => {
updateUrlParam("status", "login");
}}
>
<ThemedText className="!text-[#E2793F] text-sm font-semibold ml-1">
{t("auth.signup.login", { ns: 'login' })}
</ThemedText>
</TouchableOpacity>
</View>
</View>
}
export default SignUp

23
components/svg-icon.tsx Normal file
View File

@ -0,0 +1,23 @@
export default function svgIcon({
name,
prefix = 'icon',
color = '#333',
width = '16px',
height = '16px',
...props
}: {
name: string;
prefix?: string;
color?: string;
width?: string;
height?: string;
[key: string]: any;
}) {
const symbolId = `#${prefix}-${name}`
return (
<svg {...props} aria-hidden="true" style={{ width, height }}>
<use href={symbolId} fill={color} />
</svg>
)
}

View File

@ -0,0 +1,127 @@
import { Steps } from '@/app/(tabs)/user-message';
import ChoiceSvg from '@/assets/icons/svg/choice.svg';
import { ThemedText } from '@/components/ThemedText';
import { useState } from 'react';
import { TouchableOpacity, View } from 'react-native';
interface Props {
setSteps: (steps: Steps) => void;
}
type ChoiceOption = {
id: string;
emoji: string;
label: string;
description: string;
};
export default function Choice({ setSteps }: Props) {
const [selectedOption, setSelectedOption] = useState<string[]>([]);
const [isLoading, setIsLoading] = useState(false);
const handleContinue = () => {
if (!selectedOption) return;
setIsLoading(true);
// Simulate API call
setTimeout(() => {
setIsLoading(false);
setSteps('done');
}, 500);
};
const options: ChoiceOption[] = [
{
id: 'wakeup',
emoji: '⏰',
label: 'Wake Up',
description: 'Start your day right with a gentle wake-up'
},
{
id: 'sleep',
emoji: '😴',
label: 'Sleep',
description: 'Drift off with calming sounds'
},
{
id: 'focus',
emoji: '🎯',
label: 'Focus',
description: 'Enhance your concentration'
},
{
id: 'relax',
emoji: '🧘',
label: 'Relax',
description: 'Unwind and de-stress'
},
{
id: 'relax1',
emoji: '🧘',
label: 'Relax1',
description: 'Unwind and de-stress'
},
{
id: 'relax11',
emoji: '🧘',
label: 'Relax11',
description: 'Unwind and de-stress'
},
];
return (
<View className="h-full bg-textPrimary overflow-y-auto">
{/* Fixed Header */}
<View className="p-[2rem] pb-0">
<View className="items-center flex flex-col gap-[2rem]">
<ChoiceSvg />
<ThemedText className="text-3xl !text-white text-center font-semibold">
Every memory matters.
</ThemedText>
<ThemedText className="text-base !text-white text-center">
Select a few to help Memo create better video capsules for you
</ThemedText>
</View>
</View>
{/* Scrollable Content */}
<View className="flex-1">
<View className="p-[2rem] pb-0">
<View className="gap-4">
{options.map((option) => (
<TouchableOpacity
key={option.id}
className={`p-[1rem] rounded-full bg-[#FFF8DE] border-2 ${selectedOption.includes(option.id) ? 'border-bgPrimary' : 'border-transparent'} w-full flex items-center justify-center`}
onPress={() => {
// 如果存在则删除,没有则添加,多选
if (selectedOption.includes(option.id)) {
// 剔除
setSelectedOption((prev) => prev.filter((id) => id !== option.id));
} else {
// 添加
setSelectedOption((prev) => ([...prev, option.id]));
}
}}
>
<ThemedText className="text-lg font-semibold !text-textTertiary text-center">
{option.label}
</ThemedText>
</TouchableOpacity>
))}
{/* Next Button */}
<View className="mt-8 mb-4">
<TouchableOpacity
className={`w-full bg-white rounded-full p-4 items-center ${isLoading ? 'opacity-70' : ''}`}
onPress={handleContinue}
disabled={isLoading}
>
<ThemedText className="text-textTertiary text-lg font-semibold">
Next
</ThemedText>
</TouchableOpacity>
</View>
</View>
</View>
</View>
</View>
);
}

View File

@ -0,0 +1,38 @@
import { Steps } from '@/app/(tabs)/user-message';
import DoneSvg from '@/assets/icons/svg/done.svg';
import { useTranslation } from 'react-i18next';
import { TouchableOpacity, View } from 'react-native';
import { ThemedText } from '../ThemedText';
interface Props {
setSteps: (steps: Steps) => void;
}
export default function Done(props: Props) {
const { setSteps } = props
const { t } = useTranslation();
const handleContinue = () => {
};
return (
<View className="flex-1 bg-bgPrimary absolute top-0 left-0 right-0 bottom-0 h-full">
<View className="absolute top-[2rem] left-0 right-0 bottom-[10rem] justify-center items-center">
<ThemedText className="text-4xl !text-white text-center">
{t('auth.userMessage.allDone', { ns: 'login' })}
</ThemedText>
</View>
<View className='flex-1' />
<DoneSvg />
{/* Next Button */}
<View className="w-full mt-8 mb-4 absolute bottom-[0.5rem] p-[1rem]">
<TouchableOpacity
className={`w-full bg-buttonFill rounded-full p-4 items-center`}
onPress={handleContinue}
>
<ThemedText className="text-textTertiary text-lg font-semibold">
{t('auth.userMessage.next', { ns: 'login' })}
</ThemedText>
</TouchableOpacity>
</View>
</View>
)
}

View File

@ -0,0 +1,162 @@
import { Steps } from '@/app/(tabs)/user-message';
import AtaverSvg from '@/assets/icons/svg/ataver.svg';
import ChoicePhotoSvg from '@/assets/icons/svg/choicePhoto.svg';
import { ThemedText } from '@/components/ThemedText';
import { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Image, Modal, SafeAreaView, StyleSheet, TouchableOpacity, TouchableWithoutFeedback, View } from 'react-native';
import { SafeAreaProvider } from 'react-native-safe-area-context';
import FileUploader, { FileStatus } from '../file-upload/file-uploader';
interface Props {
setSteps?: (steps: Steps) => void;
fileData: FileStatus[];
setFileData: (fileData: FileStatus[]) => void;
isLoading: boolean;
handleUser: () => void;
avatar: string;
}
export default function Look({ fileData, setFileData, isLoading, handleUser, avatar }: Props) {
const [isModalVisible, setIsModalVisible] = useState(false);
const { t } = useTranslation();
return (
<View className="flex-1 bg-textPrimary justify-between p-[2rem]">
<View className="flex-1 justify-center items-center">
<View className="w-full items-center">
<ThemedText className="text-4xl font-bold !text-white mb-[2rem]">
{t('auth.userMessage.look', { ns: 'login' })}
</ThemedText>
<ThemedText className="text-base !text-white/80 text-center mb-[5rem]">
{t('auth.userMessage.avatarText', { ns: 'login' })}
<br />
{t('auth.userMessage.avatorText2', { ns: 'login' })}
</ThemedText>
<View className="rounded-full bg-white/10 items-center justify-center mb-[3rem]">
Review

一起看看

一起看看
{
(() => {
const imageSource = fileData?.[0]?.thumbnailUrl || avatar;
if (!imageSource) {
return <AtaverSvg />;
}
return (
<Image
style={styles.image}
source={{ uri: imageSource }}
resizeMode="cover"
/>
);
})()
}
</View>
<TouchableOpacity
className={`!bg-[#FFF8DE] rounded-2xl p-4 items-center flex flex-row gap-[1rem]`}
disabled={isLoading}
onPress={() => { setIsModalVisible(true) }}
>
<ChoicePhotoSvg />
<ThemedText className="text-lg font-semibold ">
{t('auth.userMessage.choosePhoto', { ns: 'login' })}
</ThemedText>
{/* 上传弹窗 */}
<SafeAreaProvider>
<SafeAreaView style={styles.centeredView}>
<Modal
animationType="fade"
transparent={true}
visible={isModalVisible}
onRequestClose={() => {
setIsModalVisible(false);
}}
>
<View style={styles.modalOverlay}>
<TouchableWithoutFeedback onPress={() => setIsModalVisible(false)}>
<View style={styles.modalOverlayTouchable} />
</TouchableWithoutFeedback>
<View style={styles.modalContent}>
<ThemedText style={styles.modalTitle}>{t('auth.userMessage.choosePhoto', { ns: 'login' })}</ThemedText>
<FileUploader
allowMultiple={false}
maxFiles={1}
onFilesUploaded={(file: FileStatus[]) => {
setFileData(file);
setIsModalVisible(false);
}}
allowedFileTypes={["image/png", "image/jpeg", "image/webp"]}
maxFileSize={1024 * 1024 * 10}
className="w-full"
/>
</View>
</View>
</Modal>
</SafeAreaView>
</SafeAreaProvider>
</TouchableOpacity>
</View>
</View>
{/* Continue Button */}
<View className="p-6 w-full">
<TouchableOpacity
className={`w-full bg-white rounded-full p-4 items-center ${isLoading ? 'opacity-70' : ''
}`}
onPress={handleUser}
disabled={isLoading}
>
<ThemedText className="text-textTertiary text-lg font-semibold">
{t('auth.userMessage.next', { ns: 'login' })}
</ThemedText>
</TouchableOpacity>
</View>
</View>
);
}
const styles = StyleSheet.create({
image: {
width: 215,
height: 215,
borderRadius: 107.5,
},
centeredView: {
flex: 1,
},
modalOverlay: {
flex: 1,
justifyContent: 'center',
alignItems: 'center',
backgroundColor: 'rgba(0, 0, 0, 0.5)',
},
modalOverlayTouchable: {
position: 'absolute',
top: 0,
left: 0,
right: 0,
bottom: 0,
},
modalContent: {
width: '90%',
maxWidth: 400,
backgroundColor: 'white',
borderRadius: 12,
padding: 20,
shadowColor: '#000',
shadowOffset: {
width: 0,
height: 2,
},
shadowOpacity: 0.25,
shadowRadius: 4,
elevation: 5,
},
modalTitle: {
fontSize: 18,
fontWeight: 'bold',
marginBottom: 15,
textAlign: 'center',
},
// ... 其他样式保持不变
});

View File

@ -0,0 +1,65 @@
import { Steps } from '@/app/(tabs)/user-message';
import { ThemedText } from '@/components/ThemedText';
import { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { ActivityIndicator, Platform, TextInput, TouchableOpacity, View } from 'react-native';
import Toast from 'react-native-toast-message';
interface Props {
setSteps: (steps: Steps) => void;
username: string;
setUsername: (username: string) => void;
}
export default function UserName(props: Props) {
const { setSteps, username, setUsername } = props
const { t } = useTranslation();
const [isLoading, setIsLoading] = useState(false)
const handleUserName = () => {
if (!username) {
if (Platform.OS === 'web') {
Toast.show({
type: 'error',
text1: 'Username is required'
});
}
return;
}
setIsLoading(true)
setSteps("look")
setIsLoading(false)
}
return (
<View className='bg-bgPrimary flex-1 h-full'>
<View className="flex-1" />
{/* Input container fixed at bottom */}
<View className="w-full bg-white p-4 border-t border-gray-200 rounded-t-3xl">
<View className="flex-col items-center justify-center w-full gap-[3rem]">
<ThemedText className="text-textSecondary font-semibold">{t('auth.userMessage.title', { ns: 'login' })}</ThemedText>
<View className='w-full'>
<ThemedText className="!text-textPrimary ml-2 mb-2 font-semibold">{t('auth.userMessage.username', { ns: 'login' })}</ThemedText>
<TextInput
className="flex-1 bg-inputBackground rounded-2xl px-4 py-3 w-full"
placeholder={t('auth.userMessage.usernamePlaceholder', { ns: 'login' })}
placeholderTextColor="#9CA3AF"
value={username}
onChangeText={setUsername}
/>
</View>
<TouchableOpacity
className={`w-full bg-[#E2793F] rounded-full text-[#fff] p-4 items-center mb-6 ${isLoading ? 'opacity-70' : ''} rounded-2xl`}
onPress={handleUserName}
disabled={isLoading}
>
{isLoading ? (
<ActivityIndicator color="#fff" />
) : (
<ThemedText className="!text-white font-semibold">
{t('auth.userMessage.next', { ns: 'login' })}
</ThemedText>
)}
</TouchableOpacity>
</View>
</View>
</View>
)
}

View File

@ -3,7 +3,9 @@ import { EVENT_TYPES, eventEmitter } from '@/lib/event-util';
import { fetchApi, refreshAuthToken } from '@/lib/server-api-util'; import { fetchApi, refreshAuthToken } from '@/lib/server-api-util';
import { store } from '@/store'; import { store } from '@/store';
import { User } from '@/types/user'; import { User } from '@/types/user';
import * as SecureStore from 'expo-secure-store';
import React, { createContext, ReactNode, useContext, useEffect } from 'react'; import React, { createContext, ReactNode, useContext, useEffect } from 'react';
import { Platform } from 'react-native';
import { useDispatch, useSelector } from 'react-redux'; import { useDispatch, useSelector } from 'react-redux';
interface AuthContextType { interface AuthContextType {
@ -25,7 +27,15 @@ export const AuthProvider: React.FC<{ children: ReactNode }> = ({ children }) =>
useEffect(() => { useEffect(() => {
// 检查 Redux store 中是否已有 token // 检查 Redux store 中是否已有 token
const refreshTokenAction = async () => { const refreshTokenAction = async () => {
const token = store.getState().auth.token; let token = store.getState().auth.token;
if (Platform.OS === 'web') {
token = localStorage.getItem('token') || "";
} else {
await SecureStore.getItemAsync('token').then((token) => {
token = token || "";
})
}
if (token) { if (token) {
// 验证当前 token 是否有效 // 验证当前 token 是否有效
fetchApi('/user/identity-check', {}, false) fetchApi('/user/identity-check', {}, false)
@ -67,6 +77,15 @@ export const AuthProvider: React.FC<{ children: ReactNode }> = ({ children }) =>
token: newJwt token: newJwt
})); }));
// 判断运行环境是web则存在localstorage或者expo-secure-store中
if (Platform.OS === 'web') {
localStorage.setItem('user', JSON.stringify(newUser));
localStorage.setItem('token', newJwt);
} else {
SecureStore.setItemAsync('user', JSON.stringify(newUser));
SecureStore.setItemAsync('token', newJwt);
}
// 触发事件通知 // 触发事件通知
eventEmitter.emit(EVENT_TYPES.USER_INFO_UPDATED, newUser); eventEmitter.emit(EVENT_TYPES.USER_INFO_UPDATED, newUser);
}; };

View File

@ -1,12 +1,14 @@
import { createSlice, PayloadAction } from '@reduxjs/toolkit';
import { User } from '@/types/user'; import { User } from '@/types/user';
import { createSlice, PayloadAction } from '@reduxjs/toolkit';
import * as SecureStore from 'expo-secure-store';
import { Platform } from 'react-native';
interface AuthState { interface AuthState {
user: User | null; user: User | null;
token: string | null; token: string | null;
isAuthenticated: boolean; isAuthenticated: boolean;
task_id: string | null; task_id: string | null;
url: string | null; url: string | null;
refresh_token: string | null;
} }
const initialState: AuthState = { const initialState: AuthState = {
@ -14,7 +16,8 @@ const initialState: AuthState = {
token: null, token: null,
isAuthenticated: false, isAuthenticated: false,
task_id: null, task_id: null,
url: null url: null,
refresh_token: null
}; };
export const authSlice = createSlice({ export const authSlice = createSlice({
@ -34,6 +37,13 @@ export const authSlice = createSlice({
state.user = null; state.user = null;
state.token = null; state.token = null;
state.isAuthenticated = false; state.isAuthenticated = false;
if (Platform.OS === 'web') {
localStorage.setItem('user', "");
localStorage.setItem('token', "");
} else {
SecureStore.setItemAsync('user', "");
SecureStore.setItemAsync('token', "");
}
}, },
setGuestTaskData: (state, action: PayloadAction<{ task_id: string; url: string }>) => { setGuestTaskData: (state, action: PayloadAction<{ task_id: string; url: string }>) => {
state.task_id = action.payload.task_id; state.task_id = action.payload.task_id;

View File

@ -1,28 +1,58 @@
{"auth": { {
"telLogin": { "auth": {
"sendCode": "send code", "welcomeAwaken": {
"login": "Login", "hi": "Hi!",
"codePlaceholder": "Enter verification code", "memo": "I'm Memo",
"sending": "Sending...", "awaken": "Awaken",
"codeSeconds": "seconds", "your": "Your",
"agree": "I agree to the ", "pm": "precious memories",
"terms": "Terms of Service", "slogan": "let every moment speak and feel alive",
"and": " and ", "gallery": "I live deep inside your photo gallery, ",
"privacyPolicy": "Privacy Policy", "back": "waiting for you to bring me back...",
"userAgreement": "User Agreement", "awake": "Awake your Memo"
"agreement": " and authorize ", },
"getPhone": " to obtain my phone number", "userMessage": {
"codeError": "Failed to send verification code, please try again", "title": "Choose Username",
"phoneRequired": "Please enter your phone number", "username": "Username",
"phoneInvalid": "Please enter a valid phone number", "usernamePlaceholder": "Enter your username",
"codeRequired": "Please enter the verification code", "next": "Next",
"codeInvalid": "Invalid verification code format", "look": "Choose Your Look",
"checkedRequired": "Please agree to the terms", "avatarText": "Choose an avatar to begin your journey",
"loginError": "Login failed, please try again" "avatorText2": "You can always change it later",
}, "choosePhoto": "Choose Photo",
"allDone": "All Done!"
},
"telLogin": {
"title": "Verify Your Identity",
"secondTitle": "Weve sent an email with your code to:",
"sendCode": "send code",
"continue": " Continue",
"login": "Login",
"codePlaceholder": "Enter verification code",
"sending": "Sending...",
"codeSeconds": "seconds",
"agree": "I agree to the ",
"terms": "Terms of Service",
"and": " and ",
"privacyPolicy": "Privacy Policy",
"userAgreement": "User Agreement",
"agreement": " and authorize ",
"getPhone": " to obtain my phone number",
"codeError": "Failed to send verification code, please try again",
"phoneRequired": "Please enter your phone number",
"phoneInvalid": "Please enter a valid phone number",
"codeRequired": "Please enter the verification code",
"codeInvalid": "Invalid verification code format",
"checkedRequired": "Please agree to the terms",
"loginError": "Login failed, please try again",
"codeVaild": "The code you entered is invalid",
"sendAgain": "Didnt receive a code?",
"resend": "Resend",
"goBack": "Go Back"
},
"login": { "login": {
"title": "Log in", "title": "Log in",
"email": "Email", "email": "Email Address",
"account": "Account", "account": "Account",
"password": "Password", "password": "Password",
"emailPlaceholder": "Enter your email", "emailPlaceholder": "Enter your email",
@ -34,13 +64,15 @@
"accountPlaceholder": "Enter your account or email", "accountPlaceholder": "Enter your account or email",
"signUpMessage": "Dont have an account?", "signUpMessage": "Dont have an account?",
"signUp": "Sign up", "signUp": "Sign up",
"phoneLogin":"Phone Login" "phoneLogin": "Phone Login",
"passwordNotMatch": "Passwords do not match"
}, },
"agree": { "agree": {
"text": "By signing up, you agree to our", "logintext": "By logging in, you agree to our",
"singupText": "By signing up, you agree to our",
"terms": " Terms", "terms": " Terms",
"join": "&", "join": "&",
"privacyPolicy": " Privacy Policy." "privacyPolicy": " Privacy Policy."
}, },
"welcome": { "welcome": {
"welcome": "Welcome to MemoWake", "welcome": "Welcome to MemoWake",
@ -52,8 +84,8 @@
"emailPlaceholder": "Enter your email", "emailPlaceholder": "Enter your email",
"sendEmailBtn": "Send email", "sendEmailBtn": "Send email",
"goback": "Go back", "goback": "Go back",
"success":"Email sent successfully, please check your email", "success": "Email sent successfully, please check your email",
"sendEmailBtnDisabled": "Email sent" "sendEmailBtnDisabled": "Email sent"
}, },
"resetPwd": { "resetPwd": {
"title": "Reset password", "title": "Reset password",
@ -83,7 +115,10 @@
"verifyCodePlaceholder": "Enter 6-digit code", "verifyCodePlaceholder": "Enter 6-digit code",
"sendCode": "Send Code", "sendCode": "Send Code",
"resendCode": "Resend", "resendCode": "Resend",
"codeExpireTime": "Code will expire in" "codeExpireTime": "Code will expire in",
"checkedRequired": "Please agree to the terms",
"emailAuth": "Please enter a valid email address",
"passwordAuth": "Please enter a valid password"
} }
} }
} }

View File

@ -1,26 +1,55 @@
{ {
"auth": { "auth": {
"telLogin":{ "welcomeAwaken": {
"sendCode":"获取验证码", "hi": "Hi!",
"login":"登录", "memo": "I'm Memo",
"codePlaceholder":"请输入验证码", "awaken": "Awaken",
"sending":"发送中...", "your": "Your",
"codeSeconds":"秒", "pm": "precious memories",
"agree":"我已同意", "slogan": "let every moment speak and feel alive",
"terms":"《服务条款》", "gallery": "I live deep inside your photo gallery, ",
"and":"和", "back": "waiting for you to bring me back...",
"privacyPolicy":"《隐私政策》", "awake": "Awake your Memo"
"userAgreement":"《用户协议》", },
"agreement":"并授权", "userMessage": {
"getPhone":"获得本机号码", "title": "设置用户名",
"codeError":"发送验证码失败,请重试", "username": "用户名",
"phoneRequired":"请输入手机号", "usernamePlaceholder": "请输入您的用户名",
"phoneInvalid":"请输入正确的手机号", "next": "下一步",
"codeRequired":"请输入验证码", "look": "选择您的头像",
"codeInvalid":"验证码格式不正确", "avatarText": "选择一个头像开始您的旅程",
"checkedRequired":"请勾选协议", "avatorText2": "您可以随时更改",
"loginError":"登录失败,请重试" "choosePhoto": "选择照片",
}, "allDone": "完成!"
},
"telLogin": {
"title": "验证身份",
"secondTitle": "我们已发送验证码至:",
"sendCode": "发送验证码",
"continue": "继续",
"login": "登录",
"codePlaceholder": "输入验证码",
"sending": "发送中...",
"codeSeconds": "秒",
"agree": "我已同意",
"terms": "《服务条款》",
"and": "和",
"privacyPolicy": "《隐私政策》",
"userAgreement": "《用户协议》",
"agreement": "并授权",
"getPhone": "获取本机号码",
"codeError": "验证码发送失败,请重试",
"phoneRequired": "请输入手机号",
"phoneInvalid": "请输入有效的手机号",
"codeRequired": "请输入验证码",
"codeInvalid": "验证码格式不正确",
"checkedRequired": "请同意相关条款",
"loginError": "登录失败,请重试",
"codeValid": "您输入的验证码无效",
"sendAgain": "没有收到验证码?",
"resend": "重新发送",
"goBack": "返回"
},
"login": { "login": {
"title": "登录", "title": "登录",
"account": "账号", "account": "账号",
@ -35,10 +64,12 @@
"accountPlaceholder": "请输入您的账号或邮箱", "accountPlaceholder": "请输入您的账号或邮箱",
"signUpMessage": "还没有账号?", "signUpMessage": "还没有账号?",
"signUp": "注册", "signUp": "注册",
"phoneLogin":"手机号登录" "phoneLogin": "手机号登录",
"passwordNotMatch": "密码不一致"
}, },
"agree": { "agree": {
"text": "注册即表示您同意我们的", "logintext": "登录即表示您同意我们的",
"singupText": "注册即表示您同意我们的",
"terms": "服务条款", "terms": "服务条款",
"join": "&", "join": "&",
"privacyPolicy": "隐私政策" "privacyPolicy": "隐私政策"
@ -62,7 +93,7 @@
"signupButton": "注册", "signupButton": "注册",
"resetButton": "重置", "resetButton": "重置",
"goback": "返回登录", "goback": "返回登录",
"success":"邮件已发送,请注意查收" "success": "邮件已发送,请注意查收"
}, },
"signup": { "signup": {
"title": "注册", "title": "注册",
@ -85,7 +116,10 @@
"verifyCodePlaceholder": "请输入6位验证码", "verifyCodePlaceholder": "请输入6位验证码",
"sendCode": "发送验证码", "sendCode": "发送验证码",
"resendCode": "重新发送", "resendCode": "重新发送",
"codeExpireTime": "验证码将在以下时间后过期" "codeExpireTime": "验证码将在以下时间后过期",
"checkedRequired": "请勾选协议",
"emailAuth": "请输入一个有效的邮箱地址",
"passwordAuth": "请输入一个有效的密码"
} }
} }
} }

View File

@ -1,7 +1,11 @@
import { useAuth } from '@/contexts/auth-context';
import { setCredentials } from '@/features/auth/authSlice'; import { setCredentials } from '@/features/auth/authSlice';
import { store } from '@/store'; import * as SecureStore from 'expo-secure-store';
import { User } from '@/types/user'; import { Platform } from 'react-native';
import Toast from 'react-native-toast-message';
import { useAuth } from '../contexts/auth-context';
import { store } from '../store';
import { User } from '../types/user';
// 定义错误码常量 // 定义错误码常量
const ERROR_CODES = { const ERROR_CODES = {
UNAUTHORIZED: 1004010001, UNAUTHORIZED: 1004010001,
@ -30,7 +34,12 @@ export const useAuthToken = async<T>(message: string | null) => {
// 如果接口报错,页面弹出来错误信息 // 如果接口报错,页面弹出来错误信息
if (apiResponse.code != 0) { if (apiResponse.code != 0) {
console.log(message || 'Unknown error'); if (Platform.OS === 'web') {
Toast.show({
type: 'error',
text1: apiResponse.message || 'Request failed'
});
}
throw new Error(message || 'Unknown error'); throw new Error(message || 'Unknown error');
} else { } else {
const userData = apiResponse.data as User; const userData = apiResponse.data as User;
@ -47,15 +56,32 @@ export const useAuthToken = async<T>(message: string | null) => {
// 使用Redux存储token的刷新token函数 // 使用Redux存储token的刷新token函数
export const refreshAuthToken = async<T>(message: string | null): Promise<User> => { export const refreshAuthToken = async<T>(message: string | null): Promise<User> => {
try { try {
let cookie = "";
let userId = "";
if (Platform.OS === 'web') {
cookie = localStorage.getItem('user') ? JSON.parse(localStorage.getItem('user') || "")?.refresh_token || "" : "";
userId = localStorage.getItem('user') ? JSON.parse(localStorage.getItem('user') || "")?.user_id || "" : "";
} else {
await SecureStore.getItemAsync('user').then((user: User) => {
cookie = user?.refresh_token || "";
userId = user?.user_id || "";
})
}
// 退出刷新会重新填充数据 // 退出刷新会重新填充数据
let response; let response;
response = await fetch(`${API_ENDPOINT}/v1/iam/access-token-refresh`); response = await fetch(`${API_ENDPOINT}/v1/iam/access-token-refresh`, {
method: "POST",
body: JSON.stringify({
"refresh_token": cookie,
"user_id": userId
}),
headers: {
'Content-Type': 'application/json',
}
});
const apiResponse: ApiResponse<T> = await response.json(); const apiResponse: ApiResponse<T> = await response.json();
if (apiResponse.code != 0) { if (apiResponse.code != 0) {
// addToast({
// title: message || 'Unknown error',
// color: "danger",
// })
throw new Error(message || 'Unknown error'); throw new Error(message || 'Unknown error');
} }
@ -89,11 +115,19 @@ const handleApiError = (error: unknown, needToast = true, defaultMessage = 'Unkn
export const fetchApi = async <T>( export const fetchApi = async <T>(
path: string, path: string,
options: RequestInit = {}, options: RequestInit = {},
needToast = true needToast = true,
needToken = true,
): Promise<T> => { ): Promise<T> => {
const makeRequest = async (isRetry = false): Promise<ApiResponse<T>> => { const makeRequest = async (isRetry = false): Promise<ApiResponse<T>> => {
try { try {
const token = store.getState().auth.token; let token = "";
if (Platform.OS === 'web') {
token = localStorage.getItem('token') || "";
} else {
await SecureStore.getItemAsync('token').then((token: string) => {
token = token || "";
})
}
const headers = new Headers(options.headers); const headers = new Headers(options.headers);
// 添加必要的 headers // 添加必要的 headers
@ -101,7 +135,7 @@ export const fetchApi = async <T>(
headers.set('Content-Type', 'application/json'); headers.set('Content-Type', 'application/json');
} }
if (token != null) { if (token != null && needToken) {
headers.set('Authorization', `Bearer ${token}`); headers.set('Authorization', `Bearer ${token}`);
} }
@ -121,6 +155,13 @@ export const fetchApi = async <T>(
// 处理其他错误 // 处理其他错误
if (apiResponse.code !== 0) { if (apiResponse.code !== 0) {
// 如果是web端则显示提示
if (Platform.OS === 'web') {
Toast.show({
type: 'error',
text1: apiResponse.message || 'Request failed'
});
}
throw new Error(apiResponse.message || 'Request failed'); throw new Error(apiResponse.message || 'Request failed');
} }

View File

@ -1,6 +1,23 @@
const { getDefaultConfig } = require("expo/metro-config"); const { getDefaultConfig } = require('expo/metro-config');
const { withNativeWind } = require('nativewind/metro'); const { withNativeWind } = require('nativewind/metro');
const path = require('path');
const config = getDefaultConfig(__dirname) const config = getDefaultConfig(__dirname);
module.exports = withNativeWind(config, { input: './global.css' }) // SVG 转换配置
config.transformer = {
...config.transformer,
babelTransformerPath: require.resolve('react-native-svg-transformer'),
};
config.resolver = {
...config.resolver,
assetExts: config.resolver.assetExts.filter(ext => ext !== 'svg'),
sourceExts: [...config.resolver.sourceExts, 'svg'],
alias: {
...config.resolver?.alias,
'@/': path.resolve(__dirname, './'),
},
};
module.exports = withNativeWind(config, { input: './global.css' });

1158
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -18,6 +18,7 @@
"@react-navigation/elements": "^2.3.8", "@react-navigation/elements": "^2.3.8",
"@react-navigation/native": "^7.1.6", "@react-navigation/native": "^7.1.6",
"@reduxjs/toolkit": "^2.8.2", "@reduxjs/toolkit": "^2.8.2",
"@types/react-redux": "^7.1.34",
"expo": "~53.0.12", "expo": "~53.0.12",
"expo-blur": "~14.1.5", "expo-blur": "~14.1.5",
"expo-constants": "~17.1.6", "expo-constants": "~17.1.6",
@ -28,6 +29,7 @@
"expo-linking": "~7.1.5", "expo-linking": "~7.1.5",
"expo-localization": "^16.1.5", "expo-localization": "^16.1.5",
"expo-router": "~5.1.0", "expo-router": "~5.1.0",
"expo-secure-store": "~14.2.3",
"expo-splash-screen": "~0.30.9", "expo-splash-screen": "~0.30.9",
"expo-status-bar": "~2.2.3", "expo-status-bar": "~2.2.3",
"expo-symbols": "~0.4.5", "expo-symbols": "~0.4.5",
@ -43,11 +45,13 @@
"react-native": "0.79.4", "react-native": "0.79.4",
"react-native-gesture-handler": "~2.24.0", "react-native-gesture-handler": "~2.24.0",
"react-native-reanimated": "~3.17.4", "react-native-reanimated": "~3.17.4",
"react-native-safe-area-context": "^5.4.0", "react-native-safe-area-context": "5.4.0",
"react-native-screens": "~4.11.1", "react-native-screens": "~4.11.1",
"react-native-toast-message": "^2.3.0",
"react-native-web": "~0.20.0", "react-native-web": "~0.20.0",
"react-native-webview": "13.13.5", "react-native-webview": "13.13.5",
"expo-secure-store": "~14.2.3" "react-redux": "^9.2.0",
"react-native-svg": "15.11.2"
}, },
"devDependencies": { "devDependencies": {
"@babel/core": "^7.25.2", "@babel/core": "^7.25.2",
@ -56,10 +60,18 @@
"eslint": "^9.25.0", "eslint": "^9.25.0",
"eslint-config-expo": "~9.2.0", "eslint-config-expo": "~9.2.0",
"prettier-plugin-tailwindcss": "^0.5.14", "prettier-plugin-tailwindcss": "^0.5.14",
"react-native-svg-transformer": "^1.5.1",
"tailwindcss": "^3.4.17", "tailwindcss": "^3.4.17",
"ts-node": "^10.9.2", "ts-node": "^10.9.2",
"tsx": "^4.20.3", "tsx": "^4.20.3",
"typescript": "~5.8.3" "typescript": "~5.8.3"
}, },
"private": true "private": true,
"expo": {
"doctor": {
"reactNativeDirectoryCheck": {
"listUnknownPackages": false
}
}
}
} }

View File

@ -1,21 +1,93 @@
import { I18nextProvider } from "react-i18next"; import { I18nextProvider } from "react-i18next";
import { Platform } from 'react-native';
import Toast, { BaseToast, ErrorToast, ToastConfig } from 'react-native-toast-message';
import { Provider as ReduxProvider } from "react-redux"; import { Provider as ReduxProvider } from "react-redux";
import { AuthProvider } from "./contexts/auth-context"; import { AuthProvider } from "./contexts/auth-context";
import i18n from "./i18n"; import i18n from "./i18n";
import { LanguageProvider } from "./i18n/LanguageContext"; import { LanguageProvider } from "./i18n/LanguageContext";
import { store } from "./store"; import { store } from "./store";
// 自定义 Toast 配置
const toastConfig: ToastConfig = {
/*
success
- 使 BaseToast
- props
*/
success: (props) => (
<BaseToast
{...props}
style={{ borderLeftColor: '#4CAF50' }}
contentContainerStyle={{ paddingHorizontal: 15 }}
text1Style={{
fontSize: 15,
fontWeight: '600'
}}
text2Style={{
fontSize: 13,
color: '#666'
}}
/>
),
/*
error
*/
error: (props) => (
<ErrorToast
{...props}
style={{ borderLeftColor: '#F44336' }}
contentContainerStyle={{ paddingHorizontal: 15 }}
text1Style={{
fontSize: 15,
fontWeight: '600'
}}
text2Style={{
fontSize: 13,
color: '#666'
}}
/>
),
/*
info
*/
info: (props) => (
<BaseToast
{...props}
style={{ borderLeftColor: '#2196F3' }}
contentContainerStyle={{ paddingHorizontal: 15 }}
text1Style={{
fontSize: 15,
fontWeight: '600'
}}
text2Style={{
fontSize: 13,
color: '#666'
}}
/>
),
};
export function Provider({ children }: { children: React.ReactNode }) { export function Provider({ children }: { children: React.ReactNode }) {
return ( return (
<I18nextProvider i18n={i18n}> <I18nextProvider i18n={i18n}>
<LanguageProvider> <LanguageProvider>
<ReduxProvider store={store}> <ReduxProvider store={store}>
<AuthProvider> <AuthProvider>
{children} {children}
<Toast
config={toastConfig}
position={Platform.OS === 'web' ? 'top' : 'bottom'}
topOffset={Platform.OS === 'web' ? 20 : undefined}
bottomOffset={Platform.OS === 'web' ? undefined : 40}
visibilityTime={3000}
autoHide
/>
</AuthProvider> </AuthProvider>
</ReduxProvider> </ReduxProvider>
</LanguageProvider> </LanguageProvider>
</I18nextProvider> </I18nextProvider>
); );
} }

6
src/svg.d.ts vendored Normal file
View File

@ -0,0 +1,6 @@
declare module '*.svg' {
import React from 'react';
import { SvgProps } from 'react-native-svg';
const content: React.FC<SvgProps>;
export default content;
}

44
src/utils/toast.ts Normal file
View File

@ -0,0 +1,44 @@
import Toast from 'react-native-toast-message';
type ToastType = 'success' | 'error' | 'info';
export const showToast = (
type: ToastType,
text1: string,
text2?: string,
options: {
position?: 'top' | 'bottom';
visibilityTime?: number;
autoHide?: boolean;
topOffset?: number;
bottomOffset?: number;
} = {}
) => {
Toast.show({
type,
text1,
text2,
position: options.position || 'bottom',
visibilityTime: options.visibilityTime || 3000,
autoHide: options.autoHide !== false,
topOffset: options.topOffset,
bottomOffset: options.bottomOffset,
});
};
// 快捷方法
export const showSuccess = (message: string, description?: string) => {
showToast('success', message, description);
};
export const showError = (message: string, description?: string) => {
showToast('error', message, description);
};
export const showInfo = (message: string, description?: string) => {
showToast('info', message, description);
};
export const hideToast = () => {
Toast.hide();
};

View File

@ -12,6 +12,12 @@ module.exports = {
beige: '#F5F5DC', beige: '#F5F5DC',
saddlebrown: '#8B4513', saddlebrown: '#8B4513',
darkred: '#8B0000', darkred: '#8B0000',
bgPrimary: '#FFB645',
textPrimary: '#AC7E35',
textSecondary: '#4C320C',
inputBackground: '#FFF8DE',
textTertiary: '#4C320C',
buttonFill: '#E2793F'
}, },
}, },
}, },

View File

@ -1,7 +1,9 @@
{ {
"extends": "expo/tsconfig.base", "extends": "expo/tsconfig.base",
"typeRoots": ["./node_modules/@types", "./src/types"],
"compilerOptions": { "compilerOptions": {
"strict": true, "strict": true,
"baseUrl": ".",
"paths": { "paths": {
"@/*": [ "@/*": [
"./*" "./*"

View File

@ -8,4 +8,6 @@ export interface User {
nickname?: string nickname?: string
email: string email: string
user_id?: string user_id?: string
refresh_token?: string
avatar_file_url?: string
} }