Merge branch 'feat/VersionsExplorer' of https://git.popa-popa.ru/DIKER/popa-launcher into feat/VersionsExplorer

This commit is contained in:
2025-12-14 22:25:03 +05:00
21 changed files with 1777 additions and 553 deletions

View File

@ -157,3 +157,41 @@ MIT © [Electron React Boilerplate](https://github.com/electron-react-boilerplat
[github-tag-url]: https://github.com/electron-react-boilerplate/electron-react-boilerplate/releases/latest
[stackoverflow-img]: https://img.shields.io/badge/stackoverflow-electron_react_boilerplate-blue.svg
[stackoverflow-url]: https://stackoverflow.com/questions/tagged/electron-react-boilerplate
Для использования CustomNotification:
# IMPORTS
import CustomNotification from '../components/Notifications/CustomNotification';
import type { NotificationPosition } from '../components/Notifications/CustomNotification';
import { getNotificationPosition } from '../utils/settings';
# STATE
const [notifOpen, setNotifOpen] = useState(false);
const [notifMsg, setNotifMsg] = useState<React.ReactNode>('');
const [notifSeverity, setNotifSeverity] = useState<
'success' | 'info' | 'warning' | 'error'
>('info');
const [notifPos, setNotifPos] = useState<NotificationPosition>({
vertical: 'bottom',
horizontal: 'center',
});
# ВМЕСТО setNotification
setNotifMsg('Ошибка при загрузке прокачки!'); // string
setNotifSeverity('error'); // 'success' || 'info' || 'warning' || 'error'
setNotifPos(getNotificationPosition()); // top || bottom & center || right || left
setNotifOpen(true); // Не изменять
# СРАЗУ ПОСЛЕ ПЕРВОГО <Box>
<CustomNotification
open={notifOpen}
message={notifMsg}
severity={notifSeverity}
position={notifPos}
onClose={() => setNotifOpen(false)}
autoHideDuration={2500}
/>

View File

@ -12,6 +12,19 @@
url('../../assets/fonts/benzin-bold.svg#benzin-bold') format('svg'); /* Chrome < 4, Legacy iOS */
}
:root {
--ui-scale: 1;
}
#root {
transform: scale(var(--ui-scale));
transform-origin: top left;
/* компенсация, чтобы после scale не появлялись пустые области/скроллы */
width: calc(100% / var(--ui-scale));
height: calc(100% / var(--ui-scale));
}
body {
position: relative;
color: white;

View File

@ -24,6 +24,7 @@ import PageHeader from './components/PageHeader';
import { useLocation } from 'react-router-dom';
import DailyReward from './pages/DailyReward';
import DailyQuests from './pages/DailyQuests';
import Settings from './pages/Settings';
const AuthCheck = ({ children }: { children: ReactNode }) => {
const [isAuthenticated, setIsAuthenticated] = useState<boolean | null>(null);
@ -134,7 +135,7 @@ const AppLayout = () => {
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: location.pathname === '/profile' || location.pathname.startsWith('/launch') || location.pathname === '/login' || '/registration'
justifyContent: location.pathname === '/profile' || location.pathname.startsWith('/launch') || location.pathname === '/login' || location.pathname === '/registration'
? 'center'
: 'flex-start',
overflowX: 'hidden',
@ -191,6 +192,14 @@ const AppLayout = () => {
</AuthCheck>
}
/>
<Route
path="/settings"
element={
<AuthCheck>
<Settings />
</AuthCheck>
}
/>
<Route
path="/shop"
element={

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={{

View File

@ -22,6 +22,8 @@ import {
DailyStatusResponse,
fetchDailyClaimDays,
} from '../api';
import CustomNotification from '../components/Notifications/CustomNotification';
import type { NotificationPosition } from '../components/Notifications/CustomNotification';
const pulseGradient = {
'@keyframes pulseGlow': {
@ -143,6 +145,17 @@ export default function DailyReward({ onClaimed }: Props) {
const wasOnlineToday = status?.was_online_today ?? false;
const canClaim = (status?.can_claim ?? false) && wasOnlineToday;
const [notifOpen, setNotifOpen] = useState(false);
const [notifMsg, setNotifMsg] = useState<React.ReactNode>('');
const [notifSeverity, setNotifSeverity] = useState<
'success' | 'info' | 'warning' | 'error'
>('info');
const [notifPos, setNotifPos] = useState<NotificationPosition>({
vertical: 'bottom',
horizontal: 'center',
});
const goPrev = () =>
setView((v) => new Date(v.getFullYear(), v.getMonth() - 1, 1));
const goNext = () =>
@ -278,14 +291,106 @@ export default function DailyReward({ onClaimed }: Props) {
{/* alerts */}
<Box sx={{ px: '2vw', pt: '1.2vh' }}>
{error && (
<Alert severity="error" sx={{ mb: 1.5 }}>
{error}
</Alert>
<Alert
severity="error"
icon={false}
sx={{
mb: 2,
borderRadius: '1.1vw',
px: '1.4vw',
py: '1.1vw',
color: 'rgba(255,255,255,0.90)',
fontWeight: 800,
bgcolor: 'rgba(255,255,255,0.04)',
border: '1px solid rgba(255,255,255,0.10)',
position: 'relative',
overflow: 'hidden',
backdropFilter: 'blur(10px)',
boxShadow: '0 1.0vw 2.8vw rgba(0,0,0,0.45)',
'& .MuiAlert-message': {
padding: 0,
width: '100%',
},
'&:before': {
content: '""',
position: 'absolute',
inset: 0,
background:
'radial-gradient(circle at 12% 30%, rgba(242,113,33,0.22), transparent 60%), radial-gradient(circle at 85% 0%, rgba(233,64,205,0.14), transparent 55%)',
pointerEvents: 'none',
},
'&:after': {
content: '""',
position: 'absolute',
left: 0,
top: 0,
bottom: 0,
width: '0.35vw',
background: 'linear-gradient(180deg, #F27121 0%, #E940CD 65%, #8A2387 100%)',
opacity: 0.95,
pointerEvents: 'none',
},
}}
>
<Typography sx={{ position: 'relative', zIndex: 1, color: 'rgba(255,255,255,0.90)', fontWeight: 800 }}>
{error}
</Typography>
</Alert>
)}
{success && (
<Alert severity="success" sx={{ mb: 1.5 }}>
{success}
</Alert>
// <Alert
// severity="success"
// icon={false}
// sx={{
// mb: 2,
// borderRadius: '1.1vw',
// px: '1.4vw',
// py: '1.1vw',
// color: 'rgba(255,255,255,0.90)',
// fontWeight: 800,
// bgcolor: 'rgba(255,255,255,0.04)',
// border: '1px solid rgba(255,255,255,0.10)',
// position: 'relative',
// overflow: 'hidden',
// backdropFilter: 'blur(10px)',
// boxShadow: '0 1.0vw 2.8vw rgba(0,0,0,0.45)',
// '& .MuiAlert-message': {
// padding: 0,
// width: '100%',
// },
// '&:before': {
// content: '""',
// position: 'absolute',
// inset: 0,
// background:
// 'radial-gradient(circle at 12% 30%, rgba(242,113,33,0.22), transparent 60%), radial-gradient(circle at 85% 0%, rgba(233,64,205,0.14), transparent 55%)',
// pointerEvents: 'none',
// },
// '&:after': {
// content: '""',
// position: 'absolute',
// left: 0,
// top: 0,
// bottom: 0,
// width: '0.35vw',
// background: 'linear-gradient(180deg, #F27121 0%, #E940CD 65%, #8A2387 100%)',
// opacity: 0.95,
// pointerEvents: 'none',
// },
// }}
// >
// <Typography sx={{ position: 'relative', zIndex: 1, color: 'rgba(255,255,255,0.90)', fontWeight: 800 }}>
// {success}
// </Typography>
// </Alert>
<CustomNotification
open={notifOpen}
message={notifMsg}
severity={notifSeverity}
position={notifPos}
onClose={() => setNotifOpen(false)}
autoHideDuration={99999}
/>
)}
</Box>

View File

@ -13,6 +13,7 @@ import React from 'react';
import SettingsModal from '../components/Settings/SettingsModal';
import CustomNotification from '../components/Notifications/CustomNotification';
import type { NotificationPosition } from '../components/Notifications/CustomNotification';
import { isNotificationsEnabled, getNotifPositionFromSettings } from '../utils/notifications';
declare global {
interface Window {
@ -230,8 +231,9 @@ const LaunchPage = ({
const showNotification = (
message: React.ReactNode,
severity: 'success' | 'info' | 'warning' | 'error' = 'info',
position: NotificationPosition = { vertical: 'bottom', horizontal: 'center' },
position: NotificationPosition = getNotifPositionFromSettings(),
) => {
if (!isNotificationsEnabled()) return;
setNotifMsg(message);
setNotifSeverity(severity);
setNotifPos(position);

View File

@ -21,6 +21,7 @@ import type { NotificationPosition } from '../components/Notifications/CustomNot
import * as React from 'react';
import { playBuySound } from '../utils/sounds';
import { translateServer } from '../utils/serverTranslator'
import { isNotificationsEnabled, getNotifPositionFromSettings } from '../utils/notifications';
interface TabPanelProps {
children?: React.ReactNode;
@ -155,8 +156,9 @@ export default function Marketplace() {
const showNotification = (
message: React.ReactNode,
severity: 'success' | 'info' | 'warning' | 'error' = 'info',
position: NotificationPosition = { vertical: 'bottom', horizontal: 'center' },
position: NotificationPosition = getNotifPositionFromSettings(),
) => {
if (!isNotificationsEnabled()) return;
setNotifMsg(message);
setNotifSeverity(severity);
setNotifPos(position);
@ -273,7 +275,7 @@ export default function Marketplace() {
fontSize: '2vw',
}}
>
{translateServer(playerServer || { name: '' })}
{translateServer(playerServer?.name ?? '')}
</Typography>
</Box>

View File

@ -28,11 +28,11 @@ 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 [walkingSpeed, setWalkingSpeed] = useState<number>(0.5);
const [skin, setSkin] = useState<string>('');
const [cape, setCape] = useState<string>('');
const [username, setUsername] = useState<string>('');
@ -62,6 +62,9 @@ export default function Profile() {
horizontal: 'right',
});
const [autoRotate, setAutoRotate] = useState(true);
const [walkingSpeed, setWalkingSpeed] = useState(0.5);
useEffect(() => {
const savedConfig = localStorage.getItem('launcher_config');
if (savedConfig) {
@ -174,12 +177,13 @@ export default function Profile() {
setStatusMessage(msg);
setUploadStatus('error');
setLoading(false);
// notification
if (!isNotificationsEnabled()) return;
setNotifMsg(msg);
setNotifSeverity('error');
setNotifOpen(true);
setLoading(false);
return;
}
@ -187,22 +191,25 @@ export default function Profile() {
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({ vertical: 'bottom', horizontal: 'left' });
setNotifPos(getNotifPositionFromSettings());
setNotifOpen(true);
// Обновляем информацию о игроке, чтобы увидеть новый скин
const config = JSON.parse(
localStorage.getItem('launcher_config') || '{}',
);
if (config.uuid) {
loadPlayerData(config.uuid);
}
} catch (error) {
const msg = `Ошибка: ${
error instanceof Error ? error.message : 'Не удалось загрузить скин'
@ -212,27 +219,61 @@ export default function Profile() {
setUploadStatus('error');
// notification
if (!isNotificationsEnabled()) return;
setNotifMsg(msg);
setNotifSeverity('error');
setNotifPos({ vertical: 'bottom', horizontal: 'left' });
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',
display: 'grid',
gridTemplateColumns: 'minmax(0, 1fr) minmax(0, 1fr)',
gap: '3vw',
width: '100%',
height: '100%',
justifyContent: 'center',
alignItems: 'start',
overflowY: 'auto',
boxSizing: 'border-box',
px: '2vw',
}}
>
<CustomNotification
@ -243,84 +284,134 @@ export default function Profile() {
onClose={() => setNotifOpen(false)}
autoHideDuration={2500}
/>
{loading ? (
<FullScreenLoader message="Загрузка вашего профиля" />
) : (
<>
<Paper
elevation={0}
<Box
sx={{
display: 'grid',
gridTemplateColumns: 'minmax(0, 1fr) minmax(0, 1fr)',
gap: '3vw',
alignItems: 'start',
}}
>
{/* LEFT COLUMN */}
<Box
sx={{
p: 0,
borderRadius: 2,
overflow: 'hidden',
bgcolor: 'transparent',
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
gap: '1vw',
minWidth: 0,
}}
>
{/* Используем переработанный компонент SkinViewer */}
{/* Плашка с ником */}
<Typography
sx={{
fontFamily: 'Benzin-Bold',
alignSelf: 'center',
justifySelf: 'center',
textAlign: 'center',
mb: '2vw',
fontSize: '3vw',
color: 'white',
position: 'relative',
px: '5vw',
py: '0.9vw',
borderRadius: '3vw',
p: '0.5vw 5vw',
bgcolor: 'rgb(255, 77, 77)',
boxShadow: '0 0 1vw 0 rgba(0, 0, 0, 0.5)',
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
width={viewerWidth}
height={viewerHeight}
skinUrl={skin}
capeUrl={cape}
walkingSpeed={walkingSpeed}
autoRotate={true}
/>
</Paper>
<Box
sx={{
display: 'flex',
flexDirection: 'column',
gap: '1vw',
width: '100%',
maxWidth: '44vw',
justifySelf: 'start'
}}
>
{/* 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%',
bgcolor: 'rgba(255, 255, 255, 0.05)',
padding: '3vw',
borderRadius: '1vw',
flexShrink: 0,
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={{
border: '2px dashed',
borderColor: isDragOver ? 'primary.main' : 'grey.400',
borderRadius: '1vw',
p: '1.5vw',
mb: '1vw',
borderRadius: '1.1vw',
p: '1.6vw',
mb: '1.1vw',
textAlign: 'center',
cursor: 'pointer',
bgcolor: isDragOver
? 'rgba(25, 118, 210, 0.08)'
: 'transparent',
transition: 'all 0.2s',
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();
@ -337,93 +428,91 @@ export default function Profile() {
style={{ display: 'none' }}
onChange={handleFileSelect}
/>
<Typography sx={{ color: 'white' }}>
<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
color="primary"
fullWidth
size="small"
sx={{
mb: '1vw',
color: 'white',
'&:hover .MuiInputLabel-root': {
color: 'rgb(255, 77, 77)',
transition: 'all 0.3s ease-in-out',
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
sx={{
fontFamily: 'Benzin-Bold',
color: 'white',
'&.Mui-focused': {
color: 'rgb(255, 77, 77)',
transition: 'all 0.3s ease-in-out',
},
transform: 'translate(14px, -9px) scale(0.75)',
'&.MuiInputLabel-shrink': {
transform: 'translate(14px, -9px) scale(0.75)',
transition: 'all 0.3s ease-in-out',
},
}}
>
Модель скина
</InputLabel>
<InputLabel>Модель скина</InputLabel>
<Select
value={skinModel}
label="Модель скина"
onChange={(e) => setSkinModel(e.target.value)}
displayEmpty
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={{
border: 'none',
borderRadius: '999px',
bgcolor: 'rgba(255,255,255,0.04)',
color: 'rgba(255,255,255,0.92)',
fontFamily: 'Benzin-Bold',
'& .MuiSelect-select': {
fontFamily: 'Benzin-Bold',
color: 'white',
paddingTop: '1vw',
paddingBottom: '1vw',
transition: 'all 0.3s ease-in-out',
},
'&:hover': {
'& .MuiSelect-select': {
color: 'rgb(255, 77, 77)',
transition: 'all 0.3s ease-in-out',
},
},
'&.Mui-focused': {
'& .MuiSelect-select': {
color: 'rgb(255, 77, 77)',
transition: 'all 0.3s ease-in-out',
},
py: '0.9vw',
px: '1.2vw',
},
'& .MuiOutlinedInput-notchedOutline': {
borderRadius: '5vw',
borderColor: 'rgb(255, 255, 255)',
transition: 'all 0.3s ease-in-out',
borderColor: 'rgba(255,255,255,0.14)',
},
'&:hover .MuiOutlinedInput-notchedOutline': {
borderColor: 'rgb(255, 77, 77)',
transition: 'all 0.3s ease-in-out',
borderColor: 'rgba(242,113,33,0.55)',
},
'&.Mui-focused .MuiOutlinedInput-notchedOutline': {
borderColor: 'rgb(255, 77, 77)',
borderColor: 'rgba(233,64,205,0.65)',
borderWidth: '2px',
transition: 'all 0.3s ease-in-out',
},
'& .MuiSelect-icon': {
color: 'white',
transition: 'all 0.3s ease-in-out',
},
'&:hover .MuiSelect-icon': {
color: 'rgb(255, 77, 77)',
transition: 'all 0.3s ease-in-out',
},
'&.Mui-focused .MuiSelect-icon': {
color: 'rgb(255, 77, 77)',
transition: 'all 0.3s ease-in-out',
color: 'rgba(255,255,255,0.75)',
},
}}
>
@ -432,51 +521,81 @@ export default function Profile() {
<MenuItem value="classic">Классическая (Steve)</MenuItem>
</Select>
</FormControl>
{/* button */}
<Button
sx={{
color: 'white',
borderRadius: '20px',
p: '10px 25px',
backgroundColor: 'rgb(0, 134, 0)',
'&:hover': {
backgroundColor: 'rgba(0, 134, 0, 0.5)',
},
fontFamily: 'Benzin-Bold',
}}
variant="contained"
fullWidth
onClick={handleUploadSkin}
disabled={uploadStatus === 'loading' || !skinFile}
startIcon={
uploadStatus === 'loading' ? (
<FullScreenLoader fullScreen={false} />
) : null
}
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' ? (
<FullScreenLoader message="Загрузка..." />
) : (
<Typography sx={{ color: 'white' }}>
Установить скин
</Typography>
)}
{uploadStatus === 'loading' ? 'Загрузка...' : 'Установить скин'}
</Button>
</Box>
<Box
</Box>
{/* RIGHT COLUMN */}
<Box
sx={{
display: 'flex',
flexDirection: 'column',
gap: '1vw',
minWidth: 0,
maxWidth: '44vw',
justifySelf: 'start',
}}
>
{/* Плащи */}
<Paper
elevation={0}
sx={{
display: 'flex',
justifyContent: 'center',
flexDirection: 'column',
gap: '2vw',
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>Ваши плащи</Typography>
<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',
flexDirection: 'row',
gap: '2vw',
flexWrap: 'wrap',
gap: '1.2vw',
}}
>
{capes.map((cape) => (
@ -484,17 +603,17 @@ export default function Profile() {
key={cape.cape_id}
cape={cape}
mode="profile"
onAction={
cape.is_active ? handleDeactivateCape : handleActivateCape
}
onAction={cape.is_active ? handleDeactivateCape : handleActivateCape}
actionDisabled={loading}
/>
))}
</Box>
</Box>
</Paper>
{/* Онлайн */}
<OnlinePlayersPanel currentUsername={username} />
</Box>
</>
</Box>
)}
</Box>
);

View File

@ -0,0 +1,573 @@
import { useEffect, useMemo, useState } from 'react';
import {
Box,
Typography,
Paper,
Switch,
FormControlLabel,
Slider,
Select,
MenuItem,
FormControl,
InputLabel,
Button,
Divider,
Chip,
} from '@mui/material';
import CustomNotification from '../components/Notifications/CustomNotification';
import type { NotificationPosition } from '../components/Notifications/CustomNotification';
import { isNotificationsEnabled, getNotifPositionFromSettings } from '../utils/notifications';
type SettingsState = {
// UI
uiScale: number; // 80..120
reduceMotion: boolean;
blurEffects: boolean;
// Launcher / app
autoUpdate: boolean;
startInTray: boolean;
// Game
autoRotateSkinViewer: boolean;
walkingSpeed: number; // 0..1
// Notifications
notifications: boolean;
notificationPosition: 'top-right' | 'top-center' | 'top-left' | 'bottom-right' | 'bottom-center' | 'bottom-left';
};
const STORAGE_KEY = 'launcher_settings';
const GRADIENT =
'linear-gradient(71deg, #F27121 0%, #E940CD 70%, #8A2387 100%)';
const defaultSettings: SettingsState = {
uiScale: 100,
reduceMotion: false,
blurEffects: true,
autoUpdate: true,
startInTray: false,
autoRotateSkinViewer: true,
walkingSpeed: 0.5,
notifications: true,
notificationPosition: 'top-right',
};
function safeParseSettings(raw: string | null): SettingsState | null {
if (!raw) return null;
try {
const obj = JSON.parse(raw);
return {
...defaultSettings,
...obj,
} as SettingsState;
} catch {
return null;
}
}
// 🔽 ВСТАВИТЬ СЮДА (выше Settings)
const NotificationPositionPicker = ({
value,
disabled,
onChange,
}: {
value: SettingsState['notificationPosition'];
disabled?: boolean;
onChange: (v: SettingsState['notificationPosition']) => void;
}) => {
const POSITIONS = [
{ key: 'top-left', label: 'Сверху слева', align: 'flex-start', justify: 'flex-start' },
{ key: 'top-center', label: 'Сверху по-центру', align: 'flex-start', justify: 'center' },
{ key: 'top-right', label: 'Сверху справа', align: 'flex-start', justify: 'flex-end' },
{ key: 'bottom-left', label: 'Снизу слева', align: 'flex-end', justify: 'flex-start' },
{ key: 'bottom-center', label: 'Снизу по-центру', align: 'flex-end', justify: 'center' },
{ key: 'bottom-right', label: 'Снизу справа', align: 'flex-end', justify: 'flex-end' },
] as const;
return (
<Box sx={{ opacity: disabled ? 0.45 : 1, pointerEvents: disabled ? 'none' : 'auto' }}>
<Typography sx={{ fontFamily: 'Benzin-Bold', mb: '0.8vw', color: 'rgba(255,255,255,0.75)' }}>
Позиция уведомлений
</Typography>
<Box
sx={{
borderRadius: '1.2vw',
p: '0.9vw',
border: '1px solid rgba(255,255,255,0.10)',
background: 'rgba(0,0,0,0.22)',
}}
>
<Box
sx={{
display: 'grid',
gridTemplateColumns: 'repeat(3, 1fr)',
gridTemplateRows: 'repeat(2, 8vw)',
}}
>
{POSITIONS.map((p) => {
const selected = value === p.key;
return (
<Box
key={p.key}
onClick={() => onChange(p.key)}
sx={{
cursor: 'pointer',
//borderRadius: '0.9vw',
border: selected
? '1px solid rgba(233,64,205,0.55)'
: '1px solid rgba(255,255,255,0.10)',
background: selected
? 'linear-gradient(120deg, rgba(242,113,33,0.12), rgba(233,64,205,0.10))'
: 'rgba(255,255,255,0.04)',
display: 'flex',
alignItems: p.align,
justifyContent: p.justify,
p: '0.6vw',
transition: 'all 0.18s ease',
}}
>
{/* мини-уведомление */}
<Box
sx={{
width: '75%',
borderRadius: '0.8vw',
px: '0.7vw',
py: '0.5vw',
background: 'rgba(10,10,20,0.9)',
border: '1px solid rgba(255,255,255,0.12)',
boxShadow: '0 0.8vw 2vw rgba(0,0,0,0.45)',
}}
>
<Box sx={{ height: '0.45vw', width: '60%', background: '#fff', borderRadius: 99 }} />
<Box sx={{ mt: '0.3vw', height: '0.4vw', width: '85%', background: '#aaa', borderRadius: 99 }} />
</Box>
</Box>
);
})}
</Box>
</Box>
</Box>
);
};
const mapNotifPosition = (
p: SettingsState['notificationPosition'],
): NotificationPosition => {
const [vertical, horizontal] = p.split('-') as ['top' | 'bottom', 'left' | 'center' | 'right'];
return { vertical, horizontal };
};
const Settings = () => {
const [lastSavedSettings, setLastSavedSettings] = useState<SettingsState>(() => {
if (typeof window === 'undefined') return defaultSettings;
return safeParseSettings(localStorage.getItem(STORAGE_KEY)) ?? defaultSettings;
});
const [notifOpen, setNotifOpen] = useState(false);
const [notifMsg, setNotifMsg] = useState<React.ReactNode>('');
const [notifSeverity, setNotifSeverity] = useState<
'success' | 'info' | 'warning' | 'error'
>('info');
const [notifPos, setNotifPos] = useState<NotificationPosition>({
vertical: 'bottom',
horizontal: 'center',
});
const [settings, setSettings] = useState<SettingsState>(() => {
if (typeof window === 'undefined') return defaultSettings;
return safeParseSettings(localStorage.getItem(STORAGE_KEY)) ?? defaultSettings;
});
const dirty = useMemo(() => {
return JSON.stringify(settings) !== JSON.stringify(lastSavedSettings);
}, [settings, lastSavedSettings]);
const save = () => {
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify(settings));
setLastSavedSettings(settings);
window.dispatchEvent(new CustomEvent('settings-updated'));
// если уведомления выключены — НЕ показываем нотификацию
if (!isNotificationsEnabled()) return;
setNotifMsg('Настройки успешно сохранены!');
setNotifSeverity('info');
setNotifPos(mapNotifPosition(settings.notificationPosition));
setNotifOpen(true);
} catch (e) {
console.error('Не удалось сохранить настройки', e);
}
};
const reset = () => {
setSettings(defaultSettings);
setLastSavedSettings(defaultSettings);
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify(defaultSettings));
} catch (e) {
console.error('Не удалось сбросить настройки', e);
}
};
const checkNotif = () => {
if (!settings.notifications) return; // если выключены — не показываем
setNotifMsg('Проверка уведомления!');
setNotifSeverity('info');
setNotifPos(mapNotifPosition(settings.notificationPosition)); // 👈 важно
setNotifOpen(true);
};
useEffect(() => {
if (typeof document === 'undefined') return;
const scale = settings.uiScale / 100;
document.documentElement.style.setProperty('--ui-scale', String(scale));
document.body.classList.toggle('reduce-motion', settings.reduceMotion);
document.body.classList.toggle('no-blur', !settings.blurEffects);
}, [settings.uiScale, settings.reduceMotion, settings.blurEffects]);
const SectionTitle = ({ children }: { children: string }) => (
<Typography
sx={{
fontFamily: 'Benzin-Bold',
fontSize: '1.25vw',
lineHeight: 1.1,
backgroundImage: GRADIENT,
WebkitBackgroundClip: 'text',
WebkitTextFillColor: 'transparent',
mb: '0.9vw',
}}
>
{children}
</Typography>
);
const Glass = ({ children }: { children: React.ReactNode }) => (
<Paper
elevation={0}
sx={{
borderRadius: '1.2vw',
overflow: 'hidden',
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)',
color: 'white',
}}
>
<Box sx={{ p: '1.8vw' }}>{children}</Box>
</Paper>
);
const controlSx = {
'& .MuiFormControlLabel-label': {
fontFamily: 'Benzin-Bold',
color: 'rgba(255,255,255,0.88)',
},
'& .MuiSwitch-switchBase.Mui-checked': {
color: 'rgba(242,113,33,0.95)',
},
'& .MuiSwitch-switchBase.Mui-checked + .MuiSwitch-track': {
backgroundColor: 'rgba(233,64,205,0.55)',
},
'& .MuiSwitch-track': {
backgroundColor: 'rgba(255,255,255,0.20)',
},
} as const;
return (
<Box
sx={{
px: '2vw',
pb: '2vw',
width: '100%',
boxSizing: 'border-box',
}}
>
<CustomNotification
open={notifOpen}
message={notifMsg}
severity={notifSeverity}
position={notifPos}
onClose={() => setNotifOpen(false)}
autoHideDuration={2500}
/>
{/* header */}
<Box
sx={{
mb: '1.2vw',
display: 'flex',
alignItems: 'flex-end',
justifyContent: 'space-between',
gap: '1vw',
flexWrap: 'wrap',
}}
>
<Box sx={{ display: 'flex', gap: '0.8vw', alignItems: 'center' }}>
{dirty && (
<Chip
label="Есть несохранённые изменения"
size="small"
sx={{
height: '1.6rem',
borderRadius: '999px',
color: 'white',
fontWeight: 900,
background:
'linear-gradient(120deg, rgba(242,113,33,0.24), rgba(233,64,205,0.16), rgba(138,35,135,0.20))',
border: '1px solid rgba(255,255,255,0.10)',
backdropFilter: 'blur(12px)',
}}
/>
)}
<Button
onClick={reset}
disableRipple
sx={{
borderRadius: '999px',
px: '1.2vw',
py: '0.6vw',
fontFamily: 'Benzin-Bold',
color: 'rgba(255,255,255,0.92)',
background: 'rgba(255,255,255,0.08)',
border: '1px solid rgba(255,255,255,0.10)',
'&:hover': { background: 'rgba(255,255,255,0.12)' },
}}
>
Сбросить
</Button>
<Button
onClick={save}
disableRipple
disabled={!dirty}
sx={{
borderRadius: '999px',
px: '1.2vw',
py: '0.6vw',
fontFamily: 'Benzin-Bold',
color: '#fff',
background: GRADIENT,
opacity: dirty ? 1 : 0.5,
'&:hover': { filter: 'brightness(1.05)' },
}}
>
Сохранить
</Button>
</Box>
</Box>
<Box
sx={{
display: 'grid',
gridTemplateColumns: 'minmax(0, 1fr) minmax(0, 1fr)',
gap: '2vw',
alignItems: 'start',
}}
>
{/* LEFT */}
<Box sx={{ display: 'flex', flexDirection: 'column', gap: '2vw', minWidth: 0, width: '43vw' }}>
<Glass>
<SectionTitle>Интерфейс</SectionTitle>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: '1.1vw' }}>
<Box>
<Typography sx={{ fontFamily: 'Benzin-Bold', color: 'rgba(255,255,255,0.88)' }}>
Масштаб интерфейса: {settings.uiScale}%
</Typography>
<Slider
value={settings.uiScale}
min={80}
max={120}
step={5}
onChange={(_, v) => setSettings((s) => ({ ...s, uiScale: v as number }))}
sx={{
mt: 0.4,
'& .MuiSlider-thumb': { boxShadow: '0 10px 22px rgba(0,0,0,0.45)' },
}}
/>
</Box>
<Divider sx={{ borderColor: 'rgba(255,255,255,0.10)' }} />
<FormControlLabel
control={
<Switch
checked={settings.reduceMotion}
onChange={(e) =>
setSettings((s) => ({ ...s, reduceMotion: e.target.checked }))
}
/>
}
label="Уменьшить анимации"
sx={controlSx}
/>
<FormControlLabel
control={
<Switch
checked={settings.blurEffects}
onChange={(e) =>
setSettings((s) => ({ ...s, blurEffects: e.target.checked }))
}
/>
}
label="Эффекты размытия (blur)"
sx={controlSx}
/>
</Box>
</Glass>
<Glass>
<SectionTitle>Уведомления</SectionTitle>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: '1.1vw' }}>
<FormControlLabel
control={
<Switch
checked={settings.notifications}
onChange={(e) =>
setSettings((s) => ({ ...s, notifications: e.target.checked }))
}
/>
}
label="Включить уведомления"
sx={controlSx}
/>
<NotificationPositionPicker
value={settings.notificationPosition}
disabled={!settings.notifications}
onChange={(pos) =>
setSettings((s) => ({
...s,
notificationPosition: pos,
}))
}
/>
<Box sx={{display: 'flex', flexWrap: 'wrap'}}>
<Typography sx={{ color: 'rgba(255,255,255,0.60)', fontWeight: 700, fontSize: '0.9vw' }}>
<span onClick={checkNotif} style={{borderBottom: '1px solid #ccc', cursor: 'pointer'}}>Нажмите сюда,</span> чтобы проверить уведомление.
</Typography>
</Box>
</Box>
</Glass>
</Box>
{/* RIGHT */}
<Box sx={{ display: 'flex', flexDirection: 'column', gap: '2vw', minWidth: 0 }}>
<Glass>
<SectionTitle>Игра</SectionTitle>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: '1.1vw' }}>
<FormControlLabel
control={
<Switch
checked={settings.autoRotateSkinViewer}
onChange={(e) =>
setSettings((s) => ({ ...s, autoRotateSkinViewer: e.target.checked }))
}
/>
}
label="Автоповорот персонажа в профиле"
sx={controlSx}
/>
<Box>
<Typography sx={{ fontFamily: 'Benzin-Bold', color: 'rgba(255,255,255,0.88)' }}>
Скорость ходьбы в просмотрщике: {settings.walkingSpeed.toFixed(2)}
</Typography>
<Slider
value={settings.walkingSpeed}
min={0}
max={1}
step={0.05}
onChange={(_, v) => setSettings((s) => ({ ...s, walkingSpeed: v as number }))}
sx={{ mt: 0.4 }}
/>
</Box>
<Typography sx={{ color: 'rgba(255,255,255,0.60)', fontWeight: 700, fontSize: '0.9vw' }}>
Эти значения можно прокинуть в Profile: autoRotate и walkingSpeed.
</Typography>
</Box>
</Glass>
<Glass>
<SectionTitle>Лаунчер</SectionTitle>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: '1.1vw' }}>
<FormControlLabel
control={
<Switch
checked={settings.autoUpdate}
onChange={(e) => setSettings((s) => ({ ...s, autoUpdate: e.target.checked }))}
/>
}
label="Автообновление данных (где поддерживается)"
sx={controlSx}
/>
<FormControlLabel
control={
<Switch
checked={settings.startInTray}
onChange={(e) => setSettings((s) => ({ ...s, startInTray: e.target.checked }))}
/>
}
label="Запускать свернутым (в трей)"
sx={controlSx}
/>
<Divider sx={{ borderColor: 'rgba(255,255,255,0.10)' }} />
<Button
onClick={() => {
// просто пример действия
try {
localStorage.removeItem('launcher_cache');
} catch {}
}}
disableRipple
sx={{
borderRadius: '999px',
py: '0.8vw',
fontFamily: 'Benzin-Bold',
color: '#fff',
background: 'rgba(255,255,255,0.10)',
border: '1px solid rgba(255,255,255,0.10)',
'&:hover': { background: 'rgba(255,255,255,0.14)' },
}}
>
Очистить кэш (пример)
</Button>
<Typography sx={{ color: 'rgba(255,255,255,0.60)', fontWeight: 700, fontSize: '0.9vw' }}>
Кнопка-заглушка: можно подключить к вашим реальным ключам localStorage.
</Typography>
</Box>
</Glass>
</Box>
</Box>
</Box>
);
};
export default Settings;

View File

@ -27,6 +27,9 @@ import CaseRoulette from '../components/CaseRoulette';
import BonusShopItem from '../components/BonusShopItem';
import ShopItem from '../components/ShopItem';
import { playBuySound, primeSounds } from '../utils/sounds';
import CustomNotification from '../components/Notifications/CustomNotification';
import type { NotificationPosition } from '../components/Notifications/CustomNotification';
import { isNotificationsEnabled, getNotifPositionFromSettings } from '../utils/notifications';
function getRarityByWeight(
weight?: number,
@ -63,6 +66,19 @@ export default function Shop() {
const [playerSkinUrl, setPlayerSkinUrl] = useState<string>('');
// Уведомления
const [notifOpen, setNotifOpen] = useState(false);
const [notifMsg, setNotifMsg] = useState<React.ReactNode>('');
const [notifSeverity, setNotifSeverity] = useState<
'success' | 'info' | 'warning' | 'error'
>('info');
const [notifPos, setNotifPos] = useState<NotificationPosition>({
vertical: 'bottom',
horizontal: 'center',
});
// Прокачка
const [bonusTypes, setBonusTypes] = useState<BonusType[]>([]);
@ -109,14 +125,12 @@ export default function Shop() {
setUserBonuses(user);
} catch (error) {
console.error('Ошибка при получении прокачек:', error);
setNotification({
open: true,
message:
error instanceof Error
? error.message
: 'Ошибка при загрузке прокачки',
type: 'error',
});
if (!isNotificationsEnabled()) return;
setNotifMsg('Ошибка при загрузке прокачки!');
setNotifSeverity('error');
setNotifPos({ vertical: 'top', horizontal: 'center' });
setNotifOpen(true);
} finally {
setBonusesLoading(false);
}
@ -161,19 +175,19 @@ export default function Shop() {
playBuySound();
setNotification({
open: true,
message: 'Плащ успешно куплен!',
type: 'success',
});
if (!isNotificationsEnabled()) return;
setNotifMsg('Плащ успешно куплен!');
setNotifSeverity('success');
setNotifPos({ vertical: 'top', horizontal: 'center' });
setNotifOpen(true);
} catch (error) {
console.error('Ошибка при покупке плаща:', error);
setNotification({
open: true,
message:
error instanceof Error ? error.message : 'Ошибка при покупке плаща',
type: 'error',
});
if (!isNotificationsEnabled()) return;
setNotifMsg('Ошибка при покупке плаща!');
setNotifSeverity('error');
setNotifPos({ vertical: 'top', horizontal: 'center' });
setNotifOpen(true);
}
};
@ -253,11 +267,12 @@ export default function Shop() {
const handlePurchaseBonus = async (bonusTypeId: string) => {
if (!username) {
setNotification({
open: true,
message: 'Не найдено имя игрока. Авторизуйтесь в лаунчере.',
type: 'error',
});
if (!isNotificationsEnabled()) return;
setNotifMsg('Не найдено имя игрока. Авторизируйтесь в лаунчере!');
setNotifSeverity('error');
setNotifPos({ vertical: 'top', horizontal: 'center' });
setNotifOpen(true);
return;
}
@ -267,22 +282,21 @@ export default function Shop() {
playBuySound();
setNotification({
open: true,
message: res.message || 'Прокачка успешно куплена!',
type: 'success',
});
await loadBonuses(username);
if (!isNotificationsEnabled()) return;
setNotifMsg('Прокачка успешно куплена!');
setNotifSeverity('success');
setNotifPos({ vertical: 'top', horizontal: 'center' });
setNotifOpen(true);
} catch (error) {
console.error('Ошибка при покупке прокачки:', error);
setNotification({
open: true,
message:
error instanceof Error
? error.message
: 'Ошибка при покупке прокачки',
type: 'error',
});
if (!isNotificationsEnabled()) return;
setNotifMsg('Ошибка при прокачке!');
setNotifSeverity('error');
setNotifPos({ vertical: 'top', horizontal: 'center' });
setNotifOpen(true);
}
});
};
@ -293,22 +307,22 @@ export default function Shop() {
await withProcessing(bonusId, async () => {
try {
await upgradeBonus(username, bonusId);
setNotification({
open: true,
message: 'Бонус улучшен!',
type: 'success',
});
await loadBonuses(username);
if (!isNotificationsEnabled()) return;
setNotifMsg('Бонус улучшен!');
setNotifSeverity('success');
setNotifPos({ vertical: 'top', horizontal: 'center' });
setNotifOpen(true);
} catch (error) {
console.error('Ошибка при улучшении бонуса:', error);
setNotification({
open: true,
message:
error instanceof Error
? error.message
: 'Ошибка при улучшении бонуса',
type: 'error',
});
if (!isNotificationsEnabled()) return;
setNotifMsg('Ошибка при улучшении бонуса!');
setNotifSeverity('error');
setNotifPos({ vertical: 'top', horizontal: 'center' });
setNotifOpen(true);
}
});
};
@ -322,14 +336,11 @@ export default function Shop() {
await loadBonuses(username);
} catch (error) {
console.error('Ошибка при переключении бонуса:', error);
setNotification({
open: true,
message:
error instanceof Error
? error.message
: 'Ошибка при переключении бонуса',
type: 'error',
});
if (!isNotificationsEnabled()) return;
setNotifMsg('Ошибка при переключении бонуса!');
setNotifSeverity('error');
setNotifPos({ vertical: 'top', horizontal: 'center' });
setNotifOpen(true);
}
});
};
@ -342,20 +353,19 @@ export default function Shop() {
const handleOpenCase = async (caseData: Case) => {
if (!username) {
setNotification({
open: true,
message: 'Не найдено имя игрока. Авторизуйтесь в лаунчере.',
type: 'error',
});
return;
if (!isNotificationsEnabled()) return;
setNotifMsg('Не найдено имя игрока. Авторизуйтесь в лаунчере!');
setNotifSeverity('error');
setNotifPos({ vertical: 'top', horizontal: 'center' });
setNotifOpen(true);
}
if (!isOnline || !playerServer) {
setNotification({
open: true,
message: 'Для открытия кейсов необходимо находиться на сервере в игре.',
type: 'error',
});
if (!isNotificationsEnabled()) return;
setNotifMsg('Для открытия кейсов необходимо находиться на сервере в игре!');
setNotifSeverity('error');
setNotifPos({ vertical: 'top', horizontal: 'center' });
setNotifOpen(true);
return;
}
@ -378,23 +388,24 @@ export default function Shop() {
setRouletteOpen(true);
playBuySound();
// 4. уведомление
setNotification({
open: true,
message: result.message || 'Кейс открыт!',
type: 'success',
});
setIsOpening(false);
// 4. уведомление
if (!isNotificationsEnabled()) return;
setNotifMsg('Кейс открыт!');
setNotifSeverity('success');
setNotifPos({ vertical: 'top', horizontal: 'center' });
setNotifOpen(true);
} catch (error) {
console.error('Ошибка при открытии кейса:', error);
setNotification({
open: true,
message:
error instanceof Error ? error.message : 'Ошибка при открытии кейса',
type: 'error',
});
setIsOpening(false);
if (!isNotificationsEnabled()) return;
setNotifMsg('Ошибка при открытии кейса!');
setNotifSeverity('error');
setNotifPos({ vertical: 'top', horizontal: 'center' });
setNotifOpen(true);
}
};
@ -677,19 +688,14 @@ export default function Shop() {
/>
{/* Уведомления */}
<Snackbar
open={notification.open}
autoHideDuration={6000}
onClose={handleCloseNotification}
>
<Alert
onClose={handleCloseNotification}
severity={notification.type}
sx={{ width: '100%' }}
>
{notification.message}
</Alert>
</Snackbar>
<CustomNotification
open={notifOpen}
message={notifMsg}
severity={notifSeverity}
position={notifPos}
onClose={() => setNotifOpen(false)}
autoHideDuration={2500}
/>
</Box>
);
}

View File

@ -0,0 +1,39 @@
import type { NotificationPosition } from '../components/Notifications/CustomNotification';
export function isNotificationsEnabled(): boolean {
try {
const s = JSON.parse(localStorage.getItem('launcher_settings') || '{}');
return s.notifications !== false; // по умолчанию true
} catch {
return true;
}
}
export function positionFromSettingValue(
v: string | undefined,
): NotificationPosition {
switch (v) {
case 'top-left':
return { vertical: 'top', horizontal: 'left' };
case 'top-center':
return { vertical: 'top', horizontal: 'center' };
case 'top-right':
return { vertical: 'top', horizontal: 'right' };
case 'bottom-left':
return { vertical: 'bottom', horizontal: 'left' };
case 'bottom-center':
return { vertical: 'bottom', horizontal: 'center' };
case 'bottom-right':
default:
return { vertical: 'bottom', horizontal: 'right' };
}
}
export function getNotifPositionFromSettings(): NotificationPosition {
try {
const s = JSON.parse(localStorage.getItem('launcher_settings') || '{}');
return positionFromSettingValue(s.notificationPosition);
} catch {
return { vertical: 'top', horizontal: 'right' };
}
}

View File

@ -1,8 +1,6 @@
// src/renderer/utils/serverTranslator.ts
import { Server } from '../api';
export function translateServer(server: Server): string {
switch (server.name) {
export function translateServer(serverName: string): string {
switch (serverName) {
case 'Server minecraft.hub.popa-popa.ru':
return 'Хаб';
case 'Server minecraft.survival.popa-popa.ru':
@ -10,6 +8,6 @@ export function translateServer(server: Server): string {
case 'Server minecraft.minigames.popa-popa.ru':
return 'Миниигры';
default:
return server.name;
return serverName;
}
}

View File

@ -0,0 +1,34 @@
import type { NotificationPosition } from '../components/Notifications/CustomNotification';
export type LauncherSettings = {
notificationPosition?: 'top-right' | 'top-center' | 'top-left' | 'bottom-right' | 'bottom-center' | 'bottom-left';
};
export function getLauncherSettings(): LauncherSettings {
try {
return JSON.parse(localStorage.getItem('launcher_settings') || '{}');
} catch {
return {};
}
}
export function getNotificationPosition(): NotificationPosition {
const { notificationPosition } = getLauncherSettings();
switch (notificationPosition) {
case 'top-right':
return { vertical: 'top', horizontal: 'right' };
case 'top-center':
return { vertical: 'top', horizontal: 'center' };
case 'top-left':
return { vertical: 'top', horizontal: 'left' };
case 'bottom-right':
return { vertical: 'bottom', horizontal: 'right' };
case 'bottom-center':
return { vertical: 'bottom', horizontal: 'center' };
case 'bottom-left':
return { vertical: 'bottom', horizontal: 'left' };
default:
return { vertical: 'bottom', horizontal: 'center' };
}
}