add: skin viewer, refatoring to api
This commit is contained in:
@ -53,3 +53,7 @@ h5 {
|
||||
h6 {
|
||||
font-family: 'Benzin-Bold' !important;
|
||||
}
|
||||
|
||||
span {
|
||||
font-family: 'Benzin-Bold' !important;
|
||||
}
|
||||
|
@ -14,6 +14,8 @@ import { Box } from '@mui/material';
|
||||
import MinecraftBackground from './components/MinecraftBackground';
|
||||
import { Notifier } from './components/Notifier';
|
||||
import { VersionsExplorer } from './pages/VersionsExplorer';
|
||||
import Profile from './pages/Profile';
|
||||
import Shop from './pages/Shop';
|
||||
|
||||
const AuthCheck = ({ children }: { children: ReactNode }) => {
|
||||
const [isAuthenticated, setIsAuthenticated] = useState<boolean | null>(null);
|
||||
@ -120,7 +122,7 @@ const App = () => {
|
||||
}}
|
||||
>
|
||||
<MinecraftBackground />
|
||||
<TopBar onRegister={handleRegister} username={username} />
|
||||
<TopBar onRegister={handleRegister} username={username || ''} />
|
||||
<Notifier />
|
||||
<Routes>
|
||||
<Route path="/login" element={<Login />} />
|
||||
@ -140,6 +142,22 @@ const App = () => {
|
||||
</AuthCheck>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/profile"
|
||||
element={
|
||||
<AuthCheck>
|
||||
<Profile />
|
||||
</AuthCheck>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/shop"
|
||||
element={
|
||||
<AuthCheck>
|
||||
<Shop />
|
||||
</AuthCheck>
|
||||
}
|
||||
/>
|
||||
</Routes>
|
||||
</Box>
|
||||
</Router>
|
||||
|
127
src/renderer/api.ts
Normal file
127
src/renderer/api.ts
Normal file
@ -0,0 +1,127 @@
|
||||
export const API_BASE_URL = 'http://147.78.65.214:8000';
|
||||
|
||||
export interface Player {
|
||||
uuid: string;
|
||||
username: string;
|
||||
skin_url: string;
|
||||
cloak_url: string;
|
||||
coins: number;
|
||||
is_active: boolean;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export interface CoinsResponse {
|
||||
username: string;
|
||||
coins: number;
|
||||
total_time_played: {
|
||||
seconds: number;
|
||||
formatted: string;
|
||||
};
|
||||
}
|
||||
|
||||
export interface ApiError {
|
||||
message: string;
|
||||
details?: string;
|
||||
}
|
||||
|
||||
// Получение информации о игроке
|
||||
export async function fetchPlayer(uuid: string): Promise<Player> {
|
||||
try {
|
||||
const response = await fetch(`${API_BASE_URL}/users/${uuid}`);
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json();
|
||||
throw new Error(errorData.message || 'Ошибка получения данных игрока');
|
||||
}
|
||||
|
||||
return await response.json();
|
||||
} catch (error) {
|
||||
console.error('API ошибка:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export async function fetchCoins(username: string): Promise<CoinsResponse> {
|
||||
try {
|
||||
const response = await fetch(`${API_BASE_URL}/users/${username}/coins`);
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json();
|
||||
throw new Error(errorData.message || 'Ошибка получения данных игрока');
|
||||
}
|
||||
return await response.json();
|
||||
} catch (error) {
|
||||
console.error('API ошибка:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// Загрузка скина
|
||||
export async function uploadSkin(
|
||||
username: string,
|
||||
skinFile: File,
|
||||
skinModel: string,
|
||||
): Promise<void> {
|
||||
const savedConfig = localStorage.getItem('launcher_config');
|
||||
let accessToken = '';
|
||||
let clientToken = '';
|
||||
|
||||
if (savedConfig) {
|
||||
const config = JSON.parse(savedConfig);
|
||||
accessToken = config.accessToken || '';
|
||||
clientToken = config.clientToken || '';
|
||||
}
|
||||
|
||||
const formData = new FormData();
|
||||
formData.append('skin_file', skinFile);
|
||||
formData.append('skin_model', skinModel);
|
||||
formData.append('accessToken', accessToken);
|
||||
formData.append('clientToken', clientToken);
|
||||
|
||||
const response = await fetch(`${API_BASE_URL}/user/${username}/skin`, {
|
||||
method: 'POST',
|
||||
body: formData,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json();
|
||||
throw new Error(errorData.message || 'Не удалось загрузить скин');
|
||||
}
|
||||
}
|
||||
|
||||
// Получение токенов из локального хранилища
|
||||
export function getAuthTokens(): { accessToken: string; clientToken: string } {
|
||||
const savedConfig = localStorage.getItem('launcher_config');
|
||||
let accessToken = '';
|
||||
let clientToken = '';
|
||||
|
||||
if (savedConfig) {
|
||||
const config = JSON.parse(savedConfig);
|
||||
accessToken = config.accessToken || '';
|
||||
clientToken = config.clientToken || '';
|
||||
}
|
||||
|
||||
return { accessToken, clientToken };
|
||||
}
|
||||
|
||||
// Загрузка плаща
|
||||
export async function uploadCape(
|
||||
username: string,
|
||||
capeFile: File,
|
||||
): Promise<void> {
|
||||
const { accessToken, clientToken } = getAuthTokens();
|
||||
|
||||
const formData = new FormData();
|
||||
formData.append('cape_file', capeFile);
|
||||
formData.append('accessToken', accessToken);
|
||||
formData.append('clientToken', clientToken);
|
||||
|
||||
const response = await fetch(`${API_BASE_URL}/user/${username}/cape`, {
|
||||
method: 'POST',
|
||||
body: formData,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json();
|
||||
throw new Error(errorData.message || 'Не удалось загрузить плащ');
|
||||
}
|
||||
}
|
72
src/renderer/components/SkinViewer.tsx
Normal file
72
src/renderer/components/SkinViewer.tsx
Normal file
@ -0,0 +1,72 @@
|
||||
import { useEffect, useRef } from 'react';
|
||||
|
||||
interface SkinViewerProps {
|
||||
width?: number;
|
||||
height?: number;
|
||||
skinUrl?: string;
|
||||
capeUrl?: string;
|
||||
walkingSpeed?: number;
|
||||
autoRotate?: boolean;
|
||||
}
|
||||
|
||||
export default function SkinViewer({
|
||||
width = 300,
|
||||
height = 400,
|
||||
skinUrl,
|
||||
capeUrl,
|
||||
walkingSpeed = 0.5,
|
||||
autoRotate = true,
|
||||
}: SkinViewerProps) {
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||
const viewerRef = useRef<any>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!canvasRef.current) return;
|
||||
|
||||
// Используем динамический импорт для обхода проблемы ESM/CommonJS
|
||||
const initSkinViewer = async () => {
|
||||
try {
|
||||
const skinview3d = await import('skinview3d');
|
||||
|
||||
// Создаем просмотрщик скина по документации
|
||||
const viewer = new skinview3d.SkinViewer({
|
||||
canvas: canvasRef.current,
|
||||
width,
|
||||
height,
|
||||
skin: skinUrl || undefined,
|
||||
cape: capeUrl || undefined,
|
||||
});
|
||||
|
||||
// Настраиваем вращение
|
||||
viewer.autoRotate = autoRotate;
|
||||
|
||||
// Настраиваем анимацию ходьбы
|
||||
viewer.animation = new skinview3d.WalkingAnimation();
|
||||
viewer.animation.speed = walkingSpeed;
|
||||
|
||||
// Сохраняем экземпляр для очистки
|
||||
viewerRef.current = viewer;
|
||||
} catch (error) {
|
||||
console.error('Ошибка при инициализации skinview3d:', error);
|
||||
}
|
||||
};
|
||||
|
||||
initSkinViewer();
|
||||
|
||||
// Очистка при размонтировании
|
||||
return () => {
|
||||
if (viewerRef.current) {
|
||||
viewerRef.current.dispose();
|
||||
}
|
||||
};
|
||||
}, [width, height, skinUrl, capeUrl, walkingSpeed, autoRotate]);
|
||||
|
||||
return (
|
||||
<canvas
|
||||
ref={canvasRef}
|
||||
width={width}
|
||||
height={height}
|
||||
style={{ display: 'block' }}
|
||||
/>
|
||||
);
|
||||
}
|
@ -1,9 +1,10 @@
|
||||
import { Box, Button, Typography } from '@mui/material';
|
||||
import { Box, Button, Tab, Tabs, Typography } from '@mui/material';
|
||||
import CloseRoundedIcon from '@mui/icons-material/CloseRounded';
|
||||
import { useLocation, useNavigate } from 'react-router-dom';
|
||||
import ArrowBackRoundedIcon from '@mui/icons-material/ArrowBackRounded';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Tooltip } from '@mui/material';
|
||||
import { fetchCoins } from '../api';
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
@ -23,15 +24,6 @@ interface TopBarProps {
|
||||
username?: string;
|
||||
}
|
||||
|
||||
interface CoinsResponse {
|
||||
username: string;
|
||||
coins: number;
|
||||
total_time_played: {
|
||||
seconds: number;
|
||||
formatted: string;
|
||||
};
|
||||
}
|
||||
|
||||
export default function TopBar({ onRegister, username }: TopBarProps) {
|
||||
// Получаем текущий путь
|
||||
const location = useLocation();
|
||||
@ -40,6 +32,18 @@ export default function TopBar({ onRegister, username }: TopBarProps) {
|
||||
const isVersionsExplorerPage = location.pathname.startsWith('/');
|
||||
const navigate = useNavigate();
|
||||
const [coins, setCoins] = useState<number>(0);
|
||||
const [value, setValue] = useState(0);
|
||||
|
||||
const handleChange = (event: React.SyntheticEvent, newValue: number) => {
|
||||
setValue(newValue);
|
||||
if (newValue === 0) {
|
||||
navigate('/');
|
||||
} else if (newValue === 1) {
|
||||
navigate('/profile');
|
||||
} else if (newValue === 2) {
|
||||
navigate('/shop');
|
||||
}
|
||||
};
|
||||
|
||||
const handleLaunchPage = () => {
|
||||
navigate('/');
|
||||
@ -59,17 +63,12 @@ export default function TopBar({ onRegister, username }: TopBarProps) {
|
||||
};
|
||||
|
||||
// Функция для получения количества монет
|
||||
const fetchCoins = async () => {
|
||||
const fetchCoinsData = async () => {
|
||||
if (!username) return;
|
||||
|
||||
try {
|
||||
const response = await fetch(
|
||||
`http://147.78.65.214:8000/users/${username}/coins`,
|
||||
);
|
||||
if (response.ok) {
|
||||
const data: CoinsResponse = await response.json();
|
||||
setCoins(data.coins);
|
||||
}
|
||||
const coinsData = await fetchCoins(username);
|
||||
setCoins(coinsData.coins);
|
||||
} catch (error) {
|
||||
console.error('Ошибка при получении количества монет:', error);
|
||||
}
|
||||
@ -77,8 +76,8 @@ export default function TopBar({ onRegister, username }: TopBarProps) {
|
||||
|
||||
useEffect(() => {
|
||||
if (username) {
|
||||
fetchCoins();
|
||||
const intervalId = setInterval(fetchCoins, 60000);
|
||||
fetchCoinsData();
|
||||
const intervalId = setInterval(fetchCoinsData, 60000);
|
||||
return () => clearInterval(intervalId);
|
||||
}
|
||||
}, [username]);
|
||||
@ -130,6 +129,28 @@ export default function TopBar({ onRegister, username }: TopBarProps) {
|
||||
<ArrowBackRoundedIcon />
|
||||
</Button>
|
||||
)}
|
||||
{!isLaunchPage && (
|
||||
<Box sx={{ borderBottom: 1, borderColor: 'divider' }}>
|
||||
<Tabs
|
||||
value={value}
|
||||
onChange={handleChange}
|
||||
aria-label="basic tabs example"
|
||||
>
|
||||
<Tab
|
||||
label="Версии"
|
||||
sx={{ color: 'white', fontFamily: 'Benzin-Bold' }}
|
||||
/>
|
||||
<Tab
|
||||
label="Профиль"
|
||||
sx={{ color: 'white', fontFamily: 'Benzin-Bold' }}
|
||||
/>
|
||||
<Tab
|
||||
label="Магазин"
|
||||
sx={{ color: 'white', fontFamily: 'Benzin-Bold' }}
|
||||
/>
|
||||
</Tabs>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
{/* Центр */}
|
||||
<Box
|
||||
|
247
src/renderer/pages/Profile.tsx
Normal file
247
src/renderer/pages/Profile.tsx
Normal file
@ -0,0 +1,247 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import SkinViewer from '../components/SkinViewer';
|
||||
import { fetchPlayer, uploadSkin } from '../api';
|
||||
|
||||
import {
|
||||
Box,
|
||||
Typography,
|
||||
Paper,
|
||||
Button,
|
||||
FormControl,
|
||||
InputLabel,
|
||||
Select,
|
||||
MenuItem,
|
||||
Alert,
|
||||
CircularProgress,
|
||||
} from '@mui/material';
|
||||
|
||||
export default function Profile() {
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
const [walkingSpeed, setWalkingSpeed] = useState<number>(0.5);
|
||||
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);
|
||||
|
||||
useEffect(() => {
|
||||
const savedConfig = localStorage.getItem('launcher_config');
|
||||
if (savedConfig) {
|
||||
const config = JSON.parse(savedConfig);
|
||||
if (config.uuid) {
|
||||
loadPlayerData(config.uuid);
|
||||
setUsername(config.username || '');
|
||||
}
|
||||
}
|
||||
}, []);
|
||||
|
||||
const loadPlayerData = async (uuid: string) => {
|
||||
try {
|
||||
const player = await fetchPlayer(uuid);
|
||||
setSkin(player.skin_url);
|
||||
setCape(player.cloak_url);
|
||||
} catch (error) {
|
||||
console.error('Ошибка при получении данных игрока:', error);
|
||||
setSkin('');
|
||||
setCape('');
|
||||
}
|
||||
};
|
||||
|
||||
// Обработка перетаскивания файла
|
||||
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 handleUploadSkin = async () => {
|
||||
if (!skinFile || !username) {
|
||||
setStatusMessage('Необходимо выбрать файл и указать имя пользователя');
|
||||
setUploadStatus('error');
|
||||
return;
|
||||
}
|
||||
|
||||
setUploadStatus('loading');
|
||||
|
||||
try {
|
||||
await uploadSkin(username, skinFile, skinModel);
|
||||
|
||||
setStatusMessage('Скин успешно загружен!');
|
||||
setUploadStatus('success');
|
||||
|
||||
// Обновляем информацию о игроке, чтобы увидеть новый скин
|
||||
const config = JSON.parse(
|
||||
localStorage.getItem('launcher_config') || '{}',
|
||||
);
|
||||
if (config.uuid) {
|
||||
loadPlayerData(config.uuid);
|
||||
}
|
||||
} catch (error) {
|
||||
setStatusMessage(
|
||||
`Ошибка: ${error instanceof Error ? error.message : 'Не удалось загрузить скин'}`,
|
||||
);
|
||||
setUploadStatus('error');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Box
|
||||
sx={{
|
||||
my: 4,
|
||||
display: 'flex',
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: 2,
|
||||
}}
|
||||
>
|
||||
<Paper
|
||||
elevation={0}
|
||||
sx={{
|
||||
p: 0,
|
||||
borderRadius: 2,
|
||||
overflow: 'hidden',
|
||||
mb: 4,
|
||||
bgcolor: 'transparent',
|
||||
}}
|
||||
>
|
||||
{/* Используем переработанный компонент SkinViewer */}
|
||||
<SkinViewer
|
||||
width={300}
|
||||
height={400}
|
||||
skinUrl={skin}
|
||||
capeUrl={cape}
|
||||
walkingSpeed={walkingSpeed}
|
||||
autoRotate={true}
|
||||
/>
|
||||
</Paper>
|
||||
|
||||
<Box sx={{ width: '100%', maxWidth: '500px', mt: 2 }}>
|
||||
<Typography variant="h6" gutterBottom>
|
||||
Установить скин
|
||||
</Typography>
|
||||
|
||||
<Box
|
||||
sx={{
|
||||
border: '2px dashed',
|
||||
borderColor: isDragOver ? 'primary.main' : 'grey.400',
|
||||
borderRadius: 2,
|
||||
p: 3,
|
||||
mb: 2,
|
||||
textAlign: 'center',
|
||||
cursor: 'pointer',
|
||||
bgcolor: isDragOver ? 'rgba(25, 118, 210, 0.08)' : 'transparent',
|
||||
transition: 'all 0.2s',
|
||||
}}
|
||||
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: 'white' }}>
|
||||
{skinFile
|
||||
? `Выбран файл: ${skinFile.name}`
|
||||
: 'Перетащите PNG файл скина или кликните для выбора'}
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
<FormControl color="primary" fullWidth sx={{ mb: 2, color: 'white' }}>
|
||||
<InputLabel sx={{ color: 'white' }}>Модель скина</InputLabel>
|
||||
<Select
|
||||
value={skinModel}
|
||||
label="Модель скина"
|
||||
onChange={(e) => setSkinModel(e.target.value)}
|
||||
sx={{
|
||||
color: 'white',
|
||||
borderColor: 'white',
|
||||
'& .MuiInputBase-input': {
|
||||
border: '1px solid white',
|
||||
transition: 'unset',
|
||||
},
|
||||
'&:focus': {
|
||||
borderRadius: 4,
|
||||
borderColor: '#80bdff',
|
||||
boxShadow: '0 0 0 0.2rem rgba(0,123,255,.25)',
|
||||
},
|
||||
}}
|
||||
>
|
||||
<MenuItem value="">По умолчанию</MenuItem>
|
||||
<MenuItem value="slim">Тонкая (Alex)</MenuItem>
|
||||
<MenuItem value="classic">Классическая (Steve)</MenuItem>
|
||||
</Select>
|
||||
</FormControl>
|
||||
|
||||
{uploadStatus === 'error' && (
|
||||
<Alert severity="error" sx={{ mb: 2 }}>
|
||||
{statusMessage}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{uploadStatus === 'success' && (
|
||||
<Alert severity="success" sx={{ mb: 2 }}>
|
||||
{statusMessage}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<Button
|
||||
sx={{ color: 'white' }}
|
||||
variant="contained"
|
||||
color="primary"
|
||||
fullWidth
|
||||
onClick={handleUploadSkin}
|
||||
disabled={uploadStatus === 'loading' || !skinFile}
|
||||
startIcon={
|
||||
uploadStatus === 'loading' ? (
|
||||
<CircularProgress size={20} color="inherit" />
|
||||
) : null
|
||||
}
|
||||
>
|
||||
{uploadStatus === 'loading' ? (
|
||||
<Typography sx={{ color: 'white' }}>Загрузка...</Typography>
|
||||
) : (
|
||||
<Typography sx={{ color: 'white' }}>Установить скин</Typography>
|
||||
)}
|
||||
</Button>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
3
src/renderer/pages/Shop.tsx
Normal file
3
src/renderer/pages/Shop.tsx
Normal file
@ -0,0 +1,3 @@
|
||||
export default function Shop() {
|
||||
return <div>Shop</div>;
|
||||
}
|
Reference in New Issue
Block a user