Files
popa-launcher/src/renderer/pages/Profile.tsx
2025-12-14 21:14:59 +05:00

621 lines
21 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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>
);
}