ne minor, a ebat fix

This commit is contained in:
aurinex
2025-12-14 21:14:59 +05:00
parent de616ee8ac
commit ae4a67dcdf
20 changed files with 1818 additions and 652 deletions

View File

@ -7,6 +7,7 @@ import {
Typography,
Button,
CardMedia,
Divider,
} from '@mui/material';
import CoinsDisplay from './CoinsDisplay';
@ -249,28 +250,30 @@ export const BonusShopItem: React.FC<BonusShopItemProps> = ({
)}
</Box>
<Divider sx={{background: 'rgba(160, 160, 160, 0.3)', borderRadius: '2vw'}}/>
{!isBuyMode && onToggleActive && (
<Button
variant="outlined"
size="small"
onClick={onToggleActive}
disabled={disabled}
sx={{
mt: 0.5,
borderRadius: '2.5vw',
textTransform: 'none',
fontSize: '0.75rem',
px: 2,
borderColor: 'rgba(255,255,255,0.4)',
color: 'rgba(255,255,255,0.9)',
'&:hover': {
borderColor: 'rgba(255,255,255,0.8)',
background: 'rgba(255,255,255,0.05)',
},
}}
>
<Typography
onClick={onToggleActive}
sx={{
mt: '1vw',
fontFamily: 'Benzin-Bold',
fontSize: '1vw',
textTransform: 'uppercase',
letterSpacing: '0.08em',
cursor: 'pointer',
backgroundImage:
'linear-gradient(71deg, #F27121 0%, #E940CD 60%, #8A2387 100%)',
WebkitBackgroundClip: 'text',
WebkitTextFillColor: 'transparent',
textShadow: '0 0 15px rgba(0,0,0,0.9)',
'&:hover': {
opacity: 1,
},
}}
>
{isActive ? 'Выключить' : 'Включить'}
</Button>
</Typography>
)}
</Box>

View File

@ -1,18 +1,7 @@
// src/renderer/components/CapeCard.tsx
import React from 'react';
import {
Card,
CardMedia,
CardContent,
Typography,
CardActions,
Button,
Tooltip,
Box,
Chip,
} from '@mui/material';
import React, { useMemo } from 'react';
import { Box, Typography, Paper, Chip, Button } from '@mui/material';
import CustomTooltip from './Notifications/CustomTooltip';
// Тип для плаща с необязательными полями для обоих вариантов использования
export interface CapeCardProps {
cape: {
cape_id?: string;
@ -31,104 +20,189 @@ export interface CapeCardProps {
actionDisabled?: boolean;
}
const GRADIENT =
'linear-gradient(71deg, #F27121 0%, #E940CD 70%, #8A2387 100%)';
export default function CapeCard({
cape,
mode,
onAction,
actionDisabled = false,
}: CapeCardProps) {
// Определяем текст и цвет кнопки в зависимости от режима
const getActionButton = () => {
if (mode === 'shop') {
return {
text: 'Купить',
color: 'primary',
};
} else {
// Профиль
return cape.is_active
? { text: 'Снять', color: 'error' }
: { text: 'Надеть', color: 'success' };
}
};
const actionButton = getActionButton();
// В функции компонента добавьте нормализацию данных
const capeId = cape.cape_id || cape.id || '';
const capeName = cape.cape_name || cape.name || '';
const capeName = cape.cape_name || cape.name || 'Без названия';
const capeDescription = cape.cape_description || cape.description || '';
const action = useMemo(() => {
if (mode === 'shop') {
return { text: 'Купить', variant: 'gradient' as const };
}
return cape.is_active
? { text: 'Снять', variant: 'danger' as const }
: { text: 'Надеть', variant: 'success' as const };
}, [mode, cape.is_active]);
const topRightChip =
mode === 'shop' && cape.price !== undefined
? `${cape.price} коинов`
: cape.is_active
? 'Активен'
: undefined;
return (
<CustomTooltip arrow title={capeDescription}>
<Card
<CustomTooltip arrow title={capeDescription} placement="bottom">
<Paper
elevation={0}
sx={{
bgcolor: 'rgba(255, 255, 255, 0.05)',
width: 200,
width: '16.5vw',
borderRadius: '1.2vw',
overflow: 'hidden',
position: 'relative', // для позиционирования ценника
borderRadius: '1vw',
position: 'relative',
color: 'white',
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)',
transition:
'transform 0.18s ease, border-color 0.18s ease, box-shadow 0.18s ease',
'&:hover': {
transform: 'scale(1.01)',
borderColor: 'rgba(242,113,33,0.35)',
boxShadow: '0 1.4vw 3.8vw rgba(0,0,0,0.60)',
},
}}
>
{/* Ценник для магазина */}
{mode === 'shop' && cape.price !== undefined && (
{/* градиентная полоска-акцент (как у твоих блоков) */}
<Box
sx={{
position: 'absolute',
left: 0,
top: 0,
bottom: 0,
width: '0.35vw',
background: GRADIENT,
opacity: 0.9,
pointerEvents: 'none',
zIndex: 2,
}}
/>
{/* chip справа сверху */}
{topRightChip && (
<Chip
label={`${cape.price} коинов`}
label={topRightChip}
size="small"
sx={{
position: 'absolute',
top: 8,
right: 8,
zIndex: 2,
bgcolor: 'rgba(0, 0, 0, 0.7)',
top: '0.8vw',
right: '0.8vw',
zIndex: 3,
height: '1.55rem',
fontSize: '0.72rem',
fontWeight: 900,
color: 'white',
fontWeight: 'bold',
borderRadius: '999px',
background:
mode === 'shop'
? 'rgba(0,0,0,0.65)'
: 'linear-gradient(120deg, rgba(242,113,33,0.22), rgba(233,64,205,0.14), rgba(138,35,135,0.18))',
border: '1px solid rgba(255,255,255,0.10)',
backdropFilter: 'blur(12px)',
}}
/>
)}
<CardMedia
component="img"
image={cape.image_url}
alt={capeName}
{/* preview */}
<Box
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: '6vw',
minHeight: '5vw',
px: '1.1vw',
pt: '1.0vw',
pb: '0.7vw',
display: 'flex',
justifyContent: 'center',
}}
>
<Typography sx={{ color: 'white' }}>{capeName}</Typography>
</CardContent>
<CardActions sx={{ display: 'flex', justifyContent: 'center' }}>
<Button
variant="contained"
color={actionButton.color as 'primary' | 'success' | 'error'}
onClick={() => onAction(capeId)}
disabled={actionDisabled}
<Box
sx={{
borderRadius: '2vw',
p: '0.5vw 2.5vw',
color: 'white',
backgroundColor: 'rgb(0, 134, 0)',
'&:hover': {
backgroundColor: 'rgba(0, 134, 0, 0.5)',
},
fontFamily: 'Benzin-Bold',
borderRadius: '1.0vw',
overflow: 'hidden',
border: '1px solid rgba(255,255,255,0.10)',
background: 'rgba(255,255,255,0.04)',
maxHeight: '21vw',
}}
>
{actionButton.text}
</Button>
</CardActions>
</Card>
{/* Здесь показываем ЛЕВУЮ половину текстуры (лицевую часть) */}
<Box
sx={{
width: '44vw',
height: '36vw',
minWidth: '462px',
minHeight: '380px',
imageRendering: 'pixelated',
backgroundImage: `url(${cape.image_url})`,
backgroundRepeat: 'no-repeat',
backgroundSize: '200% 100%', // важно: режем пополам “кадром”
backgroundPosition: 'left center',
ml: '-2vw',
// если нужно чуть увеличить/сдвинуть — делай через backgroundPosition
}}
/>
</Box>
</Box>
{/* content */}
<Box sx={{ px: '1.1vw', pb: '1.1vw' }}>
<Typography
sx={{
fontFamily: 'Benzin-Bold',
fontSize: '0.95vw',
minFontSize: 14,
color: 'rgba(255,255,255,0.92)',
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
}}
>
{capeName}
</Typography>
{/* действия */}
<Box sx={{ mt: '0.9vw', display: 'flex', justifyContent: 'center' }}>
<Button
fullWidth
disableRipple
onClick={() => onAction(capeId)}
disabled={actionDisabled || !capeId}
sx={{
borderRadius: '999px',
py: '0.75vw',
fontFamily: 'Benzin-Bold',
fontWeight: 900,
color: '#fff',
background:
action.variant === 'gradient'
? GRADIENT
: action.variant === 'success'
? 'rgba(0, 134, 0, 0.95)'
: 'rgba(190, 35, 35, 0.95)',
boxShadow: '0 1.0vw 2.8vw rgba(0,0,0,0.40)',
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)',
},
}}
>
{action.text}
</Button>
</Box>
</Box>
</Paper>
</CustomTooltip>
);
}

View File

@ -1,27 +1,23 @@
// CoinsDisplay.tsx
import { Box, Typography } from '@mui/material';
import CustomTooltip from './Notifications/CustomTooltip';
import { useEffect, useState } from 'react';
import { useEffect, useMemo, useState } from 'react';
import { fetchCoins } from '../api';
import type { SxProps, Theme } from '@mui/material/styles';
interface CoinsDisplayProps {
// Основные пропсы
value?: number; // Передаем значение напрямую
username?: string; // Или получаем по username из API
value?: number;
username?: string;
// Опции отображения
size?: 'small' | 'medium' | 'large';
showTooltip?: boolean;
tooltipText?: string;
showIcon?: boolean;
iconColor?: string;
// Опции обновления
autoUpdate?: boolean; // Автоматическое обновление из API
updateInterval?: number; // Интервал обновления в миллисекундах
autoUpdate?: boolean;
updateInterval?: number;
// Стилизация
backgroundColor?: string;
textColor?: string;
@ -29,31 +25,54 @@ interface CoinsDisplayProps {
}
export default function CoinsDisplay({
// Основные пропсы
value: externalValue,
username,
// Опции отображения
size = 'medium',
showTooltip = true,
tooltipText = 'Попы — внутриигровая валюта, начисляемая за время игры на серверах.',
showIcon = true,
iconColor = '#2bff00ff',
// Опции обновления
autoUpdate = false,
updateInterval = 60000,
// Стилизация
backgroundColor = 'rgba(0, 0, 0, 0.2)',
textColor = 'white',
sx,
}: CoinsDisplayProps) {
const [coins, setCoins] = useState<number>(externalValue || 0);
const storageKey = useMemo(() => {
// ключ под конкретного пользователя
return username ? `coins:${username}` : 'coins:anonymous';
}, [username]);
const readCachedCoins = (): number | null => {
if (typeof window === 'undefined') return null;
try {
const raw = localStorage.getItem(storageKey);
if (!raw) return null;
const parsed = Number(raw);
return Number.isFinite(parsed) ? parsed : null;
} catch {
return null;
}
};
const [coins, setCoins] = useState<number>(() => {
// 1) если пришло значение извне — оно приоритетнее
if (externalValue !== undefined) return externalValue;
// 2) иначе пробуем localStorage
const cached = readCachedCoins();
if (cached !== null) return cached;
// 3) иначе 0
return 0;
});
const [isLoading, setIsLoading] = useState<boolean>(false);
// Определяем размеры в зависимости от параметра size
const getSizes = () => {
switch (size) {
case 'small':
@ -86,52 +105,61 @@ export default function CoinsDisplay({
const sizes = getSizes();
// Функция для получения количества монет из API
const fetchCoinsData = async () => {
if (!username) return;
setIsLoading(true);
try {
const coinsData = await fetchCoins(username);
setCoins(coinsData.coins);
} catch (error) {
console.error('Ошибка при получении количества монет:', error);
} finally {
setIsLoading(false);
}
const formatNumber = (num: number): string => {
return num.toLocaleString('ru-RU');
};
// Эффект для внешнего значения
// Сохраняем актуальный баланс в localStorage при любом изменении coins
useEffect(() => {
if (typeof window === 'undefined') return;
try {
localStorage.setItem(storageKey, String(coins));
} catch {
// игнорируем (private mode, quota и т.п.)
}
}, [coins, storageKey]);
// Если пришло внешнее значение — обновляем и оно же попадёт в localStorage через эффект выше
useEffect(() => {
if (externalValue !== undefined) {
setCoins(externalValue);
}
}, [externalValue]);
// Эффект для API обновлений
// При смене username можно сразу подхватить кэш, чтобы не мигало при первом fetch
useEffect(() => {
if (username && autoUpdate) {
fetchCoinsData();
if (externalValue !== undefined) return; // внешнее значение важнее
const cached = readCachedCoins();
if (cached !== null) setCoins(cached);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [storageKey]);
// Создаем интервалы для периодического обновления данных
const coinsInterval = setInterval(fetchCoinsData, updateInterval);
const fetchCoinsData = async () => {
if (!username) return;
return () => {
clearInterval(coinsInterval);
};
}
}, [username, autoUpdate, updateInterval]);
// Ручное обновление данных
const handleRefresh = () => {
if (username) {
fetchCoinsData();
setIsLoading(true);
try {
const coinsData = await fetchCoins(username);
// ВАЖНО: не показываем "..." — просто меняем число, когда пришёл ответ
setCoins(coinsData.coins);
} catch (error) {
console.error('Ошибка при получении количества монет:', error);
// оставляем старое значение (из state/localStorage)
} finally {
setIsLoading(false);
}
};
// Форматирование числа с разделителями тысяч
const formatNumber = (num: number): string => {
return num.toLocaleString('ru-RU');
useEffect(() => {
if (username && autoUpdate) {
fetchCoinsData();
const coinsInterval = setInterval(fetchCoinsData, updateInterval);
return () => clearInterval(coinsInterval);
}
}, [username, autoUpdate, updateInterval]);
const handleRefresh = () => {
if (username) fetchCoinsData();
};
const coinsDisplay = (
@ -145,7 +173,9 @@ export default function CoinsDisplay({
padding: sizes.containerPadding,
border: '1px solid rgba(255, 255, 255, 0.1)',
cursor: showTooltip ? 'help' : 'default',
opacity: isLoading ? 0.7 : 1,
// можно оставить лёгкий намёк на загрузку, но без "пульса" текста
opacity: isLoading ? 0.85 : 1,
transition: 'opacity 0.2s ease',
...sx,
@ -187,7 +217,7 @@ export default function CoinsDisplay({
fontFamily: 'Benzin-Bold, sans-serif',
}}
>
{isLoading ? '...' : formatNumber(coins)}
{formatNumber(coins)}
</Typography>
</Box>
);
@ -207,34 +237,3 @@ export default function CoinsDisplay({
return coinsDisplay;
}
// Примеры использования в комментариях для разработчика:
/*
// Пример 1: Простое отображение числа
<CoinsDisplay value={1500} />
// Пример 2: Получение данных по username с автообновлением
<CoinsDisplay
username="player123"
autoUpdate={true}
updateInterval={30000} // обновлять каждые 30 секунд
/>
// Пример 3: Кастомная стилизация без иконки
<CoinsDisplay
value={9999}
size="small"
showIcon={false}
showTooltip={false}
backgroundColor="rgba(255, 100, 100, 0.2)"
textColor="#ffcc00"
/>
// Пример 4: Большой отображение для профиля
<CoinsDisplay
username="player123"
size="large"
tooltipText="Ваш текущий баланс"
iconColor="#00ffaa"
/>
*/

View File

@ -1,9 +1,10 @@
import Box from '@mui/material/Box';
import Typography from '@mui/material/Typography';
import Fade from '@mui/material/Fade';
interface FullScreenLoaderProps {
message?: string;
fullScreen?: boolean; // <-- новый проп
fullScreen?: boolean;
}
export const FullScreenLoader = ({
@ -34,39 +35,76 @@ export const FullScreenLoader = ({
return (
<Box sx={containerSx}>
{/* Градиентное вращающееся кольцо */}
<Box
sx={{
width: 80,
height: 80,
borderRadius: '50%',
position: 'relative',
overflow: 'hidden',
background: 'conic-gradient(#F27121, #E940CD, #8A2387, #F27121)',
animation: 'spin 1s linear infinite',
WebkitMask: 'radial-gradient(circle, transparent 55%, black 56%)',
mask: 'radial-gradient(circle, transparent 55%, black 56%)',
'@keyframes spin': {
'0%': { transform: 'rotate(0deg)' },
'100%': { transform: 'rotate(360deg)' },
},
}}
/>
{/* Плавное появление фона */}
{fullScreen && (
<Fade in timeout={220} appear>
<Box
sx={{
position: 'absolute',
inset: 0,
background:
'radial-gradient(circle at 15% 20%, rgba(242,113,33,0.15), transparent 60%), radial-gradient(circle at 85% 10%, rgba(233,64,205,0.12), transparent 55%), rgba(5,5,10,0.75)',
backdropFilter: 'blur(14px)',
WebkitBackdropFilter: 'blur(14px)',
}}
/>
</Fade>
)}
{message && (
<Typography
variant="h6"
{/* Плавное появление контента */}
<Fade in timeout={260} appear>
<Box
sx={{
fontFamily: 'Benzin-Bold',
background:
'linear-gradient(71deg, #F27121 0%, #E940CD 70%, #8A2387 100%)',
WebkitBackgroundClip: 'text',
WebkitTextFillColor: 'transparent',
zIndex: 1,
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
gap: 3,
// небольшой "подъём" при появлении
animation: 'popIn 260ms ease-out both',
'@keyframes popIn': {
from: { opacity: 0, transform: 'translateY(8px) scale(0.98)' },
to: { opacity: 1, transform: 'translateY(0) scale(1)' },
},
}}
>
{message}
</Typography>
)}
<Box
sx={{
width: 80,
height: 80,
borderRadius: '50%',
position: 'relative',
overflow: 'hidden',
background: 'conic-gradient(#F27121, #E940CD, #8A2387, #F27121)',
animation: 'spin 1s linear infinite',
WebkitMask: 'radial-gradient(circle, transparent 55%, black 56%)',
mask: 'radial-gradient(circle, transparent 55%, black 56%)',
'@keyframes spin': {
'0%': { transform: 'rotate(0deg)' },
'100%': { transform: 'rotate(360deg)' },
},
boxShadow: '0 0 2.5vw rgba(233,64,205,0.45)',
}}
/>
{message && (
<Typography
variant="h6"
sx={{
fontFamily: 'Benzin-Bold',
background:
'linear-gradient(71deg, #F27121 0%, #E940CD 70%, #8A2387 100%)',
WebkitBackgroundClip: 'text',
WebkitTextFillColor: 'transparent',
textAlign: 'center',
textShadow: '0 0 1.2vw rgba(0,0,0,0.45)',
}}
>
{message}
</Typography>
)}
</Box>
</Fade>
</Box>
);
};

View File

@ -1,10 +1,10 @@
// src/renderer/components/HeadAvatar.tsx
import React, { useEffect, useRef } from 'react';
interface HeadAvatarProps {
skinUrl?: string;
size?: number;
style?: React.CSSProperties;
version?: number; // ✅ добавили
}
const DEFAULT_SKIN =
@ -14,12 +14,17 @@ export const HeadAvatar: React.FC<HeadAvatarProps> = ({
skinUrl,
size = 24,
style,
version = 0, // ✅ дефолт
...canvasProps
}) => {
const canvasRef = useRef<HTMLCanvasElement>(null);
useEffect(() => {
const finalSkinUrl = skinUrl?.trim() ? skinUrl : DEFAULT_SKIN;
const baseUrl = skinUrl?.trim() ? skinUrl : DEFAULT_SKIN;
// ✅ cache-bust: чтобы браузер НЕ отдавал старую картинку
const finalSkinUrl = `${baseUrl}${baseUrl.includes('?') ? '&' : '?'}v=${version}`;
const canvas = canvasRef.current;
if (!canvas) return;
@ -37,17 +42,14 @@ export const HeadAvatar: React.FC<HeadAvatarProps> = ({
ctx.clearRect(0, 0, size, size);
ctx.imageSmoothingEnabled = false;
// База головы: (8, 8, 8, 8)
ctx.drawImage(img, 8, 8, 8, 8, 0, 0, size, size);
// Слой шляпы: (40, 8, 8, 8)
ctx.drawImage(img, 40, 8, 8, 8, 0, 0, size, size);
};
img.onerror = (e) => {
console.error('Не удалось загрузить скин для HeadAvatar:', e);
};
}, [skinUrl, size]);
}, [skinUrl, size, version]); // ✅ version добавили
return (
<canvas
@ -58,7 +60,7 @@ export const HeadAvatar: React.FC<HeadAvatarProps> = ({
height: size,
borderRadius: 4,
imageRendering: 'pixelated',
...style, // 👈 даём переопределять снаружи
...style,
}}
/>
);

View File

@ -9,6 +9,7 @@ import {
Select,
FormControl,
InputLabel,
TextField,
} from '@mui/material';
import {
fetchActiveServers,
@ -20,6 +21,7 @@ import { FullScreenLoader } from './FullScreenLoader';
import { HeadAvatar } from './HeadAvatar';
import { translateServer } from '../utils/serverTranslator';
import GradientTextField from './GradientTextField'; // <-- используем ваш градиентный инпут
import { NONAME } from 'dns';
type OnlinePlayerFlat = {
username: string;
@ -142,6 +144,34 @@ export const OnlinePlayersPanel: React.FC<OnlinePlayersPanelProps> = ({
const totalOnline = onlinePlayers.length;
const controlSx = {
minWidth: '16vw',
'& .MuiInputLabel-root': {
color: 'rgba(255,255,255,0.75)',
fontFamily: 'Benzin-Bold',
},
'& .MuiInputLabel-root.Mui-focused': {
color: 'rgba(242,113,33,0.95)',
},
'& .MuiOutlinedInput-root': {
height: '3.2vw', // <-- ЕДИНАЯ высота
borderRadius: '999px',
backgroundColor: 'rgba(255,255,255,0.04)',
color: 'white',
fontFamily: 'Benzin-Bold',
'& .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',
},
},
};
return (
<Paper
elevation={0}
@ -203,16 +233,7 @@ export const OnlinePlayersPanel: React.FC<OnlinePlayersPanelProps> = ({
{/* Select в “нашем” стиле */}
<FormControl
size="small"
sx={{
minWidth: '12vw',
'& .MuiInputLabel-root': {
color: 'rgba(255,255,255,0.75)',
fontFamily: 'Benzin-Bold',
},
'& .MuiInputLabel-root.Mui-focused': {
color: 'rgba(242,113,33,0.95)',
},
}}
sx={controlSx}
>
<InputLabel>Сервер</InputLabel>
<Select
@ -266,7 +287,7 @@ export const OnlinePlayersPanel: React.FC<OnlinePlayersPanelProps> = ({
<MenuItem value="all">Все сервера</MenuItem>
{servers.map((s) => (
<MenuItem key={s.id} value={s.id}>
{s.name}
{translateServer(s.name)}
</MenuItem>
))}
</Select>
@ -274,25 +295,55 @@ export const OnlinePlayersPanel: React.FC<OnlinePlayersPanelProps> = ({
{/* Поиск через ваш GradientTextField */}
<Box sx={{ minWidth: '16vw' }}>
<GradientTextField
size="small"
<TextField
size="small"
label="Поиск по нику"
value={search}
onChange={(e) => setSearch(e.target.value)}
sx={{
...controlSx,
'& .MuiOutlinedInput-input': {
height: '100%',
padding: '0 1.2vw', // <-- ТОЧНО ТАКОЙ ЖЕ padding
display: 'flex',
alignItems: 'center',
fontSize: '0.9vw',
color: 'rgba(255,255,255,0.92)',
},
}}
/>
{/* <GradientTextField
label="Поиск по нику"
value={search}
onChange={(e) => setSearch(e.target.value)}
sx={{
mt: 0,
mb: 0,
'& .MuiOutlinedInput-root': { borderRadius: '999px' },
'& .MuiOutlinedInput-root::before': { borderRadius: '999px' },
'& .MuiInputBase-input': {
padding: '0.85vw 1.2vw',
fontSize: '0.9vw',
padding: 'none',
fontFamily: 'none',
},
'& .MuiInputLabel-root': {
// background: 'rgba(10,10,20,0.92)',
'& .css-16wblaj-MuiInputBase-input-MuiOutlinedInput-input': {
padding: '4px 0 5px',
},
'& .css-19qnlrw-MuiFormLabel-root-MuiInputLabel-root': {
top: '-15px',
},
'& .MuiOutlinedInput-root::before': {
content: '""',
position: 'absolute',
inset: 0,
padding: '0.2vw', // толщина рамки
borderRadius: '3.5vw',
background: GRADIENT,
WebkitMask:
'linear-gradient(#fff 0 0) content-box, linear-gradient(#fff 0 0)',
WebkitMaskComposite: 'xor',
maskComposite: 'exclude',
zIndex: 0,
},
}}
/>
/> */}
</Box>
</Box>
</Box>
@ -378,7 +429,7 @@ export const OnlinePlayersPanel: React.FC<OnlinePlayersPanelProps> = ({
<Box sx={{ display: 'flex', alignItems: 'center', gap: '0.6vw', flexShrink: 0 }}>
<Chip
label={translateServer({ name: p.serverName })}
label={translateServer(p.serverName)}
size="small"
sx={{
fontFamily: 'Benzin-Bold',

View File

@ -38,6 +38,13 @@ export default function PageHeader() {
return { title: '', subtitle: '', hidden: true };
}
if (path === '/settings') {
return {
title: 'Настройки',
subtitle: 'Персонализация интерфейса и поведения лаунчера',
}
}
if (path === '/news') {
return {
title: 'Новости',

View File

@ -9,6 +9,9 @@ interface SkinViewerProps {
autoRotate?: boolean;
}
const DEFAULT_SKIN =
'https://static.planetminecraft.com/files/resource_media/skin/original-steve-15053860.png';
export default function SkinViewer({
width = 300,
height = 400,
@ -19,57 +22,111 @@ export default function SkinViewer({
}: SkinViewerProps) {
const canvasRef = useRef<HTMLCanvasElement>(null);
const viewerRef = useRef<any>(null);
const animRef = useRef<any>(null);
// 1) Инициализируем viewer ОДИН РАЗ
useEffect(() => {
if (!canvasRef.current) return;
let disposed = false;
const init = async () => {
if (!canvasRef.current || viewerRef.current) return;
// Используем динамический импорт для обхода проблемы ESM/CommonJS
const initSkinViewer = async () => {
try {
const skinview3d = await import('skinview3d');
if (disposed) return;
// Создаем просмотрщик скина по документации
const viewer = new skinview3d.SkinViewer({
canvas: canvasRef.current,
width,
height,
skin:
skinUrl ||
'https://static.planetminecraft.com/files/resource_media/skin/original-steve-15053860.png',
model: 'auto-detect',
cape: capeUrl || undefined,
});
// Настраиваем вращение
// базовая настройка
viewer.autoRotate = autoRotate;
// Настраиваем анимацию ходьбы
viewer.animation = new skinview3d.WalkingAnimation();
viewer.animation.speed = walkingSpeed;
// анимация ходьбы
const walking = new skinview3d.WalkingAnimation();
walking.speed = walkingSpeed;
viewer.animation = walking;
// Сохраняем экземпляр для очистки
viewerRef.current = viewer;
} catch (error) {
console.error('Ошибка при инициализации skinview3d:', error);
animRef.current = walking;
// выставляем ресурсы сразу
const finalSkin = skinUrl?.trim() ? skinUrl : DEFAULT_SKIN;
await viewer.loadSkin(finalSkin);
if (capeUrl?.trim()) {
await viewer.loadCape(capeUrl);
} else {
viewer.cape = null;
}
} catch (e) {
console.error('Ошибка при инициализации skinview3d:', e);
}
};
initSkinViewer();
init();
// Очистка при размонтировании
return () => {
disposed = true;
if (viewerRef.current) {
viewerRef.current.dispose();
viewerRef.current = null;
animRef.current = null;
}
};
}, [width, height, skinUrl, capeUrl, walkingSpeed, autoRotate]);
// ⚠️ пустой deps — создаём один раз
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
return (
<canvas
ref={canvasRef}
width={width}
height={height}
style={{ display: 'block' }}
/>
);
// 2) Обновляем размеры (не пересоздаём viewer)
useEffect(() => {
const viewer = viewerRef.current;
if (!viewer) return;
viewer.width = width;
viewer.height = height;
}, [width, height]);
// 3) Обновляем автоповорот
useEffect(() => {
const viewer = viewerRef.current;
if (!viewer) return;
viewer.autoRotate = autoRotate;
}, [autoRotate]);
// 4) Обновляем скорость анимации
useEffect(() => {
const walking = animRef.current;
if (!walking) return;
walking.speed = walkingSpeed;
}, [walkingSpeed]);
// 5) Обновляем скин
useEffect(() => {
const viewer = viewerRef.current;
if (!viewer) return;
const finalSkin = skinUrl?.trim() ? skinUrl : DEFAULT_SKIN;
// защита от кеша: добавим “bust” только если URL уже имеет query — не обязательно, но помогает
const url = finalSkin.includes('?') ? `${finalSkin}&t=${Date.now()}` : `${finalSkin}?t=${Date.now()}`;
viewer.loadSkin(url).catch((e: any) => console.error('loadSkin error:', e));
}, [skinUrl]);
// 6) Обновляем плащ
useEffect(() => {
const viewer = viewerRef.current;
if (!viewer) return;
if (capeUrl?.trim()) {
const url = capeUrl.includes('?') ? `${capeUrl}&t=${Date.now()}` : `${capeUrl}?t=${Date.now()}`;
viewer.loadCape(url).catch((e: any) => console.error('loadCape error:', e));
} else {
viewer.cape = null;
}
}, [capeUrl]);
return <canvas ref={canvasRef} width={width} height={height} style={{ display: 'block' }} />;
}

View File

@ -19,6 +19,7 @@ import { fetchPlayer } from './../api';
import CalendarMonthIcon from '@mui/icons-material/CalendarMonth';
import EmojiEventsIcon from '@mui/icons-material/EmojiEvents';
import PersonIcon from '@mui/icons-material/Person';
import SettingsIcon from '@mui/icons-material/Settings';
declare global {
interface Window {
electron: {
@ -64,10 +65,14 @@ export default function TopBar({ onRegister, username }: TopBarProps) {
}, []);
const [skinUrl, setSkinUrl] = useState<string>('');
const [skinVersion, setSkinVersion] = useState(0);
const [avatarAnchorEl, setAvatarAnchorEl] = useState<null | HTMLElement>(
null,
);
const path = location.pathname || '';
const isAuthPage = path.startsWith('/login') || path.startsWith('/registration');
const TAB_ROUTES: Array<{
value: number;
match: (p: string) => boolean;
@ -219,18 +224,47 @@ export default function TopBar({ onRegister, username }: TopBarProps) {
navigate('/login');
};
useEffect(() => {
const savedConfig = localStorage.getItem('launcher_config');
const loadSkin = useCallback(async () => {
if (!isAuthed) {
setSkinUrl('');
return;
}
const savedConfig = localStorage.getItem('launcher_config');
if (!savedConfig) return;
const config = JSON.parse(savedConfig);
const uuid = config.uuid;
let cfg: any = null;
try {
cfg = JSON.parse(savedConfig);
} catch {
return;
}
const uuid = cfg?.uuid;
if (!uuid) return;
fetchPlayer(uuid)
.then((player) => setSkinUrl(player.skin_url))
.catch((e) => console.error('Не удалось получить скин:', e));
}, []);
try {
const player = await fetchPlayer(uuid);
setSkinUrl(player.skin_url || '');
} catch (e) {
console.error('Не удалось получить скин:', e);
setSkinUrl('');
}
}, [isAuthed]);
useEffect(() => {
loadSkin();
}, [loadSkin, location.pathname]);
useEffect(() => {
const handler = () => {
setSkinVersion((v) => v + 1);
loadSkin();
};
window.addEventListener('skin-updated', handler as EventListener);
return () => window.removeEventListener('skin-updated', handler as EventListener);
}, [loadSkin]);
return (
<Box
@ -251,10 +285,10 @@ export default function TopBar({ onRegister, username }: TopBarProps) {
// убрать если не оч
// стиль как в Registration
background:
'linear-gradient(71deg, rgba(242,113,33,0.18) 0%, rgba(233,64,205,0.14) 70%, rgba(138,35,135,0.16) 100%)',
backdropFilter: 'blur(10px)',
boxShadow: '0 8px 30px rgba(0,0,0,0.35)',
background: isAuthPage ?
'none' : 'linear-gradient(71deg, rgba(242,113,33,0.18) 0%, rgba(233,64,205,0.14) 70%, rgba(138,35,135,0.16) 100%)',
backdropFilter: isAuthPage ? 'none' : 'blur(10px)',
boxShadow: isAuthPage ? 'none' : '0 8px 30px rgba(0,0,0,0.35)',
}}
>
{/* Левая часть */}
@ -433,6 +467,7 @@ export default function TopBar({ onRegister, username }: TopBarProps) {
<HeadAvatar
skinUrl={skinUrl}
size={44}
version={skinVersion}
style={{
borderRadius: '3vw',
cursor: 'pointer',
@ -530,6 +565,7 @@ export default function TopBar({ onRegister, username }: TopBarProps) {
<HeadAvatar
skinUrl={skinUrl}
size={40}
version={skinVersion}
style={{ borderRadius: '3vw' }}
/>
@ -547,7 +583,7 @@ export default function TopBar({ onRegister, username }: TopBarProps) {
username={username}
size="medium"
autoUpdate={true}
showTooltip={true}
showTooltip={false}
sx={{
border: 'none',
padding: '0vw',
@ -615,12 +651,31 @@ export default function TopBar({ onRegister, username }: TopBarProps) {
<EmojiEventsIcon sx={{ fontSize: '2vw' }} /> Ежедневная награда
</MenuItem>
<MenuItem
onClick={() => {
handleAvatarMenuClose();
navigate('/settings');
}}
sx={{
fontFamily: 'Benzin-Bold',
fontSize: '1.5vw',
gap: '0.5vw',
py: '0.7vw',
'&:hover': {
bgcolor: 'rgba(255,77,77,0.15)',
},
}}
>
<SettingsIcon sx={{ fontSize: '2vw' }} /> Настройки
</MenuItem>
<Divider sx={{ my: '0.4vw', borderColor: 'rgba(255,255,255,0.08)' }} />
{!isLoginPage && !isRegistrationPage && username && (
<Button
variant="outlined"
color="primary"
onClick={() => {
handleAvatarMenuClose();
logout();
}}
sx={{