feat(frontend): Экран авторизации
This commit is contained in:
@ -1,32 +1,39 @@
|
||||
{
|
||||
"expo": {
|
||||
"name": "p1ctos4ve",
|
||||
"slug": "p1ctos4ve",
|
||||
"version": "1.0.0",
|
||||
"orientation": "portrait",
|
||||
"icon": "./assets/icon.png",
|
||||
"userInterfaceStyle": "light",
|
||||
"newArchEnabled": true,
|
||||
"scheme": "p1ctos4ve",
|
||||
"splash": {
|
||||
"image": "./assets/splash-icon.png",
|
||||
"resizeMode": "contain",
|
||||
"backgroundColor": "#ffffff"
|
||||
},
|
||||
"ios": {
|
||||
"supportsTablet": true
|
||||
},
|
||||
"android": {
|
||||
"adaptiveIcon": {
|
||||
"foregroundImage": "./assets/adaptive-icon.png",
|
||||
"backgroundColor": "#ffffff"
|
||||
},
|
||||
"edgeToEdgeEnabled": true,
|
||||
"predictiveBackGestureEnabled": false
|
||||
},
|
||||
"web": {
|
||||
"favicon": "./assets/favicon.png",
|
||||
"bundler": "metro"
|
||||
}
|
||||
}
|
||||
"expo": {
|
||||
"name": "frontend",
|
||||
"slug": "frontend",
|
||||
"version": "1.0.0",
|
||||
"orientation": "portrait",
|
||||
"icon": "./assets/images/icon.png",
|
||||
"scheme": "frontend",
|
||||
"userInterfaceStyle": "automatic",
|
||||
"newArchEnabled": true,
|
||||
"splash": {
|
||||
"image": "./assets/images/splash-icon.png",
|
||||
"resizeMode": "contain",
|
||||
"backgroundColor": "#ffffff"
|
||||
},
|
||||
"ios": {
|
||||
"supportsTablet": true
|
||||
},
|
||||
"android": {
|
||||
"adaptiveIcon": {
|
||||
"foregroundImage": "./assets/images/adaptive-icon.png",
|
||||
"backgroundColor": "#ffffff"
|
||||
},
|
||||
"edgeToEdgeEnabled": true,
|
||||
"predictiveBackGestureEnabled": false
|
||||
},
|
||||
"web": {
|
||||
"bundler": "metro",
|
||||
"output": "static",
|
||||
"favicon": "./assets/images/favicon.png"
|
||||
},
|
||||
"plugins": [
|
||||
"expo-router"
|
||||
],
|
||||
"experiments": {
|
||||
"typedRoutes": true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
34
apps/frontend/app/(tabs)/_layout.tsx
Normal file
34
apps/frontend/app/(tabs)/_layout.tsx
Normal file
@ -0,0 +1,34 @@
|
||||
import React from 'react';
|
||||
import { Image as ImageIcon, Search } from 'lucide-react-native';
|
||||
import { Link, Tabs } from 'expo-router';
|
||||
import { Pressable } from 'react-native';
|
||||
|
||||
import Colors from '@/constants/Colors';
|
||||
import { useColorScheme } from '@/components/useColorScheme';
|
||||
|
||||
export default function TabLayout() {
|
||||
const colorScheme = useColorScheme();
|
||||
|
||||
return (
|
||||
<Tabs
|
||||
screenOptions={{
|
||||
tabBarActiveTintColor: Colors[colorScheme ?? 'light'].tint,
|
||||
headerShown: false,
|
||||
}}>
|
||||
<Tabs.Screen
|
||||
name="index"
|
||||
options={{
|
||||
title: 'Мои сейвы',
|
||||
tabBarIcon: ({ color }) => <ImageIcon size={28} color={color} style={{ marginBottom: -3 }} />,
|
||||
}}
|
||||
/>
|
||||
<Tabs.Screen
|
||||
name="two"
|
||||
options={{
|
||||
title: 'Поиск',
|
||||
tabBarIcon: ({ color }) => <Search size={28} color={color} style={{ marginBottom: -3 }} />,
|
||||
}}
|
||||
/>
|
||||
</Tabs>
|
||||
);
|
||||
}
|
||||
214
apps/frontend/app/(tabs)/index.tsx
Normal file
214
apps/frontend/app/(tabs)/index.tsx
Normal file
@ -0,0 +1,214 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import {
|
||||
FlatList,
|
||||
TouchableOpacity,
|
||||
Image,
|
||||
RefreshControl,
|
||||
Alert,
|
||||
ActivityIndicator,
|
||||
} from 'react-native';
|
||||
import { useRouter } from 'expo-router';
|
||||
import { useVideoPlayer, VideoView } from 'expo-video';
|
||||
import { Text, View } from '@/components/Themed';
|
||||
import { useAuth } from '@/lib/auth';
|
||||
import { savesApi } from '@/lib/api';
|
||||
import type { SaveListItem } from '@shared-types';
|
||||
import { useColorScheme } from '@/components/useColorScheme';
|
||||
import Colors from '@/constants/Colors';
|
||||
import { File, Plus, LogOut, Image as ImageIcon } from 'lucide-react-native';
|
||||
|
||||
export default function SavesScreen() {
|
||||
const [saves, setSaves] = useState<SaveListItem[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [refreshing, setRefreshing] = useState(false);
|
||||
const { user, signOut } = useAuth();
|
||||
const router = useRouter();
|
||||
const colorScheme = useColorScheme();
|
||||
const colors = Colors[colorScheme ?? 'light'];
|
||||
|
||||
const loadSaves = async () => {
|
||||
try {
|
||||
const data = await savesApi.getMySaves();
|
||||
setSaves(data);
|
||||
} catch (error: any) {
|
||||
Alert.alert('Ошибка', error.message || 'Не удалось загрузить сейвы');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
setRefreshing(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (user) {
|
||||
loadSaves();
|
||||
}
|
||||
}, [user]);
|
||||
|
||||
const handleRefresh = () => {
|
||||
setRefreshing(true);
|
||||
loadSaves();
|
||||
};
|
||||
|
||||
const handleAdd = () => {
|
||||
router.push('/add');
|
||||
};
|
||||
|
||||
const handleSavePress = (save: SaveListItem) => {
|
||||
router.push(`/save/${save.id}`);
|
||||
};
|
||||
|
||||
const handleSignOut = async () => {
|
||||
Alert.alert(
|
||||
'Выход',
|
||||
'Вы уверены, что хотите выйти?',
|
||||
[
|
||||
{ text: 'Отмена', style: 'cancel' },
|
||||
{
|
||||
text: 'Выйти',
|
||||
style: 'destructive',
|
||||
onPress: async () => {
|
||||
try {
|
||||
await signOut();
|
||||
router.replace('/auth');
|
||||
} catch (error: any) {
|
||||
Alert.alert('Ошибка', error.message);
|
||||
}
|
||||
},
|
||||
},
|
||||
]
|
||||
);
|
||||
};
|
||||
|
||||
// Компонент для элемента списка
|
||||
const SaveItem = ({ item }: { item: SaveListItem }) => {
|
||||
const itemColorScheme = useColorScheme();
|
||||
const itemColors = Colors[itemColorScheme ?? 'light'];
|
||||
|
||||
const videoPlayer = useVideoPlayer(
|
||||
item.type === 'video' ? item.url : null,
|
||||
(player) => {
|
||||
player.muted = true;
|
||||
player.loop = true;
|
||||
player.play();
|
||||
}
|
||||
);
|
||||
|
||||
return (
|
||||
<TouchableOpacity
|
||||
className="border rounded-xl mb-3 overflow-hidden"
|
||||
style={{ borderColor: itemColors.tabIconDefault }}
|
||||
onPress={() => handleSavePress(item)}
|
||||
>
|
||||
<View className="flex-row p-3">
|
||||
<View className="w-24 h-24 rounded-lg overflow-hidden mr-3">
|
||||
{item.type === 'image' || item.type === 'gif' ? (
|
||||
<Image
|
||||
source={{ uri: item.url }}
|
||||
className="w-full h-full"
|
||||
resizeMode="cover"
|
||||
/>
|
||||
) : item.type === 'video' ? (
|
||||
<VideoView
|
||||
player={videoPlayer}
|
||||
className="w-full h-full bg-black"
|
||||
contentFit="cover"
|
||||
nativeControls={false}
|
||||
fullscreenOptions={{ enable: false }}
|
||||
/>
|
||||
) : (
|
||||
<View className="w-full h-full justify-center items-center" style={{ backgroundColor: itemColors.tabIconDefault }}>
|
||||
<File size={32} color={itemColors.text} />
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
<View className="flex-1">
|
||||
<Text className="text-base font-semibold mb-1" numberOfLines={1}>
|
||||
{item.name || 'Без названия'}
|
||||
</Text>
|
||||
{item.description && (
|
||||
<Text
|
||||
className="text-sm mb-2"
|
||||
style={{ color: itemColors.tabIconDefault }}
|
||||
numberOfLines={2}
|
||||
>
|
||||
{item.description}
|
||||
</Text>
|
||||
)}
|
||||
{item.tags.length > 0 && (
|
||||
<View className="flex-row flex-wrap gap-1.5 mb-2 items-center">
|
||||
{item.tags.slice(0, 3).map((tag, idx) => (
|
||||
<View
|
||||
key={idx}
|
||||
className="px-2 py-1 rounded"
|
||||
style={{ backgroundColor: itemColors.tint + '20' }}
|
||||
>
|
||||
<Text className="text-xs" style={{ color: itemColors.tint }}>
|
||||
{tag}
|
||||
</Text>
|
||||
</View>
|
||||
))}
|
||||
{item.tags.length > 3 && (
|
||||
<Text className="text-xs" style={{ color: itemColors.tabIconDefault }}>
|
||||
+{item.tags.length - 3}
|
||||
</Text>
|
||||
)}
|
||||
</View>
|
||||
)}
|
||||
<Text className="text-xs" style={{ color: itemColors.tabIconDefault }}>
|
||||
{new Date(item.createdAt).toLocaleDateString('ru-RU')}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
);
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<View className="flex-1 justify-center items-center">
|
||||
<ActivityIndicator size="large" color={colors.tint} />
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<View className="flex-1">
|
||||
<View className="flex-row justify-between items-center p-4 pt-16">
|
||||
<Text className="text-3xl font-bold">Мои сейвы</Text>
|
||||
<View className="flex-row gap-3">
|
||||
<TouchableOpacity onPress={handleAdd} className="p-2">
|
||||
<Plus size={24} color={colors.tint} />
|
||||
</TouchableOpacity>
|
||||
<TouchableOpacity onPress={handleSignOut} className="p-2">
|
||||
<LogOut size={24} color={colors.text} />
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{saves.length === 0 ? (
|
||||
<View className="flex-1 justify-center items-center p-10">
|
||||
<ImageIcon size={64} color={colors.tabIconDefault} />
|
||||
<Text className="text-lg mt-4 mb-6 text-center">Нет сохраненных файлов</Text>
|
||||
<TouchableOpacity
|
||||
className="px-6 py-3 rounded-lg"
|
||||
style={{ backgroundColor: colors.tint }}
|
||||
onPress={handleAdd}
|
||||
>
|
||||
<Text className="text-base font-semibold" style={{ color: '#fff' }}>Добавить первый сейв</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
) : (
|
||||
<FlatList
|
||||
data={saves}
|
||||
keyExtractor={(item) => item.id.toString()}
|
||||
renderItem={({ item }) => <SaveItem item={item} />}
|
||||
refreshControl={
|
||||
<RefreshControl refreshing={refreshing} onRefresh={handleRefresh} />
|
||||
}
|
||||
contentContainerStyle={{ padding: 16 }}
|
||||
/>
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
38
apps/frontend/app/+html.tsx
Normal file
38
apps/frontend/app/+html.tsx
Normal file
@ -0,0 +1,38 @@
|
||||
import { ScrollViewStyleReset } from 'expo-router/html';
|
||||
|
||||
// This file is web-only and used to configure the root HTML for every
|
||||
// web page during static rendering.
|
||||
// The contents of this function only run in Node.js environments and
|
||||
// do not have access to the DOM or browser APIs.
|
||||
export default function Root({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charSet="utf-8" />
|
||||
<meta httpEquiv="X-UA-Compatible" content="IE=edge" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no" />
|
||||
|
||||
{/*
|
||||
Disable body scrolling on web. This makes ScrollView components work closer to how they do on native.
|
||||
However, body scrolling is often nice to have for mobile web. If you want to enable it, remove this line.
|
||||
*/}
|
||||
<ScrollViewStyleReset />
|
||||
|
||||
{/* Using raw CSS styles as an escape-hatch to ensure the background color never flickers in dark-mode. */}
|
||||
<style dangerouslySetInnerHTML={{ __html: responsiveBackground }} />
|
||||
{/* Add any additional <head> elements that you want globally available on web... */}
|
||||
</head>
|
||||
<body>{children}</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
|
||||
const responsiveBackground = `
|
||||
body {
|
||||
background-color: #fff;
|
||||
}
|
||||
@media (prefers-color-scheme: dark) {
|
||||
body {
|
||||
background-color: #000;
|
||||
}
|
||||
}`;
|
||||
18
apps/frontend/app/+not-found.tsx
Normal file
18
apps/frontend/app/+not-found.tsx
Normal file
@ -0,0 +1,18 @@
|
||||
import { Link, Stack } from 'expo-router';
|
||||
|
||||
import { Text, View } from '@/components/Themed';
|
||||
|
||||
export default function NotFoundScreen() {
|
||||
return (
|
||||
<>
|
||||
<Stack.Screen options={{ title: 'Oops!' }} />
|
||||
<View className="flex-1 items-center justify-center p-5">
|
||||
<Text className="text-xl font-bold">This screen doesn't exist.</Text>
|
||||
|
||||
<Link href="/" className="mt-4 py-4">
|
||||
<Text className="text-sm" style={{ color: '#2e78b7' }}>Go to home screen!</Text>
|
||||
</Link>
|
||||
</View>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@ -1,18 +1,82 @@
|
||||
import "../global.css";
|
||||
import '../global.css';
|
||||
import { DarkTheme, DefaultTheme, ThemeProvider } from '@react-navigation/native';
|
||||
import { useFonts } from 'expo-font';
|
||||
import { Stack, useRouter, useSegments } from 'expo-router';
|
||||
import * as SplashScreen from 'expo-splash-screen';
|
||||
import { useEffect } from 'react';
|
||||
// import 'react-native-reanimated';
|
||||
|
||||
import { SplashScreen, Stack } from "expo-router";
|
||||
import { StatusBar } from "expo-status-bar";
|
||||
import { useColorScheme } from '@/components/useColorScheme';
|
||||
import { AuthProvider, useAuth } from '@/lib/auth';
|
||||
|
||||
// SplashScreen.preventAutoHideAsync();
|
||||
export {
|
||||
ErrorBoundary,
|
||||
} from 'expo-router';
|
||||
|
||||
// export default function Layout() {
|
||||
// return (
|
||||
// <>
|
||||
// <Stack>
|
||||
// <Stack.Screen name="index" />
|
||||
// </Stack>
|
||||
// <StatusBar />
|
||||
// </>
|
||||
// );
|
||||
// }
|
||||
export const unstable_settings = {
|
||||
initialRouteName: '(tabs)',
|
||||
};
|
||||
|
||||
SplashScreen.preventAutoHideAsync();
|
||||
|
||||
export default function RootLayout() {
|
||||
const [loaded, error] = useFonts({
|
||||
SpaceMono: require('../assets/fonts/SpaceMono-Regular.ttf'),
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (error) throw error;
|
||||
}, [error]);
|
||||
|
||||
useEffect(() => {
|
||||
if (loaded) {
|
||||
SplashScreen.hideAsync();
|
||||
}
|
||||
}, [loaded]);
|
||||
|
||||
if (!loaded) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<AuthProvider>
|
||||
<RootLayoutNav />
|
||||
</AuthProvider>
|
||||
);
|
||||
}
|
||||
|
||||
function RootLayoutNav() {
|
||||
const colorScheme = useColorScheme();
|
||||
const { user, loading } = useAuth();
|
||||
const segments = useSegments();
|
||||
const router = useRouter();
|
||||
|
||||
useEffect(() => {
|
||||
if (loading) return;
|
||||
|
||||
const inAuthGroup = segments[0] === 'auth';
|
||||
|
||||
if (!user && !inAuthGroup) {
|
||||
router.replace('/auth');
|
||||
} else if (user && inAuthGroup) {
|
||||
router.replace('/(tabs)');
|
||||
}
|
||||
}, [user, loading, segments]);
|
||||
|
||||
return (
|
||||
<ThemeProvider value={colorScheme === 'dark' ? DarkTheme : DefaultTheme}>
|
||||
<Stack
|
||||
screenOptions={{
|
||||
headerShown: false,
|
||||
}}
|
||||
>
|
||||
<Stack.Screen name="auth" options={{ headerShown: false }} />
|
||||
<Stack.Screen name="(tabs)" options={{ headerShown: false }} />
|
||||
<Stack.Screen name="add" options={{ headerShown: false, presentation: 'modal' }} />
|
||||
<Stack.Screen name="save/[id]" options={{ headerShown: false }} />
|
||||
<Stack.Screen name="user/[slug]" options={{ headerShown: false }} />
|
||||
<Stack.Screen name="modal" options={{ headerShown: false, presentation: 'modal' }} />
|
||||
</Stack>
|
||||
</ThemeProvider>
|
||||
);
|
||||
}
|
||||
|
||||
129
apps/frontend/app/auth.tsx
Normal file
129
apps/frontend/app/auth.tsx
Normal file
@ -0,0 +1,129 @@
|
||||
import React, { useState } from 'react';
|
||||
import {
|
||||
TextInput,
|
||||
TouchableOpacity,
|
||||
KeyboardAvoidingView,
|
||||
Platform,
|
||||
ScrollView,
|
||||
Alert,
|
||||
} from 'react-native';
|
||||
import { useRouter } from 'expo-router';
|
||||
import { Text, View } from '@/components/Themed';
|
||||
import { useAuth } from '@/lib/auth';
|
||||
import { useColorScheme } from '@/components/useColorScheme';
|
||||
import Colors from '@/constants/Colors';
|
||||
|
||||
export default function AuthScreen() {
|
||||
|
||||
const [isLogin, setIsLogin] = useState(true);
|
||||
const [email, setEmail] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [name, setName] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const { signIn, signUp } = useAuth();
|
||||
const router = useRouter();
|
||||
const colorScheme = useColorScheme();
|
||||
const colors = Colors[colorScheme ?? 'light'];
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (!email || !password) {
|
||||
Alert.alert('Ошибка', 'Заполните все поля');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!isLogin && !name) {
|
||||
Alert.alert('Ошибка', 'Введите имя');
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
try {
|
||||
if (isLogin) {
|
||||
await signIn(email, password);
|
||||
} else {
|
||||
await signUp(email, password, name);
|
||||
}
|
||||
router.replace('/(tabs)');
|
||||
} catch (error: any) {
|
||||
Alert.alert('Ошибка', error.message || 'Произошла ошибка');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<KeyboardAvoidingView
|
||||
className="flex-1"
|
||||
behavior={Platform.OS === 'ios' ? 'padding' : 'height'}
|
||||
>
|
||||
<ScrollView
|
||||
contentContainerStyle={{ flexGrow: 1, justifyContent: 'center' }}
|
||||
keyboardShouldPersistTaps="handled"
|
||||
>
|
||||
<View className="flex-1 p-5 justify-center">
|
||||
<Text className='text-2xl font-bold mb-6 text-center text-black'>
|
||||
{isLogin ? 'Вход' : 'Регистрация'}
|
||||
</Text>
|
||||
|
||||
{!isLogin && (
|
||||
<TextInput
|
||||
className="h-12 border rounded-lg px-4 mb-4 text-base"
|
||||
style={{ color: colors.text, borderColor: colors.tabIconDefault }}
|
||||
placeholder="Имя"
|
||||
placeholderTextColor={colors.tabIconDefault}
|
||||
value={name}
|
||||
onChangeText={setName}
|
||||
autoCapitalize="words"
|
||||
/>
|
||||
)}
|
||||
|
||||
<TextInput
|
||||
className="h-12 border rounded-lg px-4 mb-4 text-base"
|
||||
style={{ color: colors.text, borderColor: colors.tabIconDefault }}
|
||||
placeholder="Email"
|
||||
placeholderTextColor={colors.tabIconDefault}
|
||||
value={email}
|
||||
onChangeText={setEmail}
|
||||
keyboardType="email-address"
|
||||
autoCapitalize="none"
|
||||
autoComplete="email"
|
||||
/>
|
||||
|
||||
<TextInput
|
||||
className="h-12 border rounded-lg px-4 mb-4 text-base"
|
||||
style={{ color: colors.text, borderColor: colors.tabIconDefault }}
|
||||
placeholder="Пароль"
|
||||
placeholderTextColor={colors.tabIconDefault}
|
||||
value={password}
|
||||
onChangeText={setPassword}
|
||||
secureTextEntry
|
||||
autoCapitalize="none"
|
||||
/>
|
||||
|
||||
<TouchableOpacity
|
||||
className="h-12 rounded-lg justify-center items-center mt-2"
|
||||
style={{ backgroundColor: colors.tint }}
|
||||
onPress={handleSubmit}
|
||||
disabled={loading}
|
||||
>
|
||||
<Text className="text-base font-semibold" style={{ color: '#fff' }}>
|
||||
{loading ? 'Загрузка...' : isLogin ? 'Войти' : 'Зарегистрироваться'}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
|
||||
<TouchableOpacity
|
||||
className="mt-5 items-center"
|
||||
onPress={() => setIsLogin(!isLogin)}
|
||||
>
|
||||
<Text className="text-sm" style={{ color: colors.tint }}>
|
||||
{isLogin
|
||||
? 'Нет аккаунта? Зарегистрироваться'
|
||||
: 'Уже есть аккаунт? Войти'}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</ScrollView>
|
||||
</KeyboardAvoidingView>
|
||||
);
|
||||
}
|
||||
|
||||
BIN
apps/frontend/assets/fonts/SpaceMono-Regular.ttf
Normal file
BIN
apps/frontend/assets/fonts/SpaceMono-Regular.ttf
Normal file
Binary file not shown.
BIN
apps/frontend/assets/images/adaptive-icon.png
Normal file
BIN
apps/frontend/assets/images/adaptive-icon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 17 KiB |
BIN
apps/frontend/assets/images/favicon.png
Normal file
BIN
apps/frontend/assets/images/favicon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.4 KiB |
BIN
apps/frontend/assets/images/icon.png
Normal file
BIN
apps/frontend/assets/images/icon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 22 KiB |
BIN
apps/frontend/assets/images/splash-icon.png
Normal file
BIN
apps/frontend/assets/images/splash-icon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 17 KiB |
45
apps/frontend/components/Themed.tsx
Normal file
45
apps/frontend/components/Themed.tsx
Normal file
@ -0,0 +1,45 @@
|
||||
/**
|
||||
* Learn more about Light and Dark modes:
|
||||
* https://docs.expo.io/guides/color-schemes/
|
||||
*/
|
||||
|
||||
import { Text as DefaultText, View as DefaultView } from 'react-native';
|
||||
|
||||
import Colors from '@/constants/Colors';
|
||||
import { useColorScheme } from './useColorScheme';
|
||||
|
||||
type ThemeProps = {
|
||||
lightColor?: string;
|
||||
darkColor?: string;
|
||||
};
|
||||
|
||||
export type TextProps = ThemeProps & DefaultText['props'];
|
||||
export type ViewProps = ThemeProps & DefaultView['props'];
|
||||
|
||||
export function useThemeColor(
|
||||
props: { light?: string; dark?: string },
|
||||
colorName: keyof typeof Colors.light & keyof typeof Colors.dark
|
||||
) {
|
||||
const theme = useColorScheme() ?? 'light';
|
||||
const colorFromProps = props[theme];
|
||||
|
||||
if (colorFromProps) {
|
||||
return colorFromProps;
|
||||
} else {
|
||||
return Colors[theme][colorName];
|
||||
}
|
||||
}
|
||||
|
||||
export function Text(props: TextProps) {
|
||||
const { style, lightColor, darkColor, ...otherProps } = props;
|
||||
const color = useThemeColor({ light: lightColor, dark: darkColor }, 'text');
|
||||
|
||||
return <DefaultText style={[{ color }, style]} {...otherProps} />;
|
||||
}
|
||||
|
||||
export function View(props: ViewProps) {
|
||||
const { style, lightColor, darkColor, ...otherProps } = props;
|
||||
const backgroundColor = useThemeColor({ light: lightColor, dark: darkColor }, 'background');
|
||||
|
||||
return <DefaultView style={[{ backgroundColor }, style]} {...otherProps} />;
|
||||
}
|
||||
1
apps/frontend/components/useColorScheme.ts
Normal file
1
apps/frontend/components/useColorScheme.ts
Normal file
@ -0,0 +1 @@
|
||||
export { useColorScheme } from 'react-native';
|
||||
7
apps/frontend/config/api.ts
Normal file
7
apps/frontend/config/api.ts
Normal file
@ -0,0 +1,7 @@
|
||||
// Конфигурация API
|
||||
export const API_BASE_URL = __DEV__
|
||||
? 'http://localhost:3000'
|
||||
: process.env.EXPO_PUBLIC_API_URL || 'http://localhost:3000';
|
||||
|
||||
export const AUTH_BASE_URL = `${API_BASE_URL}/auth/api`;
|
||||
|
||||
19
apps/frontend/constants/Colors.ts
Normal file
19
apps/frontend/constants/Colors.ts
Normal file
@ -0,0 +1,19 @@
|
||||
const tintColorLight = '#a340dd';
|
||||
const tintColorDark = '#fff';
|
||||
|
||||
export default {
|
||||
light: {
|
||||
text: '#000',
|
||||
background: '#fff',
|
||||
tint: tintColorLight,
|
||||
tabIconDefault: '#ccc',
|
||||
tabIconSelected: tintColorLight,
|
||||
},
|
||||
dark: {
|
||||
text: '#fff',
|
||||
background: '#000',
|
||||
tint: tintColorDark,
|
||||
tabIconDefault: '#ccc',
|
||||
tabIconSelected: tintColorDark,
|
||||
},
|
||||
};
|
||||
@ -1,3 +1,3 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
@tailwind utilities;
|
||||
162
apps/frontend/lib/api.ts
Normal file
162
apps/frontend/lib/api.ts
Normal file
@ -0,0 +1,162 @@
|
||||
import { API_BASE_URL } from '@/config/api';
|
||||
import type {
|
||||
SaveListItem,
|
||||
SaveDetailResponse,
|
||||
CreateSaveFromUrlRequest,
|
||||
UpdateSaveRequest,
|
||||
SaveResponse,
|
||||
User
|
||||
} from '@shared-types';
|
||||
|
||||
// Получить токен из сессии Better Auth
|
||||
async function getAuthToken(): Promise<string | null> {
|
||||
// Better Auth хранит токен в cookies, но для React Native нужно использовать другой подход
|
||||
// Временно возвращаем null, токен будет передаваться через headers в Better Auth клиенте
|
||||
return null;
|
||||
}
|
||||
|
||||
async function apiRequest<T>(
|
||||
endpoint: string,
|
||||
options: RequestInit = {}
|
||||
): Promise<T> {
|
||||
const token = await getAuthToken();
|
||||
|
||||
const headers: Record<string, string> = {
|
||||
'Content-Type': 'application/json',
|
||||
...(options.headers as Record<string, string> || {}),
|
||||
};
|
||||
|
||||
if (token) {
|
||||
headers['Authorization'] = `Bearer ${token}`;
|
||||
}
|
||||
|
||||
const response = await fetch(`${API_BASE_URL}${endpoint}`, {
|
||||
...options,
|
||||
headers,
|
||||
credentials: 'include', // Для cookies Better Auth
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json().catch(() => ({ error: 'Unknown error' }));
|
||||
throw new Error(error.error || `HTTP error! status: ${response.status}`);
|
||||
}
|
||||
|
||||
return response.json();
|
||||
}
|
||||
|
||||
// API для работы с сейвами
|
||||
export const savesApi = {
|
||||
// Получить все сейвы текущего пользователя
|
||||
async getMySaves(): Promise<SaveListItem[]> {
|
||||
return apiRequest<SaveListItem[]>('/saves/my');
|
||||
},
|
||||
|
||||
// Получить сейв по ID
|
||||
async getSaveById(id: number, shareToken?: string): Promise<SaveDetailResponse> {
|
||||
const url = shareToken
|
||||
? `/saves/${id}?share=${shareToken}`
|
||||
: `/saves/${id}`;
|
||||
return apiRequest<SaveDetailResponse>(url);
|
||||
},
|
||||
|
||||
// Создать сейв из URL
|
||||
async createFromUrl(data: CreateSaveFromUrlRequest): Promise<SaveResponse> {
|
||||
return apiRequest<SaveResponse>('/saves/external', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
},
|
||||
|
||||
// Загрузить файл
|
||||
async uploadFile(
|
||||
file: File | { uri: string; type: string; name: string },
|
||||
metadata?: {
|
||||
name?: string;
|
||||
description?: string;
|
||||
tags?: string[];
|
||||
visibility?: 'public' | 'link';
|
||||
}
|
||||
): Promise<SaveResponse> {
|
||||
const formData = new FormData();
|
||||
|
||||
// Для React Native используем другой формат
|
||||
if ('uri' in file) {
|
||||
// React Native
|
||||
formData.append('file', {
|
||||
uri: file.uri,
|
||||
type: file.type,
|
||||
name: file.name,
|
||||
} as any);
|
||||
} else {
|
||||
// Web
|
||||
formData.append('file', file);
|
||||
}
|
||||
|
||||
if (metadata?.name) formData.append('name', metadata.name);
|
||||
if (metadata?.description) formData.append('description', metadata.description);
|
||||
if (metadata?.tags) {
|
||||
metadata.tags.forEach(tag => formData.append('tags[]', tag));
|
||||
}
|
||||
if (metadata?.visibility) formData.append('visibility', metadata.visibility);
|
||||
|
||||
const token = await getAuthToken();
|
||||
const headers: Record<string, string> = {};
|
||||
if (token) {
|
||||
headers['Authorization'] = `Bearer ${token}`;
|
||||
}
|
||||
// Не устанавливаем Content-Type для FormData - браузер/платформа сделает это автоматически
|
||||
|
||||
const response = await fetch(`${API_BASE_URL}/saves/upload`, {
|
||||
method: 'POST',
|
||||
headers,
|
||||
body: formData,
|
||||
credentials: 'include',
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json().catch(() => ({ error: 'Unknown error' }));
|
||||
throw new Error(error.error || `HTTP error! status: ${response.status}`);
|
||||
}
|
||||
|
||||
return response.json();
|
||||
},
|
||||
|
||||
// Обновить сейв
|
||||
async updateSave(id: number, data: UpdateSaveRequest): Promise<SaveDetailResponse> {
|
||||
return apiRequest<SaveDetailResponse>(`/saves/${id}`, {
|
||||
method: 'PATCH',
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
},
|
||||
|
||||
// Удалить сейв
|
||||
async deleteSave(id: number): Promise<{ success: boolean; message: string }> {
|
||||
return apiRequest<{ success: boolean; message: string }>(`/saves/${id}`, {
|
||||
method: 'DELETE',
|
||||
});
|
||||
},
|
||||
|
||||
// Получить URL для скачивания
|
||||
getDownloadUrl(id: number, shareToken?: string): string {
|
||||
const baseUrl = `${API_BASE_URL}/saves/${id}/download`;
|
||||
return shareToken ? `${baseUrl}?share=${shareToken}` : baseUrl;
|
||||
},
|
||||
|
||||
// Получить публичные сейвы пользователя по slug (userId)
|
||||
async getPublicSavesByUser(slug: string): Promise<SaveListItem[]> {
|
||||
return apiRequest<SaveListItem[]>(`/saves/u/${slug}`);
|
||||
},
|
||||
};
|
||||
|
||||
// API для работы с пользователями
|
||||
export const usersApi = {
|
||||
// Получить пользователя по имени
|
||||
async getUserByName(name: string): Promise<User> {
|
||||
return apiRequest<User>(`/users/by-name?name=${encodeURIComponent(name)}`);
|
||||
},
|
||||
|
||||
// Получить пользователя по ID
|
||||
async getUserById(id: string): Promise<User> {
|
||||
return apiRequest<User>(`/users/${id}`);
|
||||
},
|
||||
};
|
||||
144
apps/frontend/lib/auth.tsx
Normal file
144
apps/frontend/lib/auth.tsx
Normal file
@ -0,0 +1,144 @@
|
||||
import React, { createContext, useContext, useEffect, useState, ReactNode } from 'react';
|
||||
import { createAuthClient } from 'better-auth/react';
|
||||
import { AUTH_BASE_URL } from '@/config/api';
|
||||
import type { User } from '@shared-types';
|
||||
|
||||
// Создаем клиент Better Auth
|
||||
export const authClient = createAuthClient({
|
||||
baseURL: AUTH_BASE_URL,
|
||||
});
|
||||
|
||||
interface AuthContextType {
|
||||
user: User | null;
|
||||
session: any | null;
|
||||
loading: boolean;
|
||||
signIn: (email: string, password: string) => Promise<void>;
|
||||
signUp: (email: string, password: string, name: string) => Promise<void>;
|
||||
signOut: () => Promise<void>;
|
||||
refresh: () => Promise<void>;
|
||||
}
|
||||
|
||||
const AuthContext = createContext<AuthContextType | undefined>(undefined);
|
||||
|
||||
export function AuthProvider({ children }: { children: ReactNode }) {
|
||||
const [user, setUser] = useState<User | null>(null);
|
||||
const [session, setSession] = useState<any | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
const refresh = async () => {
|
||||
try {
|
||||
const sessionData = await authClient.getSession();
|
||||
setSession(sessionData.data?.session || null);
|
||||
const userData = sessionData.data?.user;
|
||||
if (userData) {
|
||||
setUser({
|
||||
...userData,
|
||||
image: userData.image ?? null,
|
||||
} as User);
|
||||
} else {
|
||||
setUser(null);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Ошибка получения сессии:', error);
|
||||
setSession(null);
|
||||
setUser(null);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
refresh();
|
||||
}, []);
|
||||
|
||||
const signIn = async (email: string, password: string) => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const result = await authClient.signIn.email({
|
||||
email,
|
||||
password,
|
||||
});
|
||||
|
||||
if (result.data?.user) {
|
||||
const userData = result.data.user;
|
||||
setUser({
|
||||
...userData,
|
||||
image: userData.image ?? null,
|
||||
} as User);
|
||||
setSession(result.data as any);
|
||||
} else {
|
||||
throw new Error('Ошибка входа');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Ошибка входа:', error);
|
||||
throw error;
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const signUp = async (email: string, password: string, name: string) => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const result = await authClient.signUp.email({
|
||||
email,
|
||||
password,
|
||||
name,
|
||||
});
|
||||
|
||||
if (result.data?.user) {
|
||||
const userData = result.data.user;
|
||||
setUser({
|
||||
...userData,
|
||||
image: userData.image ?? null,
|
||||
} as User);
|
||||
setSession(result.data as any);
|
||||
} else {
|
||||
throw new Error('Ошибка регистрации');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Ошибка регистрации:', error);
|
||||
throw error;
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const signOut = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
await authClient.signOut();
|
||||
setUser(null);
|
||||
setSession(null);
|
||||
} catch (error) {
|
||||
console.error('Ошибка выхода:', error);
|
||||
throw error;
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<AuthContext.Provider
|
||||
value={{
|
||||
user,
|
||||
session,
|
||||
loading,
|
||||
signIn,
|
||||
signUp,
|
||||
signOut,
|
||||
refresh,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</AuthContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useAuth() {
|
||||
const context = useContext(AuthContext);
|
||||
if (context === undefined) {
|
||||
throw new Error('useAuth must be used within an AuthProvider');
|
||||
}
|
||||
return context;
|
||||
}
|
||||
@ -1,6 +1,8 @@
|
||||
const { getDefaultConfig } = require("expo/metro-config");
|
||||
const { withNativeWind } = require("nativewind/metro");
|
||||
const { withNativeWind } = require('nativewind/metro');
|
||||
|
||||
const config = getDefaultConfig(__dirname);
|
||||
|
||||
module.exports = withNativeWind(config, { input: "./global.css" });
|
||||
module.exports = withNativeWind(config, {
|
||||
input: './global.css'
|
||||
});
|
||||
2
apps/frontend/nativewind-env.d.ts
vendored
2
apps/frontend/nativewind-env.d.ts
vendored
@ -1 +1 @@
|
||||
/// <reference types="nativewind/types" />
|
||||
/// <reference types="nativewind/types" />
|
||||
@ -1,38 +1,44 @@
|
||||
{
|
||||
"name": "frontend",
|
||||
"version": "1.0.0",
|
||||
"main": "expo-router/entry",
|
||||
"scripts": {
|
||||
"start": "expo start",
|
||||
"android": "expo start --android",
|
||||
"ios": "expo start --ios",
|
||||
"web": "expo start --web",
|
||||
"format": "biome format --write"
|
||||
},
|
||||
"dependencies": {
|
||||
"expo": "~54.0.13",
|
||||
"expo-constants": "^18.0.9",
|
||||
"expo-linking": "^8.0.8",
|
||||
"expo-router": "^6.0.12",
|
||||
"expo-server": "^1.0.1",
|
||||
"expo-status-bar": "^3.0.8",
|
||||
"nativewind": "^4.2.1",
|
||||
"react": "19.1.0",
|
||||
"react-dom": "19.1.0",
|
||||
"react-native": "0.81.4",
|
||||
"react-native-reanimated": "~3.17.4",
|
||||
"react-native-safe-area-context": "5.4.0",
|
||||
"react-native-screens": "^4.17.1",
|
||||
"react-native-web": "^0.21.2",
|
||||
"react-native-worklets": "^0.6.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@biomejs/biome": "2.2.6",
|
||||
"@types/bun": "^1.3.0",
|
||||
"@types/react": "~19.1.0",
|
||||
"prettier-plugin-tailwindcss": "^0.5.11",
|
||||
"tailwindcss": "^3.4.17",
|
||||
"typescript": "~5.9.2"
|
||||
},
|
||||
"private": true
|
||||
"name": "frontend",
|
||||
"main": "expo-router/entry",
|
||||
"version": "1.0.0",
|
||||
"scripts": {
|
||||
"start": "expo start",
|
||||
"android": "expo start --android",
|
||||
"ios": "expo start --ios",
|
||||
"web": "expo start --web"
|
||||
},
|
||||
"dependencies": {
|
||||
"@expo/vector-icons": "^15.0.3",
|
||||
"@react-navigation/native": "^7.1.8",
|
||||
"better-auth": "^1.0.0",
|
||||
"expo": "~54.0.23",
|
||||
"expo-constants": "~18.0.10",
|
||||
"expo-font": "~14.0.9",
|
||||
"expo-image-picker": "~16.0.3",
|
||||
"expo-linking": "~8.0.8",
|
||||
"expo-router": "~6.0.14",
|
||||
"expo-splash-screen": "~31.0.10",
|
||||
"expo-status-bar": "~3.0.8",
|
||||
"expo-video": "^3.0.14",
|
||||
"expo-web-browser": "~15.0.9",
|
||||
"lucide-react-native": "^0.553.0",
|
||||
"nativewind": "^4.2.1",
|
||||
"react": "19.1.0",
|
||||
"react-dom": "19.1.0",
|
||||
"react-native": "0.81.5",
|
||||
"react-native-reanimated": "~3.17.4",
|
||||
"react-native-safe-area-context": "5.4.0",
|
||||
"react-native-screens": "~4.16.0",
|
||||
"react-native-web": "~0.21.0",
|
||||
"react-native-worklets": "0.5.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "~19.1.0",
|
||||
"prettier-plugin-tailwindcss": "^0.5.11",
|
||||
"react-test-renderer": "19.1.0",
|
||||
"tailwindcss": "^3.4.17",
|
||||
"typescript": "~5.9.2"
|
||||
},
|
||||
"private": true
|
||||
}
|
||||
|
||||
@ -1,7 +1,11 @@
|
||||
/** @type {import('tailwindcss').Config} */
|
||||
module.exports = {
|
||||
// NOTE: Update this to include the paths to all files that contain Nativewind classes.
|
||||
content: ["./app/*.{ts,tsx}", "./components/**/*.{ts,tsx}"],
|
||||
content: [
|
||||
"./app/**/*.{ts,tsx}",
|
||||
"./components/**/*.{ts,tsx}",
|
||||
"./lib/**/*.{ts,tsx}",
|
||||
],
|
||||
presets: [require("nativewind/preset")],
|
||||
theme: {
|
||||
extend: {},
|
||||
|
||||
@ -1,6 +1,21 @@
|
||||
{
|
||||
"extends": "expo/tsconfig.base",
|
||||
"compilerOptions": {
|
||||
"strict": true
|
||||
}
|
||||
}
|
||||
"extends": "expo/tsconfig.base",
|
||||
"compilerOptions": {
|
||||
"strict": true,
|
||||
"paths": {
|
||||
"@/*": [
|
||||
"./*"
|
||||
],
|
||||
"@shared-types": [
|
||||
"../../packages/shared-types"
|
||||
]
|
||||
}
|
||||
},
|
||||
"include": [
|
||||
"**/*.ts",
|
||||
"**/*.tsx",
|
||||
".expo/types/**/*.ts",
|
||||
"expo-env.d.ts",
|
||||
"nativewind-env.d.ts"
|
||||
]
|
||||
}
|
||||
Reference in New Issue
Block a user