redesign: signin & signup for the follow up
This commit is contained in:
@@ -65,7 +65,7 @@ export default class API {
|
||||
public static readonly baseUrl =
|
||||
process.env.NODE_ENV != 'development' && Platform.OS === 'web'
|
||||
? '/api'
|
||||
: Constants.manifest?.extra?.apiUrl;
|
||||
: "https://nightly.chroma.octohub.app/api";
|
||||
public static async fetch(
|
||||
params: FetchParams,
|
||||
handle: Pick<Required<HandleParams>, 'raw'>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import React from 'react';
|
||||
import { QueryClient, QueryClientProvider } from 'react-query';
|
||||
import { Provider } from 'react-redux';
|
||||
import { useEffect } from 'react';
|
||||
import store, { persistor } from './state/Store';
|
||||
import { Router } from './Navigation';
|
||||
import './i18n/i18n';
|
||||
@@ -10,12 +11,25 @@ import LanguageGate from './i18n/LanguageGate';
|
||||
import ThemeProvider, { ColorSchemeProvider } from './Theme';
|
||||
import 'react-native-url-polyfill/auto';
|
||||
import { QueryRules } from './Queries';
|
||||
import { useFonts } from 'expo-font';
|
||||
|
||||
const queryClient = new QueryClient(QueryRules);
|
||||
|
||||
export default function App() {
|
||||
// SplashScreen.preventAutoHideAsync();
|
||||
// setTimeout(SplashScreen.hideAsync, 500);
|
||||
|
||||
SplashScreen.preventAutoHideAsync();
|
||||
setTimeout(SplashScreen.hideAsync, 500);
|
||||
|
||||
const [fontsLoaded] = useFonts({
|
||||
'Lexend': require('./assets/fonts/Lexend-VariableFont_wght.ttf'),
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (fontsLoaded) {
|
||||
SplashScreen.hideAsync();
|
||||
}
|
||||
}, [fontsLoaded]);
|
||||
|
||||
return (
|
||||
<Provider store={store}>
|
||||
|
||||
@@ -12,6 +12,8 @@ import { useDispatch } from 'react-redux';
|
||||
import { Translate, translate } from './i18n/i18n';
|
||||
import SongLobbyView from './views/SongLobbyView';
|
||||
import AuthenticationView from './views/AuthenticationView';
|
||||
import SigninView from './views/SigninView';
|
||||
import SignupView from './views/SignupView';
|
||||
import StartPageView from './views/StartPageView';
|
||||
import HomeView from './views/HomeView';
|
||||
import SearchView from './views/SearchView';
|
||||
@@ -84,17 +86,27 @@ const publicRoutes = () =>
|
||||
link: '/',
|
||||
},
|
||||
Login: {
|
||||
component: (params: RouteProps<{}>) =>
|
||||
AuthenticationView({ isSignup: false, ...params }),
|
||||
options: { title: translate('signInBtn') },
|
||||
component: SigninView,
|
||||
options: { title: translate('signInBtn'), headerShown: false },
|
||||
link: '/login',
|
||||
},
|
||||
Signup: {
|
||||
component: (params: RouteProps<{}>) =>
|
||||
AuthenticationView({ isSignup: true, ...params }),
|
||||
options: { title: translate('signUpBtn') },
|
||||
component: SignupView,
|
||||
options: { title: translate('signUpBtn'), headerShown: false },
|
||||
link: '/signup',
|
||||
},
|
||||
// Login: {
|
||||
// component: (params: RouteProps<{}>) =>
|
||||
// AuthenticationView({ isSignup: false, ...params }),
|
||||
// options: { title: translate('signInBtn') },
|
||||
// link: '/login',
|
||||
// },
|
||||
// Signup: {
|
||||
// component: (params: RouteProps<{}>) =>
|
||||
// AuthenticationView({ isSignup: true, ...params }),
|
||||
// options: { title: translate('signUpBtn') },
|
||||
// link: '/signup',
|
||||
// },
|
||||
Oops: {
|
||||
component: ProfileErrorView,
|
||||
options: { title: 'Oops', headerShown: false },
|
||||
|
||||
@@ -12,6 +12,11 @@ const ThemeProvider = ({ children }: { children: JSX.Element }) => {
|
||||
useSystemColorMode: false,
|
||||
initialColorMode: colorScheme,
|
||||
},
|
||||
fonts: {
|
||||
heading: "Lexend",
|
||||
body: "Lexend",
|
||||
mono: "Lexend",
|
||||
},
|
||||
colors: {
|
||||
primary: {
|
||||
50: '#e6faea',
|
||||
|
||||
BIN
front/assets/banner.jpg
Normal file
BIN
front/assets/banner.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.3 MiB |
BIN
front/assets/fonts/Lexend-VariableFont_wght.ttf
Normal file
BIN
front/assets/fonts/Lexend-VariableFont_wght.ttf
Normal file
Binary file not shown.
147
front/components/UI/ButtonBase.tsx
Normal file
147
front/components/UI/ButtonBase.tsx
Normal file
@@ -0,0 +1,147 @@
|
||||
import React, { useRef, useState } from 'react';
|
||||
import { Animated, StyleSheet, TouchableOpacity, Text, ActivityIndicator, View, Image } from 'react-native';
|
||||
import Ionicons from '@expo/vector-icons/Ionicons';
|
||||
|
||||
interface ButtonProps {
|
||||
title?: string;
|
||||
onPress?: () => Promise<any>;
|
||||
isDisabled?: boolean;
|
||||
icon?: string;
|
||||
iconImage?: string;
|
||||
isCollapsed?: boolean;
|
||||
isOutlined?: boolean;
|
||||
}
|
||||
|
||||
const ButtonBase: React.FC<ButtonProps> = ({ title, onPress, isDisabled, icon, iconImage, isCollapsed, isOutlined = false }) => {
|
||||
const shouldCollapse = isCollapsed !== undefined ? isCollapsed : (!title && (icon || iconImage));
|
||||
const [loading, setLoading] = useState(false);
|
||||
const scaleValue = useRef(new Animated.Value(1)).current;
|
||||
const colorValue = useRef(new Animated.Value(0)).current;
|
||||
const backgroundColor = colorValue.interpolate({
|
||||
inputRange: [0, 1],
|
||||
outputRange: isOutlined ? ['transparent', 'transparent'] : ['#6075F9', '#4352ae'],
|
||||
});
|
||||
|
||||
const borderColor = colorValue.interpolate({
|
||||
inputRange: [0, 1],
|
||||
outputRange: ['#6075F9', '#4352ae'],
|
||||
});
|
||||
|
||||
const textColor = colorValue.interpolate({
|
||||
inputRange: [0, 1],
|
||||
outputRange: isOutlined ? ['#6075F9', '#4352ae'] : ['#ffffff', '#ffffff'],
|
||||
});
|
||||
|
||||
// scale animation onClick
|
||||
const handlePressIn = () => {
|
||||
Animated.spring(scaleValue, {
|
||||
toValue: 0.98,
|
||||
useNativeDriver: true,
|
||||
}).start();
|
||||
};
|
||||
|
||||
// scale animation reset after onClick
|
||||
const handlePressOut = async () => {
|
||||
Animated.spring(scaleValue, {
|
||||
toValue: 1,
|
||||
friction: 30,
|
||||
// tension: 50,
|
||||
useNativeDriver: true,
|
||||
}).start();
|
||||
|
||||
if (onPress && !isDisabled) {
|
||||
setLoading(true);
|
||||
await onPress();
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// color animation onHover
|
||||
const handleMouseEnter = () => {
|
||||
Animated.timing(colorValue, {
|
||||
toValue: 1,
|
||||
duration: 250,
|
||||
useNativeDriver: false,
|
||||
}).start();
|
||||
};
|
||||
|
||||
// color animation reset after onHover
|
||||
const handleMouseLeave = () => {
|
||||
Animated.timing(colorValue, {
|
||||
toValue: 0,
|
||||
duration: 250,
|
||||
useNativeDriver: false,
|
||||
}).start();
|
||||
};
|
||||
|
||||
return (
|
||||
<View style={shouldCollapse ? styles.collapsedContainer : styles.container}>
|
||||
<TouchableOpacity
|
||||
activeOpacity={1}
|
||||
onPressIn={handlePressIn}
|
||||
onPressOut={handlePressOut}
|
||||
disabled={isDisabled || loading}
|
||||
style={styles.buttonContainer}
|
||||
onMouseEnter={handleMouseEnter}
|
||||
onMouseLeave={handleMouseLeave}
|
||||
>
|
||||
<Animated.View
|
||||
style={[
|
||||
styles.button,
|
||||
{
|
||||
backgroundColor: isDisabled ? '#454562' : backgroundColor,
|
||||
transform: [{ scale: scaleValue }],
|
||||
borderWidth: isOutlined ? 2 : 0,
|
||||
borderColor,
|
||||
padding: (icon || iconImage) ? 12 : 16
|
||||
},
|
||||
]}
|
||||
>
|
||||
{loading ? (
|
||||
<ActivityIndicator size="small" color={isOutlined ? '#6075F9' : '#FFFFFF'} />
|
||||
) : (
|
||||
<View style={styles.content}>
|
||||
{icon && <Ionicons name={icon} size={24} color={isOutlined ? '#6075F9' : '#FFFFFF'} />}
|
||||
{iconImage && <Image source={{uri: iconImage}} style={styles.icon} />}
|
||||
{title && <Animated.Text style={[styles.text, { color: textColor }]}>{title}</Animated.Text>}
|
||||
</View>
|
||||
)}
|
||||
</Animated.View>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
width: '100%',
|
||||
},
|
||||
collapsedContainer: {
|
||||
width: 'fit-content',
|
||||
// alignItems: 'center',
|
||||
},
|
||||
buttonContainer: {
|
||||
borderRadius: 16,
|
||||
},
|
||||
button: {
|
||||
borderRadius: 16,
|
||||
padding: 16,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
},
|
||||
text: {
|
||||
color: '#fff',
|
||||
marginHorizontal: 8
|
||||
},
|
||||
content: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
},
|
||||
icon: {
|
||||
width: 24,
|
||||
height: 24,
|
||||
// marginRight: 8,
|
||||
},
|
||||
});
|
||||
|
||||
export default ButtonBase;
|
||||
23
front/components/UI/LinkBase.tsx
Normal file
23
front/components/UI/LinkBase.tsx
Normal file
@@ -0,0 +1,23 @@
|
||||
import React, { ReactNode, FunctionComponent } from 'react';
|
||||
import { TouchableOpacity, Text, StyleSheet } from 'react-native';
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
linkText: {
|
||||
textDecorationLine: 'underline',
|
||||
color: '#A3AFFC',
|
||||
fontWeight: '700',
|
||||
},
|
||||
});
|
||||
|
||||
interface LinkBaseProps {
|
||||
children: ReactNode;
|
||||
onPress: () => void;
|
||||
}
|
||||
|
||||
const LinkBase: FunctionComponent<LinkBaseProps> = ({ children, onPress }) => (
|
||||
<TouchableOpacity onPress={onPress}>
|
||||
<Text style={styles.linkText}>{children}</Text>
|
||||
</TouchableOpacity>
|
||||
);
|
||||
|
||||
export default LinkBase;
|
||||
34
front/components/UI/SeparatorBase.tsx
Normal file
34
front/components/UI/SeparatorBase.tsx
Normal file
@@ -0,0 +1,34 @@
|
||||
import React, { FunctionComponent, ReactNode } from 'react';
|
||||
import { View, Text, StyleSheet } from 'react-native';
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
line: {
|
||||
flex: 1,
|
||||
height: 2,
|
||||
backgroundColor: 'white',
|
||||
},
|
||||
container: {
|
||||
width: '100%',
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
marginVertical: 20,
|
||||
},
|
||||
text: {
|
||||
color: 'white',
|
||||
paddingHorizontal: 16,
|
||||
},
|
||||
});
|
||||
|
||||
interface SeparatorBaseProps {
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
const SeparatorBase: FunctionComponent<SeparatorBaseProps> = ({children}) => (
|
||||
<View style={styles.container}>
|
||||
<View style={styles.line} />
|
||||
<Text style={styles.text}>{children}</Text>
|
||||
<View style={styles.line} />
|
||||
</View>
|
||||
);
|
||||
|
||||
export default SeparatorBase;
|
||||
109
front/components/UI/TextFieldBase.tsx
Normal file
109
front/components/UI/TextFieldBase.tsx
Normal file
@@ -0,0 +1,109 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { View, TextInput, TouchableOpacity, StyleSheet } from 'react-native';
|
||||
import Icon from 'react-native-vector-icons/Ionicons'; // Supposons que nous utilisons la bibliothèque Ionicons pour les icônes
|
||||
|
||||
export interface TextFieldBaseProps {
|
||||
value?: string;
|
||||
icon?: string;
|
||||
iconColor?: string;
|
||||
placeholder?: string;
|
||||
autoComplete?:
|
||||
| 'birthdate-day'
|
||||
| 'birthdate-full'
|
||||
| 'birthdate-month'
|
||||
| 'birthdate-year'
|
||||
| 'cc-csc'
|
||||
| 'cc-exp'
|
||||
| 'cc-exp-day'
|
||||
| 'cc-exp-month'
|
||||
| 'cc-exp-year'
|
||||
| 'cc-number'
|
||||
| 'email'
|
||||
| 'gender'
|
||||
| 'name'
|
||||
| 'name-family'
|
||||
| 'name-given'
|
||||
| 'name-middle'
|
||||
| 'name-middle-initial'
|
||||
| 'name-prefix'
|
||||
| 'name-suffix'
|
||||
| 'password'
|
||||
| 'password-new'
|
||||
| 'postal-address'
|
||||
| 'postal-address-country'
|
||||
| 'postal-address-extended'
|
||||
| 'postal-address-extended-postal-code'
|
||||
| 'postal-address-locality'
|
||||
| 'postal-address-region'
|
||||
| 'postal-code'
|
||||
| 'street-address'
|
||||
| 'sms-otp'
|
||||
| 'tel'
|
||||
| 'tel-country-code'
|
||||
| 'tel-national'
|
||||
| 'tel-device'
|
||||
| 'username'
|
||||
| 'username-new'
|
||||
| 'off'
|
||||
| undefined;
|
||||
isSecret?: boolean;
|
||||
isRequired?: boolean;
|
||||
onChangeText?: ((text: string) => void) | undefined;
|
||||
}
|
||||
|
||||
const TextFieldBase: React.FC<TextFieldBaseProps> = ({ placeholder = '', icon, iconColor, autoComplete = 'off', isSecret = false, isRequired = false, ...props }) => {
|
||||
const [isPasswordVisible, setPasswordVisible] = useState(!isSecret);
|
||||
const [isFocused, setFocused] = useState(false);
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<View style={styles.iconContainerLeft}>
|
||||
{icon && <Icon name={icon} size={24} color={iconColor ? iconColor : (isFocused ? '#6075F9' : '#454562')} />}
|
||||
</View>
|
||||
<TextInput
|
||||
style={styles.input}
|
||||
autoComplete={autoComplete}
|
||||
placeholder={placeholder + (isRequired ? '*' : '')}
|
||||
placeholderTextColor='#454562'
|
||||
secureTextEntry={isSecret ? !isPasswordVisible : false}
|
||||
onFocus={() => setFocused(true)}
|
||||
onBlur={() => setFocused(false)}
|
||||
{...props}
|
||||
/>
|
||||
{isSecret && (
|
||||
<TouchableOpacity style={styles.iconContainerRight} onPress={() => setPasswordVisible(prevState => !prevState)}>
|
||||
<Icon name={isPasswordVisible ? 'eye-off' : 'eye'} size={24} color='#454562' />
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
backgroundColor: '#22222D',
|
||||
borderRadius: 16,
|
||||
},
|
||||
input: {
|
||||
flex: 1,
|
||||
color: '#ffffff',
|
||||
paddingHorizontal: 16 + 24 + 16,
|
||||
paddingVertical: 16,
|
||||
outlineStyle: 'none',
|
||||
},
|
||||
iconContainerLeft: {
|
||||
position: 'absolute',
|
||||
left: 16,
|
||||
zIndex: 1,
|
||||
},
|
||||
iconContainerRight: {
|
||||
position: 'absolute',
|
||||
outlineStyle: 'none',
|
||||
right: 16,
|
||||
zIndex: 1,
|
||||
},
|
||||
});
|
||||
|
||||
export default TextFieldBase;
|
||||
183
front/components/UI/TextFormField copy.tsx
Normal file
183
front/components/UI/TextFormField copy.tsx
Normal file
@@ -0,0 +1,183 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { View, TextInput, TouchableOpacity, StyleSheet, Text, Animated } from 'react-native';
|
||||
import Icon from 'react-native-vector-icons/Ionicons'; // Supposons que nous utilisons la bibliothèque Ionicons pour les icônes
|
||||
|
||||
interface TextFormFieldProps {
|
||||
value?: string;
|
||||
icon?: string;
|
||||
placeholder?: string;
|
||||
autoComplete?:
|
||||
| 'birthdate-day'
|
||||
| 'birthdate-full'
|
||||
| 'birthdate-month'
|
||||
| 'birthdate-year'
|
||||
| 'cc-csc'
|
||||
| 'cc-exp'
|
||||
| 'cc-exp-day'
|
||||
| 'cc-exp-month'
|
||||
| 'cc-exp-year'
|
||||
| 'cc-number'
|
||||
| 'email'
|
||||
| 'gender'
|
||||
| 'name'
|
||||
| 'name-family'
|
||||
| 'name-given'
|
||||
| 'name-middle'
|
||||
| 'name-middle-initial'
|
||||
| 'name-prefix'
|
||||
| 'name-suffix'
|
||||
| 'password'
|
||||
| 'password-new'
|
||||
| 'postal-address'
|
||||
| 'postal-address-country'
|
||||
| 'postal-address-extended'
|
||||
| 'postal-address-extended-postal-code'
|
||||
| 'postal-address-locality'
|
||||
| 'postal-address-region'
|
||||
| 'postal-code'
|
||||
| 'street-address'
|
||||
| 'sms-otp'
|
||||
| 'tel'
|
||||
| 'tel-country-code'
|
||||
| 'tel-national'
|
||||
| 'tel-device'
|
||||
| 'username'
|
||||
| 'username-new'
|
||||
| 'off'
|
||||
| undefined;
|
||||
isSecret?: boolean;
|
||||
isRequired?: boolean;
|
||||
error?: string;
|
||||
onChangeText?: ((text: string) => void) | undefined;
|
||||
}
|
||||
|
||||
const ERROR_HEIGHT = 20;
|
||||
const ERROR_PADDING_TOP = 8;
|
||||
|
||||
const TextFormField: React.FC<TextFormFieldProps> = ({ value = '', placeholder = '', icon, autoComplete = 'off', isSecret = false, isRequired = false, error, ...props }) => {
|
||||
const [isPasswordVisible, setPasswordVisible] = useState(!isSecret);
|
||||
const [isFocused, setFocused] = useState(false);
|
||||
const [fieldValue, setFieldValue] = useState(value);
|
||||
const fadeAnim = React.useRef(new Animated.Value(0)).current; // Initial value for opacity: 0
|
||||
const heightAnim = React.useRef(new Animated.Value(0)).current; // Initial value for height: 0
|
||||
const paddingTopAnim = React.useRef(new Animated.Value(0)).current; // Initial value for paddingTop: 0
|
||||
|
||||
// Update fieldValue whenever value changes
|
||||
useEffect(() => {
|
||||
setFieldValue(value);
|
||||
}, [value]);
|
||||
|
||||
// Animate the error message
|
||||
useEffect(() => {
|
||||
Animated.parallel([
|
||||
Animated.timing(
|
||||
fadeAnim,
|
||||
{
|
||||
toValue: error ? 1 : 0,
|
||||
duration: 500,
|
||||
useNativeDriver: true
|
||||
}
|
||||
),
|
||||
Animated.timing(
|
||||
heightAnim,
|
||||
{
|
||||
toValue: error ? ERROR_HEIGHT : 0,
|
||||
duration: 250,
|
||||
useNativeDriver: false // height cannot be animated using native driver
|
||||
}
|
||||
),
|
||||
Animated.timing(
|
||||
paddingTopAnim,
|
||||
{
|
||||
toValue: error ? ERROR_PADDING_TOP : 0,
|
||||
duration: 150,
|
||||
useNativeDriver: false // paddingTop cannot be animated using native driver
|
||||
}
|
||||
),
|
||||
]).start();
|
||||
}, [error]);
|
||||
|
||||
const handleTextChange = (text: string) => {
|
||||
setFieldValue(text);
|
||||
if (props.onChangeText) {
|
||||
props.onChangeText(text);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<View style={styles.wrapper}>
|
||||
<View style={[styles.container, error && styles.error, isFocused && styles.containerFocused]}>
|
||||
<View style={styles.iconContainerLeft}>
|
||||
{icon && <Icon name={icon} size={24} color={error ? 'red' : (isFocused ? '#6075F9' : '#454562')} />}
|
||||
</View>
|
||||
<TextInput
|
||||
value={fieldValue}
|
||||
style={styles.input}
|
||||
autoComplete={autoComplete}
|
||||
placeholder={placeholder + (isRequired ? '*' : '')}
|
||||
placeholderTextColor='#454562'
|
||||
secureTextEntry={isSecret ? !isPasswordVisible : false}
|
||||
onFocus={() => setFocused(true)}
|
||||
onBlur={() => setFocused(false)}
|
||||
onChangeText={handleTextChange}
|
||||
{...props}
|
||||
/>
|
||||
{isSecret && (
|
||||
<TouchableOpacity style={styles.iconContainerRight} onPress={() => setPasswordVisible(prevState => !prevState)}>
|
||||
<Icon name={isPasswordVisible ? 'eye-off' : 'eye'} size={24} color='#454562' />
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
</View>
|
||||
<Animated.View style={{...styles.errorContainer, opacity: fadeAnim, height: heightAnim, paddingTop: paddingTopAnim}}>
|
||||
<Icon name="warning" size={16} color='red' />
|
||||
<Text style={styles.errorText}>{error}</Text>
|
||||
</Animated.View>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
wrapper: {
|
||||
width: '100%',
|
||||
},
|
||||
container: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
backgroundColor: '#22222D',
|
||||
borderRadius: 16,
|
||||
},
|
||||
error: {
|
||||
},
|
||||
containerFocused: {
|
||||
},
|
||||
input: {
|
||||
flex: 1,
|
||||
color: '#ffffff',
|
||||
paddingHorizontal: 16 + 24 + 16,
|
||||
paddingVertical: 16,
|
||||
outlineStyle: 'none',
|
||||
},
|
||||
iconContainerLeft: {
|
||||
position: 'absolute',
|
||||
left: 16,
|
||||
zIndex: 1,
|
||||
},
|
||||
iconContainerRight: {
|
||||
position: 'absolute',
|
||||
outlineStyle: 'none',
|
||||
right: 16,
|
||||
zIndex: 1,
|
||||
},
|
||||
errorContainer: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
paddingLeft: 40,
|
||||
},
|
||||
errorText: {
|
||||
color: 'red',
|
||||
fontSize: 12,
|
||||
marginLeft: 8,
|
||||
},
|
||||
});
|
||||
|
||||
export default TextFormField;
|
||||
77
front/components/UI/TextFormField.tsx
Normal file
77
front/components/UI/TextFormField.tsx
Normal file
@@ -0,0 +1,77 @@
|
||||
import React, { useEffect } from 'react';
|
||||
import { View, Text, Animated, StyleSheet } from 'react-native';
|
||||
import Icon from 'react-native-vector-icons/Ionicons';
|
||||
import TextFieldBase, { TextFieldBaseProps } from './TextFieldBase';
|
||||
|
||||
interface TextFormFieldProps extends TextFieldBaseProps {
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
const ERROR_HEIGHT = 20;
|
||||
const ERROR_PADDING_TOP = 8;
|
||||
|
||||
const TextFormField: React.FC<TextFormFieldProps> = ({ error, ...textFieldBaseProps }) => {
|
||||
const fadeAnim = React.useRef(new Animated.Value(0)).current;
|
||||
const heightAnim = React.useRef(new Animated.Value(0)).current;
|
||||
const paddingTopAnim = React.useRef(new Animated.Value(0)).current;
|
||||
|
||||
useEffect(() => {
|
||||
Animated.parallel([
|
||||
Animated.timing(
|
||||
fadeAnim,
|
||||
{
|
||||
toValue: error ? 1 : 0,
|
||||
duration: 150,
|
||||
useNativeDriver: true
|
||||
}
|
||||
),
|
||||
Animated.timing(
|
||||
heightAnim,
|
||||
{
|
||||
toValue: error ? ERROR_HEIGHT : 0,
|
||||
duration: 250,
|
||||
useNativeDriver: false
|
||||
}
|
||||
),
|
||||
Animated.timing(
|
||||
paddingTopAnim,
|
||||
{
|
||||
toValue: error ? ERROR_PADDING_TOP : 0,
|
||||
duration: 250,
|
||||
useNativeDriver: false
|
||||
}
|
||||
),
|
||||
]).start();
|
||||
}, [error]);
|
||||
|
||||
return (
|
||||
<View style={styles.wrapper}>
|
||||
<TextFieldBase
|
||||
iconColor={error ? '#f7253d' : undefined}
|
||||
{...textFieldBaseProps}
|
||||
/>
|
||||
<Animated.View style={{...styles.errorContainer, opacity: fadeAnim, height: heightAnim, paddingTop: paddingTopAnim}}>
|
||||
<Icon name="alert-circle" size={16} color='#f7253d' />
|
||||
<Text style={styles.errorText}>{error}</Text>
|
||||
</Animated.View>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
wrapper: {
|
||||
width: '100%',
|
||||
},
|
||||
errorContainer: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
paddingLeft: 16,
|
||||
},
|
||||
errorText: {
|
||||
color: '#f7253d',
|
||||
fontSize: 12,
|
||||
marginLeft: 8,
|
||||
},
|
||||
});
|
||||
|
||||
export default TextFormField;
|
||||
@@ -5,6 +5,8 @@ import { Translate, translate } from '../../i18n/i18n';
|
||||
import { string } from 'yup';
|
||||
import { FormControl, Input, Stack, WarningOutlineIcon, Box, useToast } from 'native-base';
|
||||
import TextButton from '../TextButton';
|
||||
import TextFormField from '../UI/TextFormField';
|
||||
import ButtonBase from '../UI/ButtonBase';
|
||||
|
||||
interface SigninFormProps {
|
||||
onSubmit: (username: string, password: string) => Promise<string>;
|
||||
@@ -21,8 +23,6 @@ const LoginForm = ({ onSubmit }: SigninFormProps) => {
|
||||
error: null as string | null,
|
||||
},
|
||||
});
|
||||
const [submittingForm, setSubmittingForm] = React.useState(false);
|
||||
|
||||
const validationSchemas = {
|
||||
username: string()
|
||||
.min(3, translate('usernameTooShort'))
|
||||
@@ -35,18 +35,15 @@ const LoginForm = ({ onSubmit }: SigninFormProps) => {
|
||||
};
|
||||
const toast = useToast();
|
||||
return (
|
||||
<Box alignItems="center" style={{ width: '100%' }}>
|
||||
<Box alignItems="center" style={{ width: '100%', backgroundColor: "#101014" }}>
|
||||
<Stack mx="4" style={{ width: '80%', maxWidth: 400 }}>
|
||||
<FormControl
|
||||
isRequired
|
||||
isInvalid={formData.username.error !== null || formData.password.error !== null}
|
||||
>
|
||||
<FormControl.Label>
|
||||
<Translate translationKey="username" />
|
||||
</FormControl.Label>
|
||||
<Input
|
||||
isRequired
|
||||
type="text"
|
||||
<TextFormField
|
||||
error={formData.username.error}
|
||||
icon='person'
|
||||
placeholder="Username"
|
||||
autoComplete="username"
|
||||
value={formData.username.value}
|
||||
@@ -59,16 +56,14 @@ const LoginForm = ({ onSubmit }: SigninFormProps) => {
|
||||
setFormData({ ...formData, username: { value: t, error } });
|
||||
});
|
||||
}}
|
||||
/>
|
||||
<FormControl.ErrorMessage leftIcon={<WarningOutlineIcon size="xs" />}>
|
||||
{formData.username.error}
|
||||
</FormControl.ErrorMessage>
|
||||
<FormControl.Label>
|
||||
<Translate translationKey="password" />
|
||||
</FormControl.Label>
|
||||
<Input
|
||||
isRequired
|
||||
type="password"
|
||||
/>
|
||||
<TextFormField
|
||||
isRequired
|
||||
isSecret
|
||||
error={formData.password.error}
|
||||
icon='lock-closed'
|
||||
placeholder="Password"
|
||||
autoComplete="password"
|
||||
value={formData.password.value}
|
||||
onChangeText={(t) => {
|
||||
@@ -81,13 +76,8 @@ const LoginForm = ({ onSubmit }: SigninFormProps) => {
|
||||
});
|
||||
}}
|
||||
/>
|
||||
<FormControl.ErrorMessage leftIcon={<WarningOutlineIcon size="xs" />}>
|
||||
{formData.password.error}
|
||||
</FormControl.ErrorMessage>
|
||||
<TextButton
|
||||
translate={{ translationKey: 'signInBtn' }}
|
||||
style={{ marginTop: 10 }}
|
||||
isLoading={submittingForm}
|
||||
<ButtonBase
|
||||
title="Signin"
|
||||
isDisabled={
|
||||
formData.password.error !== null ||
|
||||
formData.username.error !== null ||
|
||||
@@ -95,24 +85,27 @@ const LoginForm = ({ onSubmit }: SigninFormProps) => {
|
||||
formData.password.value === ''
|
||||
}
|
||||
onPress={async () => {
|
||||
setSubmittingForm(true);
|
||||
try {
|
||||
const resp = await onSubmit(
|
||||
formData.username.value,
|
||||
formData.password.value
|
||||
);
|
||||
toast.show({ description: resp, colorScheme: 'secondary' });
|
||||
setSubmittingForm(false);
|
||||
} catch (e) {
|
||||
toast.show({
|
||||
description: e as string,
|
||||
colorScheme: 'red',
|
||||
avoidKeyboard: true,
|
||||
});
|
||||
setSubmittingForm(false);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<ButtonBase
|
||||
// icon='logo-google'
|
||||
isOutlined
|
||||
iconImage='https://upload.wikimedia.org/wikipedia/commons/thumb/5/53/Google_%22G%22_Logo.svg/2008px-Google_%22G%22_Logo.svg.png'
|
||||
title="Signin with google"
|
||||
/>
|
||||
</FormControl>
|
||||
</Stack>
|
||||
</Box>
|
||||
|
||||
@@ -52,6 +52,7 @@
|
||||
"react-dom": "18.1.0",
|
||||
"react-i18next": "^11.18.3",
|
||||
"react-native": "0.70.5",
|
||||
"react-native-ionicons": "^4.6.5",
|
||||
"react-native-paper": "^4.12.5",
|
||||
"react-native-reanimated": "~2.12.0",
|
||||
"react-native-safe-area-context": "4.4.1",
|
||||
|
||||
164
front/views/SigninView.tsx
Normal file
164
front/views/SigninView.tsx
Normal file
@@ -0,0 +1,164 @@
|
||||
import React from 'react';
|
||||
import { useDispatch } from '../state/Store';
|
||||
import { Translate, translate } from '../i18n/i18n';
|
||||
import API, { APIError } from '../API';
|
||||
import { setAccessToken } from '../state/UserSlice';
|
||||
import { } from 'native-base';
|
||||
import SigninForm from '../components/forms/signinform';
|
||||
import TextButton from '../components/TextButton';
|
||||
import { useNavigation } from '../Navigation';
|
||||
import { string } from 'yup';
|
||||
import { FormControl, Input, Stack, Center, Button, Text, Box, useToast } from 'native-base';
|
||||
import { TouchableOpacity, Linking, View } from 'react-native'
|
||||
import TextFormField from '../components/UI/TextFormField';
|
||||
import LinkBase from '../components/UI/LinkBase';
|
||||
import SeparatorBase from '../components/UI/SeparatorBase';
|
||||
import ButtonBase from '../components/UI/ButtonBase';
|
||||
import { Image, Flex } from 'native-base';
|
||||
import ImageBanner from '../assets/banner.jpg';
|
||||
|
||||
const hanldeSignin = async (
|
||||
username: string,
|
||||
password: string,
|
||||
apiSetter: (accessToken: string) => void
|
||||
): Promise<string> => {
|
||||
try {
|
||||
const apiAccess = await API.authenticate({ username, password });
|
||||
apiSetter(apiAccess);
|
||||
return translate('loggedIn');
|
||||
} catch (error) {
|
||||
if (error instanceof APIError) return translate(error.userMessage);
|
||||
if (error instanceof Error) return error.message;
|
||||
return translate('unknownError');
|
||||
}
|
||||
};
|
||||
|
||||
const SigninView = () => {
|
||||
const dispatch = useDispatch();
|
||||
const navigation = useNavigation();
|
||||
const [formData, setFormData] = React.useState({
|
||||
username: {
|
||||
value: '',
|
||||
error: null as string | null,
|
||||
},
|
||||
password: {
|
||||
value: '',
|
||||
error: null as string | null,
|
||||
},
|
||||
});
|
||||
const validationSchemas = {
|
||||
username: string()
|
||||
.min(3, translate('usernameTooShort'))
|
||||
.max(20, translate('usernameTooLong'))
|
||||
.required('Username is required'),
|
||||
password: string()
|
||||
.min(4, translate('passwordTooShort'))
|
||||
.max(100, translate('passwordTooLong'))
|
||||
.required('Password is required'),
|
||||
};
|
||||
const toast = useToast();
|
||||
|
||||
|
||||
const onSubmit= (username: string, password: string) => {
|
||||
return hanldeSignin(username, password, (accessToken) =>
|
||||
dispatch(setAccessToken(accessToken))
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Flex direction='row' justifyContent="space-between" style={{ flex: 1, backgroundColor: '#101014'}}>
|
||||
<Center style={{ flex: 1}}>
|
||||
<Stack space={4} justifyContent="center" alignContent="center" alignItems="center" style={{ width: '100%', maxWidth: 420, padding: 16 }}>
|
||||
<Text fontSize="4xl" textAlign="center">
|
||||
Bienvenue !
|
||||
</Text>
|
||||
<Text fontSize="xl" textAlign="center">
|
||||
Continuez avec Google ou entrez vos coordonnées.
|
||||
</Text>
|
||||
<ButtonBase
|
||||
isOutlined
|
||||
iconImage='https://upload.wikimedia.org/wikipedia/commons/thumb/5/53/Google_%22G%22_Logo.svg/2008px-Google_%22G%22_Logo.svg.png'
|
||||
title="Signin with google"
|
||||
/>
|
||||
<SeparatorBase>or</SeparatorBase>
|
||||
<TextFormField
|
||||
error={formData.username.error}
|
||||
icon='person'
|
||||
placeholder="Username"
|
||||
autoComplete="username"
|
||||
value={formData.username.value}
|
||||
onChangeText={(t) => {
|
||||
let error: null | string = null;
|
||||
validationSchemas.username
|
||||
.validate(t)
|
||||
.catch((e) => (error = e.message))
|
||||
.finally(() => {
|
||||
setFormData({ ...formData, username: { value: t, error } });
|
||||
});
|
||||
}}
|
||||
isRequired
|
||||
/>
|
||||
<TextFormField
|
||||
isRequired
|
||||
isSecret
|
||||
error={formData.password.error}
|
||||
icon='lock-closed'
|
||||
placeholder="Password"
|
||||
autoComplete="password"
|
||||
value={formData.password.value}
|
||||
onChangeText={(t) => {
|
||||
let error: null | string = null;
|
||||
validationSchemas.password
|
||||
.validate(t)
|
||||
.catch((e) => (error = e.message))
|
||||
.finally(() => {
|
||||
setFormData({ ...formData, password: { value: t, error } });
|
||||
});
|
||||
}}
|
||||
/>
|
||||
<LinkBase onPress={() => console.log('Link clicked!')}>
|
||||
{translate('forgottenPassword')}
|
||||
</LinkBase>
|
||||
|
||||
<ButtonBase
|
||||
title="Signin"
|
||||
isDisabled={
|
||||
formData.password.error !== null ||
|
||||
formData.username.error !== null ||
|
||||
formData.username.value === '' ||
|
||||
formData.password.value === ''
|
||||
}
|
||||
onPress={async () => {
|
||||
try {
|
||||
const resp = await onSubmit(
|
||||
formData.username.value,
|
||||
formData.password.value
|
||||
);
|
||||
toast.show({ description: resp, colorScheme: 'secondary' });
|
||||
} catch (e) {
|
||||
toast.show({
|
||||
description: e as string,
|
||||
colorScheme: 'red',
|
||||
avoidKeyboard: true,
|
||||
});
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<Text>Vous n'avez pas de compte ?</Text>
|
||||
<LinkBase onPress={() => navigation.navigate('Signup', {})}>
|
||||
Inscrivez-vous gratuitement
|
||||
</LinkBase>
|
||||
</Stack>
|
||||
</Center>
|
||||
<View style={{width: '50%', height: '100%', padding: 16}}>
|
||||
<Image
|
||||
source={ImageBanner}
|
||||
alt="banner page"
|
||||
style={{width: '100%', height: '100%', borderRadius: 8}}
|
||||
/>
|
||||
</View>
|
||||
</Flex>
|
||||
);
|
||||
};
|
||||
|
||||
export default SigninView;
|
||||
214
front/views/SignupView.tsx
Normal file
214
front/views/SignupView.tsx
Normal file
@@ -0,0 +1,214 @@
|
||||
import React from 'react';
|
||||
import { useDispatch } from '../state/Store';
|
||||
import { Translate, translate } from '../i18n/i18n';
|
||||
import API, { APIError } from '../API';
|
||||
import { setAccessToken } from '../state/UserSlice';
|
||||
import { } from 'native-base';
|
||||
import SigninForm from '../components/forms/signinform';
|
||||
import TextButton from '../components/TextButton';
|
||||
import { useNavigation } from '../Navigation';
|
||||
import { string } from 'yup';
|
||||
import { FormControl, Input, Stack, Center, Button, Text, Box, useToast } from 'native-base';
|
||||
import { TouchableOpacity, Linking, View } from 'react-native'
|
||||
import TextFormField from '../components/UI/TextFormField';
|
||||
import LinkBase from '../components/UI/LinkBase';
|
||||
import SeparatorBase from '../components/UI/SeparatorBase';
|
||||
import ButtonBase from '../components/UI/ButtonBase';
|
||||
import { Image, Flex } from 'native-base';
|
||||
import ImageBanner from '../assets/banner.jpg';
|
||||
|
||||
const handleSignup = async (
|
||||
username: string,
|
||||
password: string,
|
||||
email: string,
|
||||
apiSetter: (accessToken: string) => void
|
||||
): Promise<string> => {
|
||||
try {
|
||||
const apiAccess = await API.createAccount({ username, password, email });
|
||||
apiSetter(apiAccess);
|
||||
return translate('loggedIn');
|
||||
} catch (error) {
|
||||
if (error instanceof APIError) return translate(error.userMessage);
|
||||
if (error instanceof Error) return error.message;
|
||||
return translate('unknownError');
|
||||
}
|
||||
};
|
||||
|
||||
const SigninView = () => {
|
||||
const dispatch = useDispatch();
|
||||
const navigation = useNavigation();
|
||||
const [formData, setFormData] = React.useState({
|
||||
username: {
|
||||
value: '',
|
||||
error: null as string | null,
|
||||
},
|
||||
password: {
|
||||
value: '',
|
||||
error: null as string | null,
|
||||
},
|
||||
repeatPassword: {
|
||||
value: '',
|
||||
error: null as string | null,
|
||||
},
|
||||
email: {
|
||||
value: '',
|
||||
error: null as string | null,
|
||||
},
|
||||
});
|
||||
const validationSchemas = {
|
||||
username: string()
|
||||
.min(3, translate('usernameTooShort'))
|
||||
.max(20, translate('usernameTooLong'))
|
||||
.required('Username is required'),
|
||||
email: string().email('Invalid email').required('Email is required'),
|
||||
password: string()
|
||||
.min(4, translate('passwordTooShort'))
|
||||
.max(100, translate('passwordTooLong'))
|
||||
// .matches(
|
||||
// /^(?=.*[a-z])(?=.*[A-Z])(?=.*[0-9])(?=.*[!@#\$-_%\^&\*])(?=.{8,})/,
|
||||
// translate(
|
||||
// "Must Contain 8 Characters, One Uppercase, One Lowercase, One Number and One Special Case Character"
|
||||
// )
|
||||
// )
|
||||
.required('Password is required'),
|
||||
};
|
||||
const toast = useToast();
|
||||
|
||||
|
||||
const onSubmit= (username: string, email: string, password: string) => {
|
||||
return handleSignup(username, password, email, (accessToken) =>
|
||||
dispatch(setAccessToken(accessToken))
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Flex direction='row' justifyContent="space-between" style={{ flex: 1, backgroundColor: '#101014'}}>
|
||||
<Center style={{ flex: 1}}>
|
||||
<Stack space={4} justifyContent="center" alignContent="center" alignItems="center" mx="4" style={{ width: '100%', maxWidth: 420, padding: 16 }}>
|
||||
<Text fontSize="4xl" textAlign="center">Créer un compte</Text>
|
||||
<Text fontSize="xl" textAlign="center">Apprendre le piano gratuitement et de manière ludique</Text>
|
||||
<ButtonBase
|
||||
isOutlined
|
||||
iconImage='https://upload.wikimedia.org/wikipedia/commons/thumb/5/53/Google_%22G%22_Logo.svg/2008px-Google_%22G%22_Logo.svg.png'
|
||||
title="Signup with google"
|
||||
/>
|
||||
<SeparatorBase>or</SeparatorBase>
|
||||
<TextFormField
|
||||
error={formData.username.error}
|
||||
icon='person'
|
||||
placeholder="Username"
|
||||
autoComplete="username"
|
||||
value={formData.username.value}
|
||||
onChangeText={(t) => {
|
||||
let error: null | string = null;
|
||||
validationSchemas.username
|
||||
.validate(t)
|
||||
.catch((e) => (error = e.message))
|
||||
.finally(() => {
|
||||
setFormData({ ...formData, username: { value: t, error } });
|
||||
});
|
||||
}}
|
||||
isRequired
|
||||
/>
|
||||
<TextFormField
|
||||
error={formData.email.error}
|
||||
icon='mail'
|
||||
placeholder="Email"
|
||||
autoComplete="email"
|
||||
value={formData.email.value}
|
||||
onChangeText={(t) => {
|
||||
let error: null | string = null;
|
||||
validationSchemas.email
|
||||
.validate(t)
|
||||
.catch((e) => (error = e.message))
|
||||
.finally(() => {
|
||||
setFormData({ ...formData, email: { value: t, error } });
|
||||
});
|
||||
}}
|
||||
isRequired
|
||||
/>
|
||||
<TextFormField
|
||||
isRequired
|
||||
isSecret
|
||||
error={formData.password.error}
|
||||
icon='lock-closed'
|
||||
placeholder="Password"
|
||||
autoComplete="password-new"
|
||||
value={formData.password.value}
|
||||
onChangeText={(t) => {
|
||||
let error: null | string = null;
|
||||
validationSchemas.password
|
||||
.validate(t)
|
||||
.catch((e) => (error = e.message))
|
||||
.finally(() => {
|
||||
setFormData({ ...formData, password: { value: t, error } });
|
||||
});
|
||||
}}
|
||||
/>
|
||||
<TextFormField
|
||||
isRequired
|
||||
isSecret
|
||||
error={formData.repeatPassword.error}
|
||||
icon='lock-closed'
|
||||
placeholder="Repeat password"
|
||||
autoComplete="password-new"
|
||||
value={formData.repeatPassword.value}
|
||||
onChangeText={(t) => {
|
||||
let error: null | string = null;
|
||||
validationSchemas.password
|
||||
.validate(t)
|
||||
.catch((e) => (error = e.message))
|
||||
.finally(() => {
|
||||
if (!error && t !== formData.password.value) {
|
||||
error = translate('passwordsDontMatch');
|
||||
}
|
||||
setFormData({
|
||||
...formData,
|
||||
repeatPassword: { value: t, error },
|
||||
});
|
||||
});
|
||||
}}
|
||||
/>
|
||||
<ButtonBase
|
||||
title="Signup"
|
||||
isDisabled={
|
||||
formData.password.error !== null ||
|
||||
formData.username.error !== null ||
|
||||
formData.repeatPassword.error !== null ||
|
||||
formData.email.error !== null ||
|
||||
formData.username.value === '' ||
|
||||
formData.password.value === '' ||
|
||||
formData.repeatPassword.value === '' ||
|
||||
formData.repeatPassword.value === ''
|
||||
}
|
||||
onPress={async () => {
|
||||
try {
|
||||
const resp = await onSubmit(
|
||||
formData.username.value,
|
||||
formData.password.value,
|
||||
formData.email.value
|
||||
);
|
||||
toast.show({ description: resp });
|
||||
} catch (e) {
|
||||
toast.show({ description: e as string });
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<Text>Vous avez déjà un compte ?</Text>
|
||||
<LinkBase onPress={() => navigation.navigate('Login', {})}>
|
||||
S'identifier
|
||||
</LinkBase>
|
||||
</Stack>
|
||||
</Center>
|
||||
<View style={{width: '50%', height: '100%', padding: 16}}>
|
||||
<Image
|
||||
source={ImageBanner}
|
||||
alt="banner page"
|
||||
style={{width: '100%', height: '100%', borderRadius: 8}}
|
||||
/>
|
||||
</View>
|
||||
</Flex>
|
||||
);
|
||||
};
|
||||
|
||||
export default SigninView;
|
||||
@@ -15762,6 +15762,11 @@ react-native-gradle-plugin@^0.70.3:
|
||||
resolved "https://registry.yarnpkg.com/react-native-gradle-plugin/-/react-native-gradle-plugin-0.70.3.tgz#cbcf0619cbfbddaa9128701aa2d7b4145f9c4fc8"
|
||||
integrity sha512-oOanj84fJEXUg9FoEAQomA8ISG+DVIrTZ3qF7m69VQUJyOGYyDZmPqKcjvRku4KXlEH6hWO9i4ACLzNBh8gC0A==
|
||||
|
||||
react-native-ionicons@^4.6.5:
|
||||
version "4.6.5"
|
||||
resolved "https://registry.yarnpkg.com/react-native-ionicons/-/react-native-ionicons-4.6.5.tgz#b792ec8896381e67ff237eba955e38afe7ceb7a4"
|
||||
integrity sha512-s2Ia7M5t609LE9LWygMj3ALVPUlKhK7R9XcMb67fP4EYJv0oLcwg5pc+8ftv9XXaUuTW/WgL3zJlBYxAvtvMJg==
|
||||
|
||||
react-native-iphone-x-helper@^1.3.1:
|
||||
version "1.3.1"
|
||||
resolved "https://registry.yarnpkg.com/react-native-iphone-x-helper/-/react-native-iphone-x-helper-1.3.1.tgz#20c603e9a0e765fd6f97396638bdeb0e5a60b010"
|
||||
|
||||
Reference in New Issue
Block a user