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