feat: добавлен экран деталей сейва с возможностью редактирования
This commit is contained in:
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;
|
||||||
|
|
||||||
|
// Используем прямой URL из S3
|
||||||
|
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;
|
||||||
|
|
||||||
|
// Формируем полный URL для доступа к сейву по share token
|
||||||
|
const shareUrl = `${API_BASE_URL}/saves/${save.id}?share=${save.shareUrl}`;
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Используем Share API - на некоторых платформах это позволяет скопировать
|
||||||
|
await Share.share({
|
||||||
|
message: `Ссылка для доступа к сейву: ${shareUrl}`,
|
||||||
|
url: shareUrl,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
// Если Share не работает, показываем Alert с URL для ручного копирования
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
// Обновляем источник видео при изменении save
|
||||||
|
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;
|
||||||
|
// Используем прямой URL из S3 вместо /download эндпоинта
|
||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user