621 lines
21 KiB
TypeScript
621 lines
21 KiB
TypeScript
import { useEffect, useRef, useState } from 'react';
|
||
import SkinViewer from '../components/SkinViewer';
|
||
import {
|
||
fetchPlayer,
|
||
uploadSkin,
|
||
fetchCapes,
|
||
Cape,
|
||
activateCape,
|
||
deactivateCape,
|
||
} from '../api';
|
||
|
||
import {
|
||
Box,
|
||
Typography,
|
||
Paper,
|
||
Button,
|
||
FormControl,
|
||
InputLabel,
|
||
Select,
|
||
MenuItem,
|
||
Alert,
|
||
} from '@mui/material';
|
||
|
||
import CapeCard from '../components/CapeCard';
|
||
import { FullScreenLoader } from '../components/FullScreenLoader';
|
||
import { OnlinePlayersPanel } from '../components/OnlinePlayersPanel';
|
||
import DailyRewards from '../components/Profile/DailyRewards';
|
||
import CustomNotification from '../components/Notifications/CustomNotification';
|
||
import type { NotificationPosition } from '../components/Notifications/CustomNotification';
|
||
import { useNavigate } from 'react-router-dom';
|
||
import { isNotificationsEnabled, getNotifPositionFromSettings } from '../utils/notifications';
|
||
|
||
export default function Profile() {
|
||
const navigate = useNavigate();
|
||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||
const [skin, setSkin] = useState<string>('');
|
||
const [cape, setCape] = useState<string>('');
|
||
const [username, setUsername] = useState<string>('');
|
||
const [skinFile, setSkinFile] = useState<File | null>(null);
|
||
const [skinModel, setSkinModel] = useState<string>(''); // slim или classic
|
||
const [uploadStatus, setUploadStatus] = useState<
|
||
'idle' | 'loading' | 'success' | 'error'
|
||
>('idle');
|
||
const [statusMessage, setStatusMessage] = useState<string>('');
|
||
const [isDragOver, setIsDragOver] = useState<boolean>(false);
|
||
const [capes, setCapes] = useState<Cape[]>([]);
|
||
const [uuid, setUuid] = useState<string>('');
|
||
const [loading, setLoading] = useState<boolean>(false);
|
||
|
||
const [viewerWidth, setViewerWidth] = useState(500);
|
||
const [viewerHeight, setViewerHeight] = useState(600);
|
||
|
||
// notification
|
||
const [notifOpen, setNotifOpen] = useState(false);
|
||
const [notifMsg, setNotifMsg] = useState<React.ReactNode>('');
|
||
const [notifSeverity, setNotifSeverity] = useState<
|
||
'success' | 'info' | 'warning' | 'error'
|
||
>('success');
|
||
|
||
const [notifPos, setNotifPos] = useState<NotificationPosition>({
|
||
vertical: 'top',
|
||
horizontal: 'right',
|
||
});
|
||
|
||
const [autoRotate, setAutoRotate] = useState(true);
|
||
const [walkingSpeed, setWalkingSpeed] = useState(0.5);
|
||
|
||
useEffect(() => {
|
||
const savedConfig = localStorage.getItem('launcher_config');
|
||
if (savedConfig) {
|
||
const config = JSON.parse(savedConfig);
|
||
if (config.uuid) {
|
||
loadPlayerData(config.uuid);
|
||
setUsername(config.username || '');
|
||
loadCapesData(config.username || '');
|
||
setUuid(config.uuid || '');
|
||
}
|
||
}
|
||
}, []);
|
||
|
||
useEffect(() => {
|
||
// Функция для обновления размеров
|
||
const updateDimensions = () => {
|
||
setViewerWidth(window.innerWidth * 0.4); // 25vw
|
||
setViewerHeight(window.innerWidth * 0.5); // 30vw
|
||
};
|
||
|
||
// Вызываем один раз при монтировании
|
||
updateDimensions();
|
||
|
||
// Добавляем слушатель изменения размера окна
|
||
window.addEventListener('resize', updateDimensions);
|
||
|
||
// Очистка при размонтировании
|
||
return () => {
|
||
window.removeEventListener('resize', updateDimensions);
|
||
};
|
||
}, []);
|
||
|
||
const loadPlayerData = async (uuid: string) => {
|
||
try {
|
||
setLoading(true);
|
||
const player = await fetchPlayer(uuid);
|
||
setSkin(player.skin_url);
|
||
setCape(player.cloak_url);
|
||
setLoading(false);
|
||
} catch (error) {
|
||
console.error('Ошибка при получении данных игрока:', error);
|
||
setSkin('');
|
||
setCape('');
|
||
}
|
||
};
|
||
|
||
const loadCapesData = async (username: string) => {
|
||
try {
|
||
setLoading(true);
|
||
const capesData = await fetchCapes(username);
|
||
setCapes(capesData);
|
||
setLoading(false);
|
||
} catch (error) {
|
||
console.error('Ошибка при получении плащей:', error);
|
||
setCapes([]);
|
||
}
|
||
};
|
||
|
||
// Обработка перетаскивания файла
|
||
const handleFileDrop = (e: React.DragEvent<HTMLDivElement>) => {
|
||
e.preventDefault();
|
||
setIsDragOver(false);
|
||
|
||
if (e.dataTransfer.files && e.dataTransfer.files.length > 0) {
|
||
const file = e.dataTransfer.files[0];
|
||
if (file.type === 'image/png') {
|
||
setSkinFile(file);
|
||
setStatusMessage(`Файл "${file.name}" готов к загрузке`);
|
||
} else {
|
||
setStatusMessage('Пожалуйста, выберите файл в формате PNG');
|
||
setUploadStatus('error');
|
||
}
|
||
}
|
||
};
|
||
|
||
// Обработка выбора файла
|
||
const handleFileSelect = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||
if (e.target.files && e.target.files.length > 0) {
|
||
const file = e.target.files[0];
|
||
if (file.type === 'image/png') {
|
||
setSkinFile(file);
|
||
setStatusMessage(`Файл "${file.name}" готов к загрузке`);
|
||
} else {
|
||
setStatusMessage('Пожалуйста, выберите файл в формате PNG');
|
||
setUploadStatus('error');
|
||
}
|
||
}
|
||
};
|
||
|
||
const handleActivateCape = async (cape_id: string) => {
|
||
setLoading(true);
|
||
await activateCape(username, cape_id);
|
||
await loadCapesData(username);
|
||
setLoading(false);
|
||
};
|
||
|
||
const handleDeactivateCape = async (cape_id: string) => {
|
||
setLoading(true);
|
||
await deactivateCape(username, cape_id);
|
||
await loadCapesData(username);
|
||
await loadPlayerData(uuid);
|
||
setLoading(false);
|
||
};
|
||
|
||
// Отправка запроса на установку скина
|
||
const handleUploadSkin = async () => {
|
||
setLoading(true);
|
||
if (!skinFile || !username) {
|
||
const msg = 'Необходимо выбрать файл и указать имя пользователя';
|
||
setStatusMessage(msg);
|
||
setUploadStatus('error');
|
||
|
||
setLoading(false);
|
||
|
||
// notification
|
||
if (!isNotificationsEnabled()) return;
|
||
setNotifMsg(msg);
|
||
setNotifSeverity('error');
|
||
setNotifOpen(true);
|
||
return;
|
||
}
|
||
|
||
setUploadStatus('loading');
|
||
|
||
try {
|
||
await uploadSkin(username, skinFile, skinModel);
|
||
|
||
setStatusMessage('Скин успешно загружен!');
|
||
setUploadStatus('success');
|
||
|
||
// 1) подтягиваем свежий skin_url с бэка
|
||
const config = JSON.parse(localStorage.getItem('launcher_config') || '{}');
|
||
if (config.uuid) {
|
||
await loadPlayerData(config.uuid);
|
||
}
|
||
|
||
// 2) сообщаем TopBar'у, что скин обновился
|
||
window.dispatchEvent(new CustomEvent('skin-updated'));
|
||
|
||
// notification
|
||
if (!isNotificationsEnabled()) return;
|
||
setNotifMsg('Скин успешно загружен!');
|
||
setNotifSeverity('success');
|
||
setNotifPos(getNotifPositionFromSettings());
|
||
setNotifOpen(true);
|
||
} catch (error) {
|
||
const msg = `Ошибка: ${
|
||
error instanceof Error ? error.message : 'Не удалось загрузить скин'
|
||
}`;
|
||
|
||
setStatusMessage(msg);
|
||
setUploadStatus('error');
|
||
|
||
// notification
|
||
if (!isNotificationsEnabled()) return;
|
||
setNotifMsg(msg);
|
||
setNotifSeverity('error');
|
||
setNotifPos(getNotifPositionFromSettings());
|
||
setNotifOpen(true);
|
||
} finally {
|
||
setLoading(false);
|
||
}
|
||
};
|
||
|
||
const GRADIENT =
|
||
'linear-gradient(71deg, #F27121 0%, #E940CD 70%, #8A2387 100%)';
|
||
|
||
useEffect(() => {
|
||
const STORAGE_KEY = 'launcher_settings';
|
||
|
||
const read = () => {
|
||
try {
|
||
const raw = localStorage.getItem(STORAGE_KEY);
|
||
const s = raw ? JSON.parse(raw) : null;
|
||
setAutoRotate(s?.autoRotateSkinViewer ?? true);
|
||
setWalkingSpeed(s?.walkingSpeed ?? 0.5);
|
||
} catch {
|
||
setAutoRotate(true);
|
||
setWalkingSpeed(0.5);
|
||
}
|
||
};
|
||
|
||
read();
|
||
|
||
// если хочешь, чтобы обновлялось сразу, когда Settings сохраняют:
|
||
const onStorage = (e: StorageEvent) => {
|
||
if (e.key === STORAGE_KEY) read();
|
||
};
|
||
window.addEventListener('storage', onStorage);
|
||
|
||
// и наш “локальный” евент (для Electron/одного окна storage может не стрелять)
|
||
const onSettingsUpdated = () => read();
|
||
window.addEventListener('settings-updated', onSettingsUpdated as EventListener);
|
||
|
||
return () => {
|
||
window.removeEventListener('storage', onStorage);
|
||
window.removeEventListener('settings-updated', onSettingsUpdated as EventListener);
|
||
};
|
||
}, []);
|
||
|
||
return (
|
||
<Box
|
||
sx={{
|
||
mt: '10vh',
|
||
width: '100%',
|
||
height: '100%',
|
||
overflowY: 'auto',
|
||
boxSizing: 'border-box',
|
||
px: '2vw',
|
||
}}
|
||
>
|
||
<CustomNotification
|
||
open={notifOpen}
|
||
message={notifMsg}
|
||
severity={notifSeverity}
|
||
position={notifPos}
|
||
onClose={() => setNotifOpen(false)}
|
||
autoHideDuration={2500}
|
||
/>
|
||
|
||
{loading ? (
|
||
<FullScreenLoader message="Загрузка вашего профиля" />
|
||
) : (
|
||
<Box
|
||
sx={{
|
||
display: 'grid',
|
||
gridTemplateColumns: 'minmax(0, 1fr) minmax(0, 1fr)',
|
||
gap: '3vw',
|
||
alignItems: 'start',
|
||
}}
|
||
>
|
||
{/* LEFT COLUMN */}
|
||
<Box
|
||
sx={{
|
||
display: 'flex',
|
||
flexDirection: 'column',
|
||
gap: '1vw',
|
||
minWidth: 0,
|
||
}}
|
||
>
|
||
{/* Плашка с ником */}
|
||
<Typography
|
||
sx={{
|
||
fontFamily: 'Benzin-Bold',
|
||
alignSelf: 'center',
|
||
justifySelf: 'center',
|
||
textAlign: 'center',
|
||
fontSize: '3vw',
|
||
position: 'relative',
|
||
px: '5vw',
|
||
py: '0.9vw',
|
||
borderRadius: '3vw',
|
||
color: 'rgba(255,255,255,0.95)',
|
||
background:
|
||
'radial-gradient(circle at 10% 10%, rgba(242,113,33,0.20), transparent 55%), radial-gradient(circle at 90% 20%, rgba(233,64,205,0.16), transparent 55%), rgba(10,10,20,0.92)',
|
||
border: '1px solid rgba(255,255,255,0.10)',
|
||
boxShadow: '0 1.4vw 3.8vw rgba(0,0,0,0.55)',
|
||
backdropFilter: 'blur(14px)',
|
||
overflow: 'hidden',
|
||
'&:after': {
|
||
content: '""',
|
||
position: 'absolute',
|
||
left: '8%',
|
||
right: '8%',
|
||
bottom: 0,
|
||
height: '0.35vw',
|
||
borderRadius: '999px',
|
||
background: GRADIENT,
|
||
opacity: 0.9,
|
||
},
|
||
}}
|
||
>
|
||
{username}
|
||
</Typography>
|
||
|
||
{/* SkinViewer */}
|
||
<Box
|
||
sx={{
|
||
overflow: 'hidden',
|
||
display: 'flex',
|
||
justifyContent: 'center',
|
||
}}
|
||
>
|
||
<SkinViewer
|
||
width={400}
|
||
height={465}
|
||
skinUrl={skin}
|
||
capeUrl={cape}
|
||
walkingSpeed={walkingSpeed}
|
||
autoRotate={autoRotate}
|
||
/>
|
||
</Box>
|
||
|
||
{/* Загрузчик скинов */}
|
||
<Box
|
||
sx={{
|
||
width: '100%',
|
||
p: '2.2vw',
|
||
borderRadius: '1.2vw',
|
||
boxSizing: 'border-box',
|
||
minWidth: 0,
|
||
overflow: 'hidden',
|
||
position: 'relative',
|
||
background:
|
||
'radial-gradient(circle at 10% 10%, rgba(242,113,33,0.14), transparent 55%), radial-gradient(circle at 90% 20%, rgba(233,64,205,0.12), transparent 55%), rgba(10,10,20,0.86)',
|
||
border: '1px solid rgba(255,255,255,0.08)',
|
||
boxShadow: '0 1.2vw 3.2vw rgba(0,0,0,0.55)',
|
||
backdropFilter: 'blur(14px)',
|
||
}}
|
||
>
|
||
{/* dropzone */}
|
||
<Box
|
||
sx={{
|
||
borderRadius: '1.1vw',
|
||
p: '1.6vw',
|
||
mb: '1.1vw',
|
||
textAlign: 'center',
|
||
cursor: 'pointer',
|
||
position: 'relative',
|
||
overflow: 'hidden',
|
||
background: 'rgba(255,255,255,0.04)',
|
||
border: '1px solid rgba(255,255,255,0.10)',
|
||
transition:
|
||
'transform 0.18s ease, border-color 0.18s ease, background 0.18s ease',
|
||
'&:hover': {
|
||
transform: 'scale(1.005)',
|
||
borderColor: 'rgba(242,113,33,0.35)',
|
||
background: 'rgba(255,255,255,0.05)',
|
||
},
|
||
...(isDragOver
|
||
? {
|
||
borderColor: 'rgba(233,64,205,0.55)',
|
||
background:
|
||
'linear-gradient(120deg, rgba(242,113,33,0.10), rgba(233,64,205,0.08), rgba(138,35,135,0.10))',
|
||
}
|
||
: null),
|
||
'&:after': {
|
||
content: '""',
|
||
position: 'absolute',
|
||
left: 0,
|
||
top: 0,
|
||
bottom: 0,
|
||
width: '0.35vw',
|
||
background: GRADIENT,
|
||
opacity: 0.9,
|
||
pointerEvents: 'none',
|
||
},
|
||
}}
|
||
onDragOver={(e) => {
|
||
e.preventDefault();
|
||
setIsDragOver(true);
|
||
}}
|
||
onDragLeave={() => setIsDragOver(false)}
|
||
onDrop={handleFileDrop}
|
||
onClick={() => fileInputRef.current?.click()}
|
||
>
|
||
<input
|
||
type="file"
|
||
ref={fileInputRef}
|
||
accept=".png"
|
||
style={{ display: 'none' }}
|
||
onChange={handleFileSelect}
|
||
/>
|
||
|
||
<Typography
|
||
sx={{
|
||
color: 'rgba(255,255,255,0.92)',
|
||
fontWeight: 800,
|
||
}}
|
||
>
|
||
{skinFile
|
||
? `Выбран файл: ${skinFile.name}`
|
||
: 'Перетащите PNG файл скина или кликните для выбора'}
|
||
</Typography>
|
||
|
||
<Typography
|
||
sx={{
|
||
mt: 0.6,
|
||
color: 'rgba(255,255,255,0.60)',
|
||
fontWeight: 700,
|
||
fontSize: '0.9vw',
|
||
}}
|
||
>
|
||
Только .png • Рекомендуется 64×64
|
||
</Typography>
|
||
</Box>
|
||
|
||
{/* select */}
|
||
<FormControl
|
||
fullWidth
|
||
size="small"
|
||
sx={{
|
||
mb: '1.1vw',
|
||
'& .MuiInputLabel-root': {
|
||
color: 'rgba(255,255,255,0.75)',
|
||
fontFamily: 'Benzin-Bold',
|
||
},
|
||
'& .MuiInputLabel-root.Mui-focused': {
|
||
color: 'rgba(242,113,33,0.95)',
|
||
},
|
||
}}
|
||
>
|
||
<InputLabel>Модель скина</InputLabel>
|
||
<Select
|
||
value={skinModel}
|
||
label="Модель скина"
|
||
onChange={(e) => setSkinModel(e.target.value)}
|
||
MenuProps={{
|
||
PaperProps: {
|
||
sx: {
|
||
bgcolor: 'rgba(10,10,20,0.96)',
|
||
border: '1px solid rgba(255,255,255,0.10)',
|
||
borderRadius: '1vw',
|
||
backdropFilter: 'blur(14px)',
|
||
'& .MuiMenuItem-root': {
|
||
color: 'rgba(255,255,255,0.9)',
|
||
fontFamily: 'Benzin-Bold',
|
||
},
|
||
'& .MuiMenuItem-root.Mui-selected': {
|
||
backgroundColor: 'rgba(242,113,33,0.16)',
|
||
},
|
||
'& .MuiMenuItem-root:hover': {
|
||
backgroundColor: 'rgba(233,64,205,0.14)',
|
||
},
|
||
},
|
||
},
|
||
}}
|
||
sx={{
|
||
borderRadius: '999px',
|
||
bgcolor: 'rgba(255,255,255,0.04)',
|
||
color: 'rgba(255,255,255,0.92)',
|
||
fontFamily: 'Benzin-Bold',
|
||
'& .MuiSelect-select': {
|
||
py: '0.9vw',
|
||
px: '1.2vw',
|
||
},
|
||
'& .MuiOutlinedInput-notchedOutline': {
|
||
borderColor: 'rgba(255,255,255,0.14)',
|
||
},
|
||
'&:hover .MuiOutlinedInput-notchedOutline': {
|
||
borderColor: 'rgba(242,113,33,0.55)',
|
||
},
|
||
'&.Mui-focused .MuiOutlinedInput-notchedOutline': {
|
||
borderColor: 'rgba(233,64,205,0.65)',
|
||
borderWidth: '2px',
|
||
},
|
||
'& .MuiSelect-icon': {
|
||
color: 'rgba(255,255,255,0.75)',
|
||
},
|
||
}}
|
||
>
|
||
<MenuItem value="">По умолчанию</MenuItem>
|
||
<MenuItem value="slim">Тонкая (Alex)</MenuItem>
|
||
<MenuItem value="classic">Классическая (Steve)</MenuItem>
|
||
</Select>
|
||
</FormControl>
|
||
|
||
{/* button */}
|
||
<Button
|
||
variant="contained"
|
||
fullWidth
|
||
onClick={handleUploadSkin}
|
||
disabled={uploadStatus === 'loading' || !skinFile}
|
||
disableRipple
|
||
sx={{
|
||
borderRadius: '2.5vw',
|
||
py: '0.95vw',
|
||
fontFamily: 'Benzin-Bold',
|
||
color: '#fff',
|
||
background: GRADIENT,
|
||
boxShadow: '0 1.0vw 2.8vw rgba(0,0,0,0.45)',
|
||
transition:
|
||
'transform 0.18s ease, filter 0.18s ease, opacity 0.18s ease',
|
||
'&:hover': {
|
||
transform: 'scale(1.01)',
|
||
filter: 'brightness(1.05)',
|
||
},
|
||
'&.Mui-disabled': {
|
||
background: 'rgba(255,255,255,0.10)',
|
||
color: 'rgba(255,255,255,0.55)',
|
||
},
|
||
}}
|
||
>
|
||
{uploadStatus === 'loading' ? 'Загрузка...' : 'Установить скин'}
|
||
</Button>
|
||
</Box>
|
||
</Box>
|
||
|
||
{/* RIGHT COLUMN */}
|
||
<Box
|
||
sx={{
|
||
display: 'flex',
|
||
flexDirection: 'column',
|
||
gap: '1vw',
|
||
minWidth: 0,
|
||
maxWidth: '44vw',
|
||
justifySelf: 'start',
|
||
}}
|
||
>
|
||
{/* Плащи */}
|
||
<Paper
|
||
elevation={0}
|
||
sx={{
|
||
p: '1.6vw',
|
||
borderRadius: '1.2vw',
|
||
background:
|
||
'radial-gradient(circle at 10% 10%, rgba(242,113,33,0.14), transparent 55%), radial-gradient(circle at 90% 20%, rgba(233,64,205,0.12), transparent 55%), rgba(10,10,20,0.86)',
|
||
border: '1px solid rgba(255,255,255,0.08)',
|
||
boxShadow: '0 1.2vw 3.2vw rgba(0,0,0,0.55)',
|
||
backdropFilter: 'blur(14px)',
|
||
}}
|
||
>
|
||
<Typography
|
||
sx={{
|
||
fontFamily: 'Benzin-Bold',
|
||
fontSize: '1.35vw',
|
||
lineHeight: 1.1,
|
||
backgroundImage: GRADIENT,
|
||
WebkitBackgroundClip: 'text',
|
||
WebkitTextFillColor: 'transparent',
|
||
mb: '1.0vw',
|
||
}}
|
||
>
|
||
Ваши плащи
|
||
</Typography>
|
||
|
||
<Box
|
||
sx={{
|
||
display: 'flex',
|
||
flexWrap: 'wrap',
|
||
gap: '1.2vw',
|
||
}}
|
||
>
|
||
{capes.map((cape) => (
|
||
<CapeCard
|
||
key={cape.cape_id}
|
||
cape={cape}
|
||
mode="profile"
|
||
onAction={cape.is_active ? handleDeactivateCape : handleActivateCape}
|
||
actionDisabled={loading}
|
||
/>
|
||
))}
|
||
</Box>
|
||
</Paper>
|
||
|
||
{/* Онлайн */}
|
||
<OnlinePlayersPanel currentUsername={username} />
|
||
</Box>
|
||
</Box>
|
||
)}
|
||
</Box>
|
||
);
|
||
}
|