Archived
1
0

9 Commits

8 changed files with 1475 additions and 0 deletions

View File

@ -0,0 +1,145 @@
// apps/backend/src/tests/e2e/access-control.test.ts
// Path: apps/backend/src/tests/e2e/access-control.test.ts
import { describe, expect, test, beforeAll } from 'bun:test';
describe('E2E: Access Control', () => {
let user1Cookie: string;
let user1Id: string;
let user2Cookie: string;
let user2Id: string;
let linkSaveId: number;
let linkShareUrl: string;
let publicSaveId: number;
beforeAll(async () => {
// Создаем первого пользователя
const user1SignUp = await fetch('http://localhost:3000/auth/api/sign-up/email', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
name: 'User 1',
email: `user1-${Date.now()}@example.com`,
password: 'Password123!',
}),
});
const user1Data = await user1SignUp.json();
user1Id = user1Data.user.id;
user1Cookie = user1SignUp.headers.get('set-cookie') || '';
// Создаем второго пользователя
const user2SignUp = await fetch('http://localhost:3000/auth/api/sign-up/email', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
name: 'User 2',
email: `user2-${Date.now()}@example.com`,
password: 'Password123!',
}),
});
const user2Data = await user2SignUp.json();
user2Id = user2Data.user.id;
user2Cookie = user2SignUp.headers.get('set-cookie') || '';
// User 1 создает сейв с visibility: link
const linkSaveResponse = await fetch('http://localhost:3000/saves/external', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Cookie': user1Cookie,
},
body: JSON.stringify({
url: 'https://httpbin.org/image/png',
name: 'Link Save',
visibility: 'link',
}),
});
const linkSaveData = await linkSaveResponse.json();
linkSaveId = linkSaveData.id;
linkShareUrl = linkSaveData.shareUrl;
// User 1 создает публичный сейв
const publicSaveResponse = await fetch('http://localhost:3000/saves/external', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Cookie': user1Cookie,
},
body: JSON.stringify({
url: 'https://httpbin.org/image/jpeg',
name: 'Public Save',
visibility: 'public',
}),
});
const publicSaveData = await publicSaveResponse.json();
publicSaveId = publicSaveData.id;
});
test('owner should access link save', async () => {
const response = await fetch(`http://localhost:3000/saves/${linkSaveId}`, {
headers: { 'Cookie': user1Cookie },
});
expect(response.status).toBe(200);
});
test('non-owner should NOT access link save without share token', async () => {
const response = await fetch(`http://localhost:3000/saves/${linkSaveId}`, {
headers: { 'Cookie': user2Cookie },
});
expect(response.status).toBe(404);
});
test('non-owner should access link save WITH share token', async () => {
const response = await fetch(
`http://localhost:3000/saves/${linkSaveId}?share=${linkShareUrl}`,
{
headers: { 'Cookie': user2Cookie },
}
);
expect(response.status).toBe(200);
const data = await response.json();
expect(data.id).toBe(linkSaveId);
});
test('anyone should access public save', async () => {
const response = await fetch(`http://localhost:3000/saves/${publicSaveId}`);
expect(response.status).toBe(200);
const data = await response.json();
expect(data.id).toBe(publicSaveId);
expect(data.visibility).toBe('public');
});
test('non-owner should NOT be able to update save', async () => {
const response = await fetch(`http://localhost:3000/saves/${publicSaveId}`, {
method: 'PATCH',
headers: {
'Content-Type': 'application/json',
'Cookie': user2Cookie,
},
body: JSON.stringify({
name: 'Hacked Name',
}),
});
expect(response.status).toBeGreaterThanOrEqual(400);
});
test('non-owner should NOT be able to delete save', async () => {
const response = await fetch(`http://localhost:3000/saves/${publicSaveId}`, {
method: 'DELETE',
headers: { 'Cookie': user2Cookie },
});
expect(response.status).toBeGreaterThanOrEqual(400);
});
});

View File

@ -0,0 +1,88 @@
// apps/backend/src/tests/e2e/scraper.test.ts
// Path: apps/backend/src/tests/e2e/scraper.test.ts
import { describe, expect, test, beforeAll } from 'bun:test';
describe('E2E: Scraper Integration', () => {
let authCookie: string;
beforeAll(async () => {
const signUpResponse = await fetch('http://localhost:3000/auth/api/sign-up/email', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
name: 'Scraper Test User',
email: `scraper-test-${Date.now()}@example.com`,
password: 'TestPassword123!',
}),
});
authCookie = signUpResponse.headers.get('set-cookie') || '';
});
test('should save from direct image URL', async () => {
const response = await fetch('http://localhost:3000/saves/external', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Cookie': authCookie,
},
body: JSON.stringify({
url: 'https://mrqiz.ru/',
name: 'Direct Image Test',
visibility: 'public',
}),
});
expect(response.status).toBe(200);
const data = await response.json();
expect(data.type).toBe('image');
expect(data.name).toBe('Direct Image Test');
});
// Эти тесты требуют реальных URL
test.skip('should save from Pinterest URL (manual test)', async () => {
// Замените на актуальный Pinterest URL
const pinterestUrl = 'https://www.pinterest.com/pin/ACTUAL_PIN_ID/';
const response = await fetch('http://localhost:3000/saves/external', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Cookie': authCookie,
},
body: JSON.stringify({
url: pinterestUrl,
visibility: 'link',
}),
});
expect(response.status).toBe(200);
const data = await response.json();
expect(['image', 'video', 'gif']).toContain(data.type);
});
test.skip('should save from Tenor GIF (manual test)', async () => {
// Замените на актуальный Tenor URL
const tenorUrl = 'https://tenor.com/view/ACTUAL_GIF_ID';
const response = await fetch('http://localhost:3000/saves/external', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Cookie': authCookie,
},
body: JSON.stringify({
url: tenorUrl,
visibility: 'link',
}),
});
expect(response.status).toBe(200);
const data = await response.json();
expect(data.type).toBe('gif');
});
});

View File

@ -0,0 +1,257 @@
import { describe, expect, test, mock, beforeEach } from 'bun:test';
import { s3Service } from '@/services/s3.service';
// Моки
const mockDb = {
insert: mock(() => ({
values: mock(() => ({
returning: mock(() => [
{
id: 1,
userId: 'user1',
name: 'Test Save',
description: 'Test description',
type: 'image',
tags: ['test'],
visibility: 'link',
shareUrl: 'abc123',
s3Key: 'user1/test.jpg',
url: 'http://s3/bucket/user1/test.jpg',
fileSize: 1024,
mimeType: 'image/jpeg',
createdAt: new Date(),
updatedAt: new Date(),
},
]),
})),
})),
select: mock(() => ({
from: mock(() => ({
where: mock(() => ({
limit: mock(() => [
{
id: 1,
userId: 'user1',
name: 'Test Save',
description: 'Test description',
type: 'image',
tags: ['test'],
visibility: 'link',
shareUrl: 'abc123',
s3Key: 'user1/test.jpg',
url: 'http://s3/bucket/user1/test.jpg',
fileSize: 1024,
mimeType: 'image/jpeg',
createdAt: new Date(),
updatedAt: new Date(),
},
]),
orderBy: mock(() => []),
})),
})),
})),
update: mock(() => ({
set: mock(() => ({
where: mock(() => ({
returning: mock(() => []),
})),
})),
})),
delete: mock(() => ({
where: mock(() => ({})),
})),
};
const mockS3Service = {
uploadFile: mock(async () => ({
key: 'user1/test.jpg',
url: 'http://s3/bucket/user1/test.jpg',
size: 1024,
})),
deleteFile: mock(async () => {}),
downloadFromUrl: mock(async () => ({
buffer: Buffer.from('test'),
mimeType: 'image/jpeg',
size: 4,
})),
getFileType: mock((mimeType: string) => 'image'),
};
describe('SavesService', () => {
beforeEach(() => {
// Сбрасываем моки перед каждым тестом
mock.restore();
});
describe('createFromFile', () => {
test('should create save from uploaded file', async () => {
const file = new File(['test content'], 'test.jpg', { type: 'image/jpeg' });
const userId = 'user1';
const metadata = {
name: 'Test Image',
description: 'A test image',
tags: ['test', 'image'],
visibility: 'link' as const,
};
// Здесь в реальном тесте нужно использовать моки или тестовую БД
// Для примера проверяем структуру
expect(file.type).toBe('image/jpeg');
expect(file.name).toBe('test.jpg');
});
test('should throw error for unsupported file type', async () => {
const file = new File(['test'], 'test.txt', { type: 'text/plain' });
const userId = 'user1';
// В реальном тесте проверяем, что выбрасывается ошибка
expect(() => {
if (s3Service.getFileType(file.type) === 'unknown') {
throw new Error('Unsupported file type');
}
}).toThrow('Unsupported file type');
});
});
describe('createFromUrl', () => {
test('should create save from external URL', async () => {
const userId = 'user1';
const data = {
url: 'https://example.com/image.jpg',
name: 'External Image',
description: 'Downloaded image',
tags: ['external'],
visibility: 'public' as const,
};
// Проверяем валидность данных
expect(data.url).toMatch(/^https?:\/\//);
expect(data.visibility).toBe('public');
});
});
describe('getById', () => {
test('should return save for owner', async () => {
const saveId = 1;
const userId = 'user1';
// Мокаем сейв
const mockSave = {
id: 1,
userId: 'user1',
visibility: 'link',
shareUrl: 'abc123',
};
// Владелец имеет доступ
expect(mockSave.userId).toBe(userId);
});
test('should return save for public visibility', async () => {
const mockSave = {
id: 1,
userId: 'user1',
visibility: 'public',
};
// Публичный сейв доступен всем
expect(mockSave.visibility).toBe('public');
});
test('should return save with valid share token', async () => {
const mockSave = {
id: 1,
userId: 'user1',
visibility: 'link',
shareUrl: 'abc123',
};
const shareToken = 'abc123';
// Доступ по share token
expect(mockSave.shareUrl).toBe(shareToken);
});
test('should not return save without access', async () => {
const mockSave = {
id: 1,
userId: 'user1',
visibility: 'link',
shareUrl: 'abc123',
};
const requestUserId = 'user2';
const shareToken = 'wrong-token';
// Нет доступа
const hasAccess =
mockSave.userId === requestUserId ||
mockSave.visibility === 'public' ||
(mockSave.visibility === 'link' && mockSave.shareUrl === shareToken);
expect(hasAccess).toBe(false);
});
});
describe('update', () => {
test('should update save metadata', async () => {
const saveId = 1;
const userId = 'user1';
const updateData = {
name: 'Updated Name',
description: 'Updated description',
tags: ['updated'],
};
// Проверяем данные для обновления
expect(updateData.name).toBe('Updated Name');
expect(updateData.tags).toContain('updated');
});
test('should generate shareUrl when changing to link visibility', async () => {
const mockSave = {
id: 1,
userId: 'user1',
visibility: 'public',
shareUrl: null,
};
const newVisibility = 'link';
// Должен быть сгенерирован shareUrl
if (newVisibility === 'link' && !mockSave.shareUrl) {
const generatedUrl = 'generated-url';
expect(generatedUrl).toBeTruthy();
}
});
});
describe('delete', () => {
test('should delete save and file from S3', async () => {
const saveId = 1;
const userId = 'user1';
const mockSave = {
id: 1,
userId: 'user1',
s3Key: 'user1/test.jpg',
};
// Проверяем, что есть s3Key для удаления
expect(mockSave.s3Key).toBeTruthy();
});
test('should throw error when deleting non-owned save', async () => {
const mockSave = {
id: 1,
userId: 'user1',
};
const requestUserId = 'user2';
// Проверка владельца
expect(() => {
if (mockSave.userId !== requestUserId) {
throw new Error('Save not found or access denied');
}
}).toThrow('access denied');
});
});
});

View File

@ -0,0 +1,71 @@
import { describe, expect, test } from 'bun:test';
import { scraperService } from '@/services/scraper.service';
describe('ScraperService', () => {
describe('scrapeUrl', () => {
test('should detect direct image URL', async () => {
const url = 'https://example.com/image.jpg';
const result = await scraperService.scrapeUrl(url);
expect(result.source).toBe('direct');
expect(result.type).toBe('image');
expect(result.url).toBe(url);
});
test('should detect direct GIF URL', async () => {
const url = 'https://example.com/animation.gif';
const result = await scraperService.scrapeUrl(url);
expect(result.source).toBe('direct');
expect(result.type).toBe('gif');
expect(result.url).toBe(url);
});
test('should detect direct video URL', async () => {
const url = 'https://example.com/video.mp4';
const result = await scraperService.scrapeUrl(url);
expect(result.source).toBe('direct');
expect(result.type).toBe('video');
expect(result.url).toBe(url);
});
test('should reject unsupported URL', async () => {
const url = 'https://unsupported-site.com/page';
expect(scraperService.scrapeUrl(url)).rejects.toThrow(
'Unsupported URL'
);
});
});
describe('scrapePinterest', () => {
test('should recognize Pinterest URL', async () => {
const url = 'https://www.pinterest.com/pin/123456789/';
try {
const result = await scraperService.scrapeUrl(url);
expect(result.source).toBe('pinterest');
expect(['image', 'video']).toContain(result.type);
} catch (error) {
// В тестовой среде без интернета это нормально
expect(error).toBeDefined();
}
});
});
describe('scrapeTenor', () => {
test('should recognize Tenor URL', async () => {
const url = 'https://tenor.com/view/test-gif-12345678';
try {
const result = await scraperService.scrapeUrl(url);
expect(result.source).toBe('tenor');
expect(result.type).toBe('gif');
} catch (error) {
expect(error).toBeDefined();
}
});
});
});

View File

@ -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 (
<View className="flex-1 pt-16">
<RNView className="p-4">
<RNView className="flex-row gap-3 items-center">
<TextInput
className="flex-1 h-12 border rounded-lg px-4 text-base"
style={{ color: colors.text, borderColor: colors.tabIconDefault }}
placeholder="Введите имя пользователя..."
placeholderTextColor={colors.tabIconDefault}
value={searchQuery}
onChangeText={setSearchQuery}
onSubmitEditing={handleSearch}
returnKeyType="search"
editable={!loading}
/>
<TouchableOpacity
onPress={handleSearch}
disabled={loading || !searchQuery.trim()}
className="w-12 h-12 rounded-lg justify-center items-center"
style={{
backgroundColor: colors.tint,
opacity: loading || !searchQuery.trim() ? 0.5 : 1,
}}
>
{loading ? (
<ActivityIndicator size="small" color="#fff" />
) : (
<Search size={20} color="#fff" />
)}
</TouchableOpacity>
</RNView>
</RNView>
<View className="flex-1 items-center justify-center p-5">
<User size={64} color={colors.tabIconDefault} />
<Text className="text-2xl font-bold mt-4 mb-3">Поиск пользователей</Text>
<Text className="text-base text-center leading-6" style={{ color: colors.tabIconDefault }}>
Введите имя пользователя в поле выше и нажмите поиск, чтобы просмотреть его профиль
</Text>
</View>
</View>
);
}

212
apps/frontend/app/add.tsx Normal file
View File

@ -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 (
<ScrollView className="flex-1">
<View className="p-5 pt-16">
<Text className="text-3xl font-bold mb-6">Добавить сейв</Text>
<View className="flex-row gap-3 mb-5">
<TouchableOpacity
className="flex-1 p-3 rounded-lg border items-center"
style={mode === 'upload' ? { backgroundColor: colors.tint } : {}}
onPress={() => setMode('upload')}
>
<Text
className="text-base font-semibold"
style={mode === 'upload' ? { color: '#fff' } : {}}
>
Загрузить файл
</Text>
</TouchableOpacity>
<TouchableOpacity
className="flex-1 p-3 rounded-lg border items-center"
style={mode === 'url' ? { backgroundColor: colors.tint } : {}}
onPress={() => setMode('url')}
>
<Text
className="text-base font-semibold"
style={mode === 'url' ? { color: '#fff' } : {}}
>
По URL
</Text>
</TouchableOpacity>
</View>
{mode === 'url' && (
<TextInput
className="h-12 border rounded-lg px-4 mb-4 text-base"
style={{ color: colors.text, borderColor: colors.tabIconDefault }}
placeholder="URL медиафайла"
placeholderTextColor={colors.tabIconDefault}
value={url}
onChangeText={setUrl}
keyboardType="url"
autoCapitalize="none"
/>
)}
<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}
/>
<TextInput
className="h-24 border rounded-lg px-4 pt-4 mb-4 text-base"
style={{ color: colors.text, borderColor: colors.tabIconDefault }}
placeholder="Описание (необязательно)"
placeholderTextColor={colors.tabIconDefault}
value={description}
onChangeText={setDescription}
multiline
numberOfLines={4}
/>
<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={tags}
onChangeText={setTags}
/>
<View className="mb-5">
<Text className="text-base font-semibold mb-3">Видимость:</Text>
<TouchableOpacity
className="p-3 rounded-lg mb-2 border items-center"
style={visibility === 'public' ? { backgroundColor: colors.tint } : {}}
onPress={() => setVisibility('public')}
>
<Text
className="text-base font-semibold"
style={visibility === 'public' ? { color: '#fff' } : {}}
>
Публичный
</Text>
</TouchableOpacity>
<TouchableOpacity
className="p-3 rounded-lg mb-2 border items-center"
style={visibility === 'link' ? { backgroundColor: colors.tint } : {}}
onPress={() => setVisibility('link')}
>
<Text
className="text-base font-semibold"
style={visibility === 'link' ? { color: '#fff' } : {}}
>
По ссылке
</Text>
</TouchableOpacity>
</View>
<TouchableOpacity
className="h-12 rounded-lg justify-center items-center mt-2"
style={{ backgroundColor: colors.tint }}
onPress={handleUpload}
disabled={loading}
>
{loading ? (
<ActivityIndicator color="#fff" />
) : (
<Text className="text-base font-semibold" style={{ color: '#fff' }}>
{mode === 'url' ? 'Создать из URL' : 'Выбрать файл'}
</Text>
)}
</TouchableOpacity>
</View>
</ScrollView>
);
}

View File

@ -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<SaveDetailResponse | null>(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 (
<View className="flex-1 justify-center items-center">
<ActivityIndicator size="large" color={colors.tint} />
</View>
);
}
if (!save) {
return null;
}
const isOwner = user?.id === save.userId;
const mediaUrl = save.url;
const renderMedia = () => {
if (save.type === 'video') {
return (
<VideoView
player={videoPlayer}
className="w-full min-h-[300px] max-h-[500px] rounded-xl mb-4 bg-black"
contentFit="contain"
nativeControls
fullscreenOptions={{ enable: true }}
/>
);
}
else if (save.type === 'image' || save.type === 'gif') {
return (
<Image
source={{ uri: mediaUrl }}
className="w-full h-[300px] rounded-xl mb-4"
resizeMode="contain"
/>
);
}
else {
return (
<View className="w-full h-[300px] rounded-xl mb-4 justify-center items-center" style={{ backgroundColor: colors.tabIconDefault }}>
<File size={64} color={colors.text} />
<Text className="mt-3 text-sm">{save.type}</Text>
</View>
);
}
};
return (
<ScrollView className="flex-1">
<View className="p-4 pt-16">
{renderMedia()}
<View className="flex-row justify-between items-center mb-4">
<Text className="text-2xl font-bold flex-1">
{editing ? 'Редактирование' : save.name || 'Без названия'}
</Text>
<View className="flex-row gap-3">
{isOwner ? (
editing ? (
<>
<TouchableOpacity
onPress={() => {
setEditing(false);
setName(save.name || '');
setDescription(save.description || '');
setTags(save.tags.join(', ') || '');
setVisibility(save.visibility);
}}
className="p-2"
>
<X size={24} color={colors.text} />
</TouchableOpacity>
<TouchableOpacity
onPress={handleSave}
disabled={saving}
className="p-2"
>
{saving ? (
<ActivityIndicator size="small" color={colors.tint} />
) : (
<Check size={24} color={colors.tint} />
)}
</TouchableOpacity>
</>
) : (
<>
{save.shareUrl && (
<TouchableOpacity
onPress={handleCopyShareUrl}
className="p-2"
>
<Copy size={24} color={colors.tint} />
</TouchableOpacity>
)}
<TouchableOpacity
onPress={handleShare}
className="p-2"
>
<ShareIcon size={24} color={colors.tint} />
</TouchableOpacity>
<TouchableOpacity
onPress={() => setEditing(true)}
className="p-2"
>
<Edit size={24} color={colors.tint} />
</TouchableOpacity>
<TouchableOpacity
onPress={handleDelete}
className="p-2"
>
<Trash size={24} color="#ff4444" />
</TouchableOpacity>
</>
)
) : (
<>
{save.shareUrl && (
<TouchableOpacity
onPress={handleCopyShareUrl}
className="p-2"
>
<Copy size={24} color={colors.tint} />
</TouchableOpacity>
)}
<TouchableOpacity
onPress={handleShare}
className="p-2"
>
<ShareIcon size={24} color={colors.tint} />
</TouchableOpacity>
<TouchableOpacity
onPress={handleViewUserProfile}
className="p-2"
>
<User size={24} color={colors.tint} />
</TouchableOpacity>
</>
)}
</View>
</View>
{editing ? (
<>
<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}
/>
<TextInput
className="h-24 border rounded-lg px-4 pt-4 mb-4 text-base"
style={{ color: colors.text, borderColor: colors.tabIconDefault }}
placeholder="Описание"
placeholderTextColor={colors.tabIconDefault}
value={description}
onChangeText={setDescription}
multiline
numberOfLines={4}
/>
<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={tags}
onChangeText={setTags}
/>
<View className="mb-5">
<Text className="text-base font-semibold mb-3">Видимость:</Text>
<TouchableOpacity
className="p-3 rounded-lg mb-2 border items-center"
style={visibility === 'public' ? { backgroundColor: colors.tint } : {}}
onPress={() => setVisibility('public')}
>
<Text
className="text-base font-semibold"
style={visibility === 'public' ? { color: '#fff' } : {}}
>
Публичный
</Text>
</TouchableOpacity>
<TouchableOpacity
className="p-3 rounded-lg mb-2 border items-center"
style={visibility === 'link' ? { backgroundColor: colors.tint } : {}}
onPress={() => setVisibility('link')}
>
<Text
className="text-base font-semibold"
style={visibility === 'link' ? { color: '#fff' } : {}}
>
По ссылке
</Text>
</TouchableOpacity>
</View>
</>
) : (
<>
{save.description && (
<Text className="text-base mb-4 leading-6">{save.description}</Text>
)}
{save.tags.length > 0 && (
<View className="flex-row flex-wrap gap-2 mb-4">
{save.tags.map((tag, idx) => (
<View
key={idx}
className="px-3 py-1.5 rounded-md"
style={{ backgroundColor: colors.tint + '20' }}
>
<Text className="text-sm" style={{ color: colors.tint }}>
{tag}
</Text>
</View>
))}
</View>
)}
<View className="mt-4 pt-4 border-t border-gray-200">
{!isOwner && save.userId && (
<TouchableOpacity
onPress={handleViewUserProfile}
className="flex-row items-center mb-3 py-2"
>
<User size={16} color={colors.tint} />
<Text className="ml-2 text-sm" style={{ color: colors.tint }}>
Просмотреть профиль автора
</Text>
</TouchableOpacity>
)}
<Text className="text-sm mb-2" style={{ color: colors.tabIconDefault }}>
Тип: {save.type}
</Text>
<Text className="text-sm mb-2" style={{ color: colors.tabIconDefault }}>
Видимость: {save.visibility === 'public' ? 'Публичный' : 'По ссылке'}
</Text>
<Text className="text-sm mb-2" style={{ color: colors.tabIconDefault }}>
Создан: {new Date(save.createdAt).toLocaleString('ru-RU')}
</Text>
{save.shareUrl && (
<View className="mt-2">
<Text className="text-sm mb-1" style={{ color: colors.tabIconDefault }}>
Ссылка для доступа:
</Text>
<TouchableOpacity
onPress={handleCopyShareUrl}
className="flex-row items-center p-2 rounded-md bg-gray-100 gap-2"
>
<Copy size={14} color={colors.tint} />
<Text className="flex-1 text-xs" style={{ color: colors.tint }} numberOfLines={1}>
{`${API_BASE_URL}/saves/${save.id}?share=${save.shareUrl}`}
</Text>
</TouchableOpacity>
</View>
)}
</View>
</>
)}
</View>
</ScrollView>
);
}

View File

@ -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<SaveListItem[]>([]);
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 (
<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 && 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">
<TouchableOpacity
onPress={() => router.back()}
className="p-2"
>
<ArrowLeft size={24} color={colors.text} />
</TouchableOpacity>
<Text className="text-3xl font-bold flex-1 text-center">Публичные сейвы</Text>
<View className="w-10" />
</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-2 text-center font-semibold">Нет публичных сейвов</Text>
<Text className="text-sm text-center" style={{ color: colors.tabIconDefault }}>
У этого пользователя пока нет публичных сейвов
</Text>
</View>
) : (
<FlatList
data={saves}
keyExtractor={(item) => item.id.toString()}
renderItem={({ item }) => <SaveItem item={item} />}
refreshControl={
<RefreshControl refreshing={refreshing} onRefresh={handleRefresh} />
}
contentContainerStyle={{ padding: 16 }}
/>
)}
</View>
);
}