Archived
1
0

feat(frontend): Экран авторизации

This commit is contained in:
2025-11-19 17:18:19 +03:00
parent 5f60d12996
commit 20556231b9
26 changed files with 1177 additions and 183 deletions

View File

@ -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
}
}
}

View 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>
);
}

View 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>
);
}

View 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;
}
}`;

View 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>
</>
);
}

View File

@ -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
View 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>
);
}

Binary file not shown.

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

View 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} />;
}

View File

@ -0,0 +1 @@
export { useColorScheme } from 'react-native';

View 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`;

View 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,
},
};

View File

@ -1,3 +1,3 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
@tailwind utilities;

162
apps/frontend/lib/api.ts Normal file
View 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
View 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;
}

View File

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

View File

@ -1 +1 @@
/// <reference types="nativewind/types" />
/// <reference types="nativewind/types" />

View File

@ -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
}

View File

@ -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: {},

View File

@ -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"
]
}