Compare commits
5 Commits
98a0704c55
...
feat/panel
| Author | SHA1 | Date | |
|---|---|---|---|
| c1769be91f | |||
| 362172e832 | |||
| bc972febc5 | |||
| 7fd19f9a09 | |||
| d6ae0cf92c |
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