diff --git a/apps/frontend/app/(tabs)/two.tsx b/apps/frontend/app/(tabs)/two.tsx new file mode 100644 index 0000000..b6d80a3 --- /dev/null +++ b/apps/frontend/app/(tabs)/two.tsx @@ -0,0 +1,86 @@ +import React, { useState } from 'react'; +import { + TextInput, + View as RNView, + TouchableOpacity, + Alert, + ActivityIndicator, +} from 'react-native'; +import { useRouter } from 'expo-router'; +import { Text, View } from '@/components/Themed'; +import { useColorScheme } from '@/components/useColorScheme'; +import Colors from '@/constants/Colors'; +import { usersApi } from '@/lib/api'; +import { Search, User } from 'lucide-react-native'; + +export default function SearchScreen() { + const [searchQuery, setSearchQuery] = useState(''); + const [loading, setLoading] = useState(false); + const colorScheme = useColorScheme(); + const colors = Colors[colorScheme ?? 'light']; + const router = useRouter(); + + const handleSearch = async () => { + const query = searchQuery.trim(); + + if (!query) { + Alert.alert('Ошибка', 'Введите имя пользователя'); + return; + } + + setLoading(true); + try { + const user = await usersApi.getUserByName(query); + router.push(`/user/${user.id}` as any); + setSearchQuery(''); + } + catch (error: any) { + Alert.alert('Пользователь не найден', error.message || 'Пользователь с таким именем не существует'); + } + finally { + setLoading(false); + } + }; + + return ( + + + + + + {loading ? ( + + ) : ( + + )} + + + + + + Поиск пользователей + + Введите имя пользователя в поле выше и нажмите поиск, чтобы просмотреть его профиль + + + + ); +} diff --git a/apps/frontend/app/add.tsx b/apps/frontend/app/add.tsx new file mode 100644 index 0000000..2cd8009 --- /dev/null +++ b/apps/frontend/app/add.tsx @@ -0,0 +1,212 @@ +import React, { useState } from 'react'; +import { + TextInput, + TouchableOpacity, + ScrollView, + Alert, + ActivityIndicator, + Platform, +} from 'react-native'; +import { useRouter } from 'expo-router'; +import * as ImagePicker from 'expo-image-picker'; +import { Text, View } from '@/components/Themed'; +import { savesApi } from '@/lib/api'; +import { useColorScheme } from '@/components/useColorScheme'; +import Colors from '@/constants/Colors'; + +export default function AddSaveScreen() { + const [mode, setMode] = useState<'upload' | 'url'>('upload'); + const [url, setUrl] = useState(''); + const [name, setName] = useState(''); + const [description, setDescription] = useState(''); + const [tags, setTags] = useState(''); + const [visibility, setVisibility] = useState<'public' | 'link'>('link'); + const [loading, setLoading] = useState(false); + const router = useRouter(); + const colorScheme = useColorScheme(); + const colors = Colors[colorScheme ?? 'light']; + + const handleUpload = async () => { + if (mode === 'url') { + if (!url) { + Alert.alert('Ошибка', 'Введите URL'); + return; + } + } + + setLoading(true); + try { + if (mode === 'url') { + const tagsArray = tags + ? tags.split(',').map((t) => t.trim()).filter(Boolean) + : undefined; + + await savesApi.createFromUrl({ + url, + name: name || undefined, + description: description || undefined, + tags: tagsArray, + visibility, + }); + } else { + // Загрузка файла + const result = await ImagePicker.launchImageLibraryAsync({ + mediaTypes: ImagePicker.MediaTypeOptions.All, + allowsEditing: false, + quality: 1, + }); + + if (result.canceled) { + setLoading(false); + return; + } + + const asset = result.assets[0]; + const file = { + uri: asset.uri, + type: asset.mimeType || 'image/jpeg', + name: asset.fileName || `image.${asset.uri.split('.').pop()}`, + }; + + const tagsArray = tags + ? tags.split(',').map((t) => t.trim()).filter(Boolean) + : undefined; + + await savesApi.uploadFile(file, { + name: name || undefined, + description: description || undefined, + tags: tagsArray, + visibility, + }); + } + + Alert.alert('Успех', 'Сейв успешно создан', [ + { text: 'OK', onPress: () => router.back() }, + ]); + } catch (error: any) { + Alert.alert('Ошибка', error.message || 'Не удалось создать сейв'); + } finally { + setLoading(false); + } + }; + + return ( + + + Добавить сейв + + + setMode('upload')} + > + + Загрузить файл + + + setMode('url')} + > + + По URL + + + + + {mode === 'url' && ( + + )} + + + + + + + + + Видимость: + setVisibility('public')} + > + + Публичный + + + setVisibility('link')} + > + + По ссылке + + + + + + {loading ? ( + + ) : ( + + {mode === 'url' ? 'Создать из URL' : 'Выбрать файл'} + + )} + + + + ); +} diff --git a/apps/frontend/app/save/[id].tsx b/apps/frontend/app/save/[id].tsx new file mode 100644 index 0000000..0a71ecd --- /dev/null +++ b/apps/frontend/app/save/[id].tsx @@ -0,0 +1,432 @@ +import React, { useEffect, useState } from 'react'; +import { + ScrollView, + Image, + TouchableOpacity, + TextInput, + Alert, + ActivityIndicator, + Share, +} from 'react-native'; +import { useRouter, useLocalSearchParams } 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 { API_BASE_URL } from '@/config/api'; +import type { SaveDetailResponse } from '@shared-types'; +import { useColorScheme } from '@/components/useColorScheme'; +import Colors from '@/constants/Colors'; +import { File, X, Check, Copy, Share as ShareIcon, Edit, Trash, User } from 'lucide-react-native'; + +export default function SaveDetailScreen() { + const { id } = useLocalSearchParams<{ id: string }>(); + const [save, setSave] = useState(null); + const [loading, setLoading] = useState(true); + const [editing, setEditing] = useState(false); + const [name, setName] = useState(''); + const [description, setDescription] = useState(''); + const [tags, setTags] = useState(''); + const [visibility, setVisibility] = useState<'public' | 'link'>('link'); + const [saving, setSaving] = useState(false); + const { user } = useAuth(); + const router = useRouter(); + const colorScheme = useColorScheme(); + const colors = Colors[colorScheme ?? 'light']; + + useEffect(() => { + loadSave(); + }, [id]); + + const loadSave = async () => { + try { + const data = await savesApi.getSaveById(Number(id)); + setSave(data); + setName(data.name || ''); + setDescription(data.description || ''); + setTags(data.tags.join(', ') || ''); + setVisibility(data.visibility); + } + catch (error: any) { + Alert.alert('Ошибка', error.message || 'Не удалось загрузить сейв'); + router.back(); + } + finally { + setLoading(false); + } + }; + + const handleSave = async () => { + setSaving(true); + try { + const tagsArray = tags + ? tags.split(',').map((t) => t.trim()).filter(Boolean) + : []; + + const updated = await savesApi.updateSave(Number(id), { + name: name || undefined, + description: description || undefined, + tags: tagsArray, + visibility, + }); + + setSave(updated as SaveDetailResponse); + setEditing(false); + Alert.alert('Успех', 'Сейв обновлен'); + } + catch (error: any) { + Alert.alert('Ошибка', error.message || 'Не удалось обновить сейв'); + } + finally { + setSaving(false); + } + }; + + const handleDelete = () => { + Alert.alert( + 'Удаление', + 'Вы уверены, что хотите удалить этот сейв?', + [ + { text: 'Отмена', style: 'cancel' }, + { + text: 'Удалить', + style: 'destructive', + onPress: async () => { + try { + await savesApi.deleteSave(Number(id)); + Alert.alert('Успех', 'Сейв удален', [ + { text: 'OK', onPress: () => router.back() }, + ]); + } catch (error: any) { + Alert.alert('Ошибка', error.message || 'Не удалось удалить сейв'); + } + }, + }, + ] + ); + }; + + const handleShare = async () => { + if (!save) return; + + const shareUrl = save.url; + + if (shareUrl) { + try { + await Share.share({ + message: shareUrl, + url: shareUrl, + }); + } + catch (error) { + console.error('Ошибка при попытке поделиться:', error); + } + } + }; + + const handleCopyShareUrl = async () => { + if (!save || !save.shareUrl) return; + + const shareUrl = `${API_BASE_URL}/saves/${save.id}?share=${save.shareUrl}`; + + try { + await Share.share({ + message: `Ссылка для доступа к сейву: ${shareUrl}`, + url: shareUrl, + }); + } + catch (error) { + Alert.alert( + 'Ссылка для доступа', + `Скопируйте эту ссылку:\n\n${shareUrl}`, + [ + { text: 'OK' }, + ] + ); + } + }; + + const handleViewUserProfile = () => { + if (!save || !save.userId) return; + router.push(`/user/${save.userId}` as any); + }; + + const videoPlayer = useVideoPlayer( + save?.type === 'video' ? save?.url ?? null : null, + (player) => { + player.muted = false; + } + ); + + useEffect(() => { + const updateVideoSource = async () => { + if (save?.type === 'video' && save?.url) { + await videoPlayer.replaceAsync(save.url); + } + else if (save && save.type !== 'video') { + await videoPlayer.replaceAsync(null); + } + }; + updateVideoSource(); + }, [save?.type, save?.url, videoPlayer]); + + if (loading) { + return ( + + + + ); + } + + if (!save) { + return null; + } + + const isOwner = user?.id === save.userId; + const mediaUrl = save.url; + + const renderMedia = () => { + if (save.type === 'video') { + return ( + + ); + } + else if (save.type === 'image' || save.type === 'gif') { + return ( + + ); + } + else { + return ( + + + {save.type} + + ); + } + }; + + return ( + + + {renderMedia()} + + + + {editing ? 'Редактирование' : save.name || 'Без названия'} + + + {isOwner ? ( + editing ? ( + <> + { + setEditing(false); + setName(save.name || ''); + setDescription(save.description || ''); + setTags(save.tags.join(', ') || ''); + setVisibility(save.visibility); + }} + className="p-2" + > + + + + {saving ? ( + + ) : ( + + )} + + + ) : ( + <> + {save.shareUrl && ( + + + + )} + + + + setEditing(true)} + className="p-2" + > + + + + + + + ) + ) : ( + <> + {save.shareUrl && ( + + + + )} + + + + + + + + )} + + + + {editing ? ( + <> + + + + + Видимость: + setVisibility('public')} + > + + Публичный + + + setVisibility('link')} + > + + По ссылке + + + + + ) : ( + <> + {save.description && ( + {save.description} + )} + {save.tags.length > 0 && ( + + {save.tags.map((tag, idx) => ( + + + {tag} + + + ))} + + )} + + {!isOwner && save.userId && ( + + + + Просмотреть профиль автора + + + )} + + Тип: {save.type} + + + Видимость: {save.visibility === 'public' ? 'Публичный' : 'По ссылке'} + + + Создан: {new Date(save.createdAt).toLocaleString('ru-RU')} + + {save.shareUrl && ( + + + Ссылка для доступа: + + + + + {`${API_BASE_URL}/saves/${save.id}?share=${save.shareUrl}`} + + + + )} + + + )} + + + ); +} diff --git a/apps/frontend/app/user/[slug].tsx b/apps/frontend/app/user/[slug].tsx new file mode 100644 index 0000000..e2ced3c --- /dev/null +++ b/apps/frontend/app/user/[slug].tsx @@ -0,0 +1,184 @@ +import React, { useEffect, useState } from 'react'; +import { + FlatList, + TouchableOpacity, + Image, + RefreshControl, + Alert, + ActivityIndicator, +} from 'react-native'; +import { useRouter, useLocalSearchParams } from 'expo-router'; +import { useVideoPlayer, VideoView } from 'expo-video'; +import { Text, View } from '@/components/Themed'; +import { savesApi } from '@/lib/api'; +import type { SaveListItem } from '@shared-types'; +import { useColorScheme } from '@/components/useColorScheme'; +import Colors from '@/constants/Colors'; +import { File, ArrowLeft, Image as ImageIcon } from 'lucide-react-native'; + +export default function UserProfileScreen() { + const { slug } = useLocalSearchParams<{ slug: string }>(); + const [saves, setSaves] = useState([]); + const [loading, setLoading] = useState(true); + const [refreshing, setRefreshing] = useState(false); + const router = useRouter(); + const colorScheme = useColorScheme(); + const colors = Colors[colorScheme ?? 'light']; + + const loadSaves = async () => { + if (!slug) return; + + try { + const data = await savesApi.getPublicSavesByUser(slug); + setSaves(data); + } + catch (error: any) { + Alert.alert('Ошибка', error.message || 'Не удалось загрузить сейвы'); + } + finally { + setLoading(false); + setRefreshing(false); + } + }; + + useEffect(() => { + if (slug) { + loadSaves(); + } + }, [slug]); + + const handleRefresh = () => { + setRefreshing(true); + loadSaves(); + }; + + const handleSavePress = (save: SaveListItem) => { + router.push(`/save/${save.id}`); + }; + + const SaveItem = React.memo(({ item }: { item: SaveListItem }) => { + if (!item) return null; + + const itemColorScheme = useColorScheme(); + const itemColors = Colors[itemColorScheme ?? 'light']; + const videoPlayer = useVideoPlayer( + item.type === 'video' ? item.url : null, + (player) => { + player.muted = true; + player.loop = true; + } + ); + + return ( + handleSavePress(item)} + > + + + {item.type === 'image' || item.type === 'gif' ? ( + + ) : item.type === 'video' ? ( + + ) : ( + + + + )} + + + + {item.name || 'Без названия'} + + {item.description && ( + + {item.description} + + )} + {item.tags && item.tags.length > 0 && ( + + {item.tags.slice(0, 3).map((tag, idx) => ( + + + {tag} + + + ))} + {item.tags.length > 3 && ( + + +{item.tags.length - 3} + + )} + + )} + + {new Date(item.createdAt).toLocaleDateString('ru-RU')} + + + + + ); + }); + + if (loading) { + return ( + + + + ); + } + + return ( + + + router.back()} + className="p-2" + > + + + Публичные сейвы + + + + {saves.length === 0 ? ( + + + Нет публичных сейвов + + У этого пользователя пока нет публичных сейвов + + + ) : ( + item.id.toString()} + renderItem={({ item }) => } + refreshControl={ + + } + contentContainerStyle={{ padding: 16 }} + /> + )} + + ); +}