add: skin viewer, refatoring to api

This commit is contained in:
2025-07-18 18:29:34 +05:00
parent f3aa32a60a
commit ec54219192
10 changed files with 704 additions and 33 deletions

View File

@ -16,6 +16,7 @@ import {
} from '@xmcl/installer';
import { spawn } from 'child_process';
import { AuthService } from './auth-service';
import { API_BASE_URL } from '../renderer/api';
// Константы
const AUTHLIB_INJECTOR_FILENAME = 'authlib-injector-1.2.5.jar';
@ -740,7 +741,7 @@ export function initMinecraftHandlers() {
server: serverConfig, // Используем созданный объект конфигурации
extraJVMArgs: [
'-Dlog4j2.formatMsgNoLookups=true',
`-javaagent:${authlibPath}=http://147.78.65.214:8000`,
`-javaagent:${authlibPath}=${API_BASE_URL}`,
`-Xmx${memory}M`,
'-Dauthlibinjector.skinWhitelist=127.0.0.1,falrfg-213-87-196-173.ru.tuna.am',
'-Dauthlibinjector.debug=verbose,authlib',

View File

@ -53,3 +53,7 @@ h5 {
h6 {
font-family: 'Benzin-Bold' !important;
}
span {
font-family: 'Benzin-Bold' !important;
}

View File

@ -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
View 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 || 'Не удалось загрузить плащ');
}
}

View 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' }}
/>
);
}

View File

@ -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

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

View File

@ -0,0 +1,3 @@
export default function Shop() {
return <div>Shop</div>;
}