add: capes switch in user profile
This commit is contained in:
@ -19,6 +19,17 @@ export interface CoinsResponse {
|
||||
};
|
||||
}
|
||||
|
||||
export interface Cape {
|
||||
cape_id: string;
|
||||
cape_name: string;
|
||||
cape_description: string;
|
||||
image_url: string;
|
||||
purchased_at: string;
|
||||
is_active: boolean;
|
||||
}
|
||||
|
||||
export type CapesResponse = Cape[];
|
||||
|
||||
export interface ApiError {
|
||||
message: string;
|
||||
details?: string;
|
||||
@ -55,6 +66,63 @@ export async function fetchCoins(username: string): Promise<CoinsResponse> {
|
||||
}
|
||||
}
|
||||
|
||||
export async function fetchCapes(username: string): Promise<CapesResponse> {
|
||||
try {
|
||||
const response = await fetch(
|
||||
`${API_BASE_URL}/store/user/${username}/capes`,
|
||||
);
|
||||
if (!response.ok) {
|
||||
return []; // Если плащи не найдены, возвращаем пустой массив
|
||||
}
|
||||
return await response.json();
|
||||
} catch (error) {
|
||||
console.error('API ошибка:', error);
|
||||
return []; // В случае ошибки возвращаем пустой массив
|
||||
}
|
||||
}
|
||||
|
||||
export async function activateCape(
|
||||
username: string,
|
||||
cape_id: string,
|
||||
): Promise<void> {
|
||||
const response = await fetch(
|
||||
`${API_BASE_URL}/store/user/${username}/capes/activate/${cape_id}`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
cape_id: cape_id,
|
||||
}),
|
||||
},
|
||||
);
|
||||
if (!response.ok) {
|
||||
throw new Error('Не удалось активировать плащ');
|
||||
}
|
||||
}
|
||||
|
||||
export async function deactivateCape(
|
||||
username: string,
|
||||
cape_id: string,
|
||||
): Promise<void> {
|
||||
const response = await fetch(
|
||||
`${API_BASE_URL}/store/user/${username}/capes/deactivate/${cape_id}`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
cape_id: cape_id,
|
||||
}),
|
||||
},
|
||||
);
|
||||
if (!response.ok) {
|
||||
throw new Error('Не удалось деактивировать плащ');
|
||||
}
|
||||
}
|
||||
|
||||
// Загрузка скина
|
||||
export async function uploadSkin(
|
||||
username: string,
|
||||
|
@ -1,6 +1,13 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import SkinViewer from '../components/SkinViewer';
|
||||
import { fetchPlayer, uploadSkin } from '../api';
|
||||
import {
|
||||
fetchPlayer,
|
||||
uploadSkin,
|
||||
fetchCapes,
|
||||
Cape,
|
||||
activateCape,
|
||||
deactivateCape,
|
||||
} from '../api';
|
||||
|
||||
import {
|
||||
Box,
|
||||
@ -13,6 +20,11 @@ import {
|
||||
MenuItem,
|
||||
Alert,
|
||||
CircularProgress,
|
||||
Card,
|
||||
CardContent,
|
||||
CardMedia,
|
||||
Tooltip,
|
||||
CardActions,
|
||||
} from '@mui/material';
|
||||
|
||||
export default function Profile() {
|
||||
@ -28,6 +40,9 @@ export default function Profile() {
|
||||
>('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);
|
||||
|
||||
useEffect(() => {
|
||||
const savedConfig = localStorage.getItem('launcher_config');
|
||||
@ -36,15 +51,19 @@ export default function Profile() {
|
||||
if (config.uuid) {
|
||||
loadPlayerData(config.uuid);
|
||||
setUsername(config.username || '');
|
||||
loadCapesData(config.username || '');
|
||||
setUuid(config.uuid || '');
|
||||
}
|
||||
}
|
||||
}, []);
|
||||
|
||||
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('');
|
||||
@ -52,6 +71,18 @@ export default function Profile() {
|
||||
}
|
||||
};
|
||||
|
||||
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();
|
||||
@ -83,8 +114,24 @@ export default function Profile() {
|
||||
}
|
||||
};
|
||||
|
||||
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) {
|
||||
setStatusMessage('Необходимо выбрать файл и указать имя пользователя');
|
||||
setUploadStatus('error');
|
||||
@ -111,6 +158,8 @@ export default function Profile() {
|
||||
`Ошибка: ${error instanceof Error ? error.message : 'Не удалось загрузить скин'}`,
|
||||
);
|
||||
setUploadStatus('error');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
@ -124,124 +173,213 @@ export default function Profile() {
|
||||
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)}
|
||||
{loading ? (
|
||||
<CircularProgress />
|
||||
) : (
|
||||
<>
|
||||
<Paper
|
||||
elevation={0}
|
||||
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)',
|
||||
},
|
||||
p: 0,
|
||||
borderRadius: 2,
|
||||
overflow: 'hidden',
|
||||
mb: 4,
|
||||
bgcolor: 'transparent',
|
||||
}}
|
||||
>
|
||||
<MenuItem value="">По умолчанию</MenuItem>
|
||||
<MenuItem value="slim">Тонкая (Alex)</MenuItem>
|
||||
<MenuItem value="classic">Классическая (Steve)</MenuItem>
|
||||
</Select>
|
||||
</FormControl>
|
||||
{/* Используем переработанный компонент SkinViewer */}
|
||||
<SkinViewer
|
||||
width={300}
|
||||
height={400}
|
||||
skinUrl={skin}
|
||||
capeUrl={cape}
|
||||
walkingSpeed={walkingSpeed}
|
||||
autoRotate={true}
|
||||
/>
|
||||
</Paper>
|
||||
|
||||
{uploadStatus === 'error' && (
|
||||
<Alert severity="error" sx={{ mb: 2 }}>
|
||||
{statusMessage}
|
||||
</Alert>
|
||||
)}
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
|
||||
<Box
|
||||
sx={{
|
||||
width: '100%',
|
||||
maxWidth: '500px',
|
||||
bgcolor: 'rgba(255, 255, 255, 0.05)',
|
||||
padding: '3vw',
|
||||
borderRadius: '1vw',
|
||||
}}
|
||||
>
|
||||
<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>
|
||||
|
||||
{uploadStatus === 'success' && (
|
||||
<Alert severity="success" sx={{ mb: 2 }}>
|
||||
{statusMessage}
|
||||
</Alert>
|
||||
)}
|
||||
<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>
|
||||
|
||||
<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>
|
||||
{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
|
||||
sx={{
|
||||
display: 'flex',
|
||||
justifyContent: 'center',
|
||||
flexDirection: 'column',
|
||||
gap: 2,
|
||||
}}
|
||||
>
|
||||
<Typography>Ваши плащи</Typography>
|
||||
<Box sx={{ display: 'flex', flexDirection: 'row', gap: 2 }}>
|
||||
{capes.map((cape) => (
|
||||
<Tooltip arrow title={cape.cape_description}>
|
||||
<Card
|
||||
key={cape.cape_id}
|
||||
sx={{
|
||||
bgcolor: 'rgba(255, 255, 255, 0.05)',
|
||||
width: 200, // фиксированная ширина карточки
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
>
|
||||
<CardMedia
|
||||
component="img"
|
||||
image={cape.image_url}
|
||||
alt={cape.cape_name}
|
||||
sx={{
|
||||
display: 'block',
|
||||
width: '100%',
|
||||
transform:
|
||||
'scale(2.9) translateX(66px) translateY(32px)',
|
||||
imageRendering: 'pixelated',
|
||||
}}
|
||||
/>
|
||||
<CardContent
|
||||
sx={{ bgcolor: 'rgba(255, 255, 255, 0.05)', pt: '9vh' }}
|
||||
>
|
||||
<Typography sx={{ color: 'white' }}>
|
||||
{cape.cape_name}
|
||||
</Typography>
|
||||
</CardContent>
|
||||
{cape.is_active ? (
|
||||
<CardActions
|
||||
sx={{ display: 'flex', justifyContent: 'center' }}
|
||||
>
|
||||
<Button
|
||||
variant="contained"
|
||||
sx={{ bgcolor: 'red' }}
|
||||
onClick={() => handleDeactivateCape(cape.cape_id)}
|
||||
>
|
||||
Снять
|
||||
</Button>
|
||||
</CardActions>
|
||||
) : (
|
||||
<CardActions
|
||||
sx={{ display: 'flex', justifyContent: 'center' }}
|
||||
>
|
||||
<Button
|
||||
variant="contained"
|
||||
sx={{ bgcolor: 'green' }}
|
||||
onClick={() => handleActivateCape(cape.cape_id)}
|
||||
>
|
||||
Надеть
|
||||
</Button>
|
||||
</CardActions>
|
||||
)}
|
||||
</Card>
|
||||
</Tooltip>
|
||||
))}
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
</>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
Reference in New Issue
Block a user