Compare commits
14 Commits
feat/saves
...
feat/panel
| Author | SHA1 | Date | |
|---|---|---|---|
| c1769be91f | |||
| 362172e832 | |||
| bc972febc5 | |||
| 7fd19f9a09 | |||
| d6ae0cf92c | |||
| 98a0704c55 | |||
| 45f33b9365 | |||
| b678f0181a | |||
| ee5cd575bf | |||
| bebea4e374 | |||
| 354e00db56 | |||
| 35fd995d2d | |||
| 74513b4de3 | |||
| 9d3fb6ae9a |
145
apps/backend/src/tests/e2e/access-control.test.ts
Normal file
145
apps/backend/src/tests/e2e/access-control.test.ts
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
95
apps/backend/src/tests/e2e/auth.test.ts
Normal file
95
apps/backend/src/tests/e2e/auth.test.ts
Normal file
@ -0,0 +1,95 @@
|
|||||||
|
import { describe, expect, test, } from 'bun:test';
|
||||||
|
|
||||||
|
describe('E2E: Authentication', () => {
|
||||||
|
const testUser = {
|
||||||
|
name: 'Test User',
|
||||||
|
email: `test-${Date.now()}@example.com`,
|
||||||
|
password: 'TestPassword123!',
|
||||||
|
};
|
||||||
|
|
||||||
|
let authCookie: string;
|
||||||
|
let userId: string;
|
||||||
|
|
||||||
|
test('should register new user', async () => {
|
||||||
|
const response = await fetch('http://localhost:3000/auth/api/sign-up/email', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify(testUser),
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(response.status).toBe(200);
|
||||||
|
|
||||||
|
const data = await response.json() as unknown as any;
|
||||||
|
expect(data.user).toBeDefined();
|
||||||
|
expect(data.user.email).toBe(testUser.email);
|
||||||
|
expect(data.user.name).toBe(testUser.name);
|
||||||
|
|
||||||
|
userId = data.user.id;
|
||||||
|
|
||||||
|
// Получаем cookies из ответа
|
||||||
|
const setCookieHeader = response.headers.get('set-cookie');
|
||||||
|
expect(setCookieHeader).toBeDefined();
|
||||||
|
authCookie = setCookieHeader!;
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should get current session with cookies', async () => {
|
||||||
|
const response = await fetch('http://localhost:3000/auth/api/get-session', {
|
||||||
|
headers: {
|
||||||
|
'Cookie': authCookie,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(response.status).toBe(200);
|
||||||
|
|
||||||
|
const data = await response.json() as any;
|
||||||
|
expect(data.user).toBeDefined();
|
||||||
|
expect(data.user.email).toBe(testUser.email);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should sign in with credentials', async () => {
|
||||||
|
const response = await fetch('http://localhost:3000/auth/api/sign-in/email', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
email: testUser.email,
|
||||||
|
password: testUser.password,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(response.status).toBe(200);
|
||||||
|
|
||||||
|
const data = await response.json() as any;
|
||||||
|
expect(data.user).toBeDefined();
|
||||||
|
expect(data.user.email).toBe(testUser.email);
|
||||||
|
|
||||||
|
const setCookieHeader = response.headers.get('set-cookie');
|
||||||
|
expect(setCookieHeader).toBeDefined();
|
||||||
|
authCookie = setCookieHeader!;
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should fail with wrong password', async () => {
|
||||||
|
const response = await fetch('http://localhost:3000/auth/api/sign-in/email', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
email: testUser.email,
|
||||||
|
password: 'WrongPassword',
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(response.status).toBeGreaterThanOrEqual(400);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should fail without cookies', async () => {
|
||||||
|
const response = await fetch('http://localhost:3000/auth/api/session');
|
||||||
|
|
||||||
|
// Должен вернуть 401 или отсутствующую сессию
|
||||||
|
expect(response.status).toBeGreaterThanOrEqual(400);
|
||||||
|
});
|
||||||
|
});
|
||||||
146
apps/backend/src/tests/e2e/saves.test.ts
Normal file
146
apps/backend/src/tests/e2e/saves.test.ts
Normal file
@ -0,0 +1,146 @@
|
|||||||
|
// apps/backend/src/tests/e2e/saves.test.ts
|
||||||
|
// Path: apps/backend/src/tests/e2e/saves.test.ts
|
||||||
|
|
||||||
|
import { describe, expect, test, beforeAll } from 'bun:test';
|
||||||
|
|
||||||
|
describe('E2E: Saves Management', () => {
|
||||||
|
let user1Cookie: string;
|
||||||
|
let user1Id: string;
|
||||||
|
let saveId: number;
|
||||||
|
|
||||||
|
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: 'Save Test User',
|
||||||
|
email: `save-test-${Date.now()}@example.com`,
|
||||||
|
password: 'TestPassword123!',
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
const signUpData = await signUpResponse.json();
|
||||||
|
user1Id = signUpData.user.id;
|
||||||
|
|
||||||
|
// Получаем cookies
|
||||||
|
const setCookieHeader = signUpResponse.headers.get('set-cookie');
|
||||||
|
if (setCookieHeader) {
|
||||||
|
user1Cookie = setCookieHeader;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should create save from external URL', async () => {
|
||||||
|
const response = await fetch('http://localhost:3000/saves/external', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'Cookie': user1Cookie,
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
url: 'https://www.pinterest.com/pin/40391727901690267/',
|
||||||
|
name: 'Test Image',
|
||||||
|
description: 'A test image from httpbin',
|
||||||
|
tags: ['test', 'e2e'],
|
||||||
|
visibility: 'link',
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
expect(response.status).toBe(200);
|
||||||
|
|
||||||
|
expect(data.id).toBeDefined();
|
||||||
|
expect(data.name).toBe('Test Image');
|
||||||
|
expect(data.type).toBe('image');
|
||||||
|
expect(data.visibility).toBe('link');
|
||||||
|
expect(data.shareUrl).toBeDefined();
|
||||||
|
|
||||||
|
saveId = data.id;
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should get my saves', async () => {
|
||||||
|
const response = await fetch('http://localhost:3000/saves/my', {
|
||||||
|
headers: {
|
||||||
|
'Cookie': user1Cookie,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(response.status).toBe(200);
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
expect(Array.isArray(data)).toBe(true);
|
||||||
|
expect(data.length).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should get save by ID', async () => {
|
||||||
|
const response = await fetch(`http://localhost:3000/saves/${saveId}`, {
|
||||||
|
headers: {
|
||||||
|
'Cookie': user1Cookie,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(response.status).toBe(200);
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
expect(data.id).toBe(saveId);
|
||||||
|
expect(data.name).toBe('Test Image');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should update save', async () => {
|
||||||
|
const response = await fetch(`http://localhost:3000/saves/${saveId}`, {
|
||||||
|
method: 'PATCH',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'Cookie': user1Cookie,
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
name: 'Updated Test Image',
|
||||||
|
description: 'Updated description',
|
||||||
|
tags: ['updated', 'test'],
|
||||||
|
visibility: 'public',
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(response.status).toBe(200);
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
expect(data.name).toBe('Updated Test Image');
|
||||||
|
expect(data.visibility).toBe('public');
|
||||||
|
expect(data.tags).toContain('updated');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should access public save without auth', async () => {
|
||||||
|
const response = await fetch(`http://localhost:3000/saves/${saveId}`);
|
||||||
|
|
||||||
|
expect(response.status).toBe(200);
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
expect(data.id).toBe(saveId);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should delete save', async () => {
|
||||||
|
const response = await fetch(`http://localhost:3000/saves/${saveId}`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
headers: {
|
||||||
|
'Cookie': user1Cookie,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(response.status).toBe(200);
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
expect(data.success).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should return 404 for deleted save', async () => {
|
||||||
|
const response = await fetch(`http://localhost:3000/saves/${saveId}`, {
|
||||||
|
headers: {
|
||||||
|
'Cookie': user1Cookie,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(response.status).toBe(404);
|
||||||
|
});
|
||||||
|
});
|
||||||
88
apps/backend/src/tests/e2e/scraper.test.ts
Normal file
88
apps/backend/src/tests/e2e/scraper.test.ts
Normal 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');
|
||||||
|
});
|
||||||
|
});
|
||||||
19
apps/backend/src/tests/setup.ts
Normal file
19
apps/backend/src/tests/setup.ts
Normal file
@ -0,0 +1,19 @@
|
|||||||
|
import { beforeAll, afterAll } from 'bun:test';
|
||||||
|
import { redis } from '@/services/redis.service';
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
console.log('Setting up test environment...');
|
||||||
|
|
||||||
|
try {
|
||||||
|
await redis.connect();
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('Redis not available in tests, continuing without cache');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
console.log('Cleaning up test environment...');
|
||||||
|
|
||||||
|
await redis.disconnect();
|
||||||
|
});
|
||||||
|
|
||||||
257
apps/backend/src/tests/unit/saves.service.test.ts
Normal file
257
apps/backend/src/tests/unit/saves.service.test.ts
Normal 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');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
71
apps/backend/src/tests/unit/scraper.service.test.ts
Normal file
71
apps/backend/src/tests/unit/scraper.service.test.ts
Normal 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();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
86
apps/frontend/app/(tabs)/two.tsx
Normal file
86
apps/frontend/app/(tabs)/two.tsx
Normal 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
212
apps/frontend/app/add.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
432
apps/frontend/app/save/[id].tsx
Normal file
432
apps/frontend/app/save/[id].tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
184
apps/frontend/app/user/[slug].tsx
Normal file
184
apps/frontend/app/user/[slug].tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user