ne minor, a ebat fix
This commit is contained in:
38
README.md
38
README.md
@ -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}
|
||||
/>
|
||||
@ -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={
|
||||
|
||||
@ -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>
|
||||
|
||||
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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"
|
||||
/>
|
||||
*/
|
||||
|
||||
@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@ -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,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
|
||||
@ -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',
|
||||
|
||||
@ -38,6 +38,13 @@ export default function PageHeader() {
|
||||
return { title: '', subtitle: '', hidden: true };
|
||||
}
|
||||
|
||||
if (path === '/settings') {
|
||||
return {
|
||||
title: 'Настройки',
|
||||
subtitle: 'Персонализация интерфейса и поведения лаунчера',
|
||||
}
|
||||
}
|
||||
|
||||
if (path === '/news') {
|
||||
return {
|
||||
title: 'Новости',
|
||||
|
||||
@ -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' }} />;
|
||||
}
|
||||
|
||||
@ -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={{
|
||||
|
||||
@ -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>
|
||||
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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>
|
||||
|
||||
|
||||
@ -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,9 +219,10 @@ 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);
|
||||
@ -224,18 +232,48 @@ export default function Profile() {
|
||||
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
|
||||
@ -246,277 +284,318 @@ 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>
|
||||
|
||||
{/* 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%',
|
||||
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={{
|
||||
borderRadius: '1.1vw',
|
||||
p: '1.6vw',
|
||||
mb: '1.1vw',
|
||||
textAlign: 'center',
|
||||
cursor: 'pointer',
|
||||
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();
|
||||
setIsDragOver(true);
|
||||
}}
|
||||
onDragLeave={() => setIsDragOver(false)}
|
||||
onDrop={handleFileDrop}
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
>
|
||||
<input
|
||||
type="file"
|
||||
ref={fileInputRef}
|
||||
accept=".png"
|
||||
style={{ display: 'none' }}
|
||||
onChange={handleFileSelect}
|
||||
/>
|
||||
|
||||
<Typography
|
||||
sx={{
|
||||
color: '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
|
||||
fullWidth
|
||||
size="small"
|
||||
sx={{
|
||||
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>Модель скина</InputLabel>
|
||||
<Select
|
||||
value={skinModel}
|
||||
label="Модель скина"
|
||||
onChange={(e) => setSkinModel(e.target.value)}
|
||||
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={{
|
||||
borderRadius: '999px',
|
||||
bgcolor: 'rgba(255,255,255,0.04)',
|
||||
color: 'rgba(255,255,255,0.92)',
|
||||
fontFamily: 'Benzin-Bold',
|
||||
'& .MuiSelect-select': {
|
||||
py: '0.9vw',
|
||||
px: '1.2vw',
|
||||
},
|
||||
'& .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',
|
||||
},
|
||||
'& .MuiSelect-icon': {
|
||||
color: 'rgba(255,255,255,0.75)',
|
||||
},
|
||||
}}
|
||||
>
|
||||
<MenuItem value="">По умолчанию</MenuItem>
|
||||
<MenuItem value="slim">Тонкая (Alex)</MenuItem>
|
||||
<MenuItem value="classic">Классическая (Steve)</MenuItem>
|
||||
</Select>
|
||||
</FormControl>
|
||||
|
||||
{/* button */}
|
||||
<Button
|
||||
variant="contained"
|
||||
fullWidth
|
||||
onClick={handleUploadSkin}
|
||||
disabled={uploadStatus === 'loading' || !skinFile}
|
||||
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' ? 'Загрузка...' : 'Установить скин'}
|
||||
</Button>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{/* RIGHT COLUMN */}
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: '1vw',
|
||||
width: '100%',
|
||||
minWidth: 0,
|
||||
maxWidth: '44vw',
|
||||
justifySelf: 'start'
|
||||
justifySelf: 'start',
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
sx={{
|
||||
width: '100%',
|
||||
p: '2.2vw',
|
||||
borderRadius: '1.2vw',
|
||||
flexShrink: 0,
|
||||
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={{
|
||||
borderRadius: '1.1vw',
|
||||
p: '1.6vw',
|
||||
mb: '1.1vw',
|
||||
textAlign: 'center',
|
||||
cursor: 'pointer',
|
||||
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();
|
||||
setIsDragOver(true);
|
||||
}}
|
||||
onDragLeave={() => setIsDragOver(false)}
|
||||
onDrop={handleFileDrop}
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
>
|
||||
<input
|
||||
type="file"
|
||||
ref={fileInputRef}
|
||||
accept=".png"
|
||||
style={{ display: 'none' }}
|
||||
onChange={handleFileSelect}
|
||||
/>
|
||||
|
||||
<Typography
|
||||
sx={{
|
||||
color: '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
|
||||
fullWidth
|
||||
size="small"
|
||||
sx={{
|
||||
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>Модель скина</InputLabel>
|
||||
|
||||
<Select
|
||||
value={skinModel}
|
||||
label="Модель скина"
|
||||
onChange={(e) => setSkinModel(e.target.value)}
|
||||
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={{
|
||||
borderRadius: '999px',
|
||||
bgcolor: 'rgba(255,255,255,0.04)',
|
||||
color: 'rgba(255,255,255,0.92)',
|
||||
fontFamily: 'Benzin-Bold',
|
||||
'& .MuiSelect-select': {
|
||||
py: '0.9vw',
|
||||
px: '1.2vw',
|
||||
},
|
||||
'& .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',
|
||||
},
|
||||
'& .MuiSelect-icon': {
|
||||
color: 'rgba(255,255,255,0.75)',
|
||||
},
|
||||
}}
|
||||
>
|
||||
<MenuItem value="">По умолчанию</MenuItem>
|
||||
<MenuItem value="slim">Тонкая (Alex)</MenuItem>
|
||||
<MenuItem value="classic">Классическая (Steve)</MenuItem>
|
||||
</Select>
|
||||
</FormControl>
|
||||
|
||||
{/* button */}
|
||||
<Button
|
||||
variant="contained"
|
||||
fullWidth
|
||||
onClick={handleUploadSkin}
|
||||
disabled={uploadStatus === 'loading' || !skinFile}
|
||||
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' ? (
|
||||
<Typography sx={{ fontWeight: 900, color: 'rgba(255,255,255,0.9)' }}>
|
||||
Загрузка...
|
||||
</Typography>
|
||||
) : (
|
||||
<Typography sx={{ fontWeight: 900, color: 'rgba(255,255,255,0.95)' }}>
|
||||
Установить скин
|
||||
</Typography>
|
||||
)}
|
||||
</Button>
|
||||
</Box>
|
||||
<Box
|
||||
{/* Плащи */}
|
||||
<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) => (
|
||||
@ -524,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>
|
||||
);
|
||||
|
||||
568
src/renderer/pages/Settings.tsx
Normal file
568
src/renderer/pages/Settings.tsx
Normal file
@ -0,0 +1,568 @@
|
||||
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 Settings = () => {
|
||||
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(() => {
|
||||
if (typeof window === 'undefined') return false;
|
||||
const saved = safeParseSettings(localStorage.getItem(STORAGE_KEY)) ?? defaultSettings;
|
||||
return JSON.stringify(saved) !== JSON.stringify(settings);
|
||||
}, [settings]);
|
||||
|
||||
const save = () => {
|
||||
try {
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(settings));
|
||||
|
||||
window.dispatchEvent(new CustomEvent('settings-updated'));
|
||||
|
||||
// если уведомления выключены — НЕ показываем нотификацию
|
||||
if (!isNotificationsEnabled()) return;
|
||||
setNotifMsg('Настройки успешно сохранены!');
|
||||
setNotifSeverity('info');
|
||||
setNotifPos(getNotifPositionFromSettings());
|
||||
setNotifOpen(true);
|
||||
} catch (e) {
|
||||
console.error('Не удалось сохранить настройки', e);
|
||||
}
|
||||
};
|
||||
|
||||
const reset = () => {
|
||||
setSettings(defaultSettings);
|
||||
try {
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(defaultSettings));
|
||||
} catch (e) {
|
||||
console.error('Не удалось сбросить настройки', e);
|
||||
}
|
||||
};
|
||||
|
||||
const checkNotif = () => {
|
||||
setNotifMsg('Проверка уведомления!');
|
||||
setNotifSeverity('info');
|
||||
setNotifPos(getNotifPositionFromSettings());
|
||||
setNotifOpen(true);
|
||||
}
|
||||
|
||||
// Apply a few settings instantly
|
||||
useEffect(() => {
|
||||
if (typeof document === 'undefined') return;
|
||||
|
||||
// UI scale (простая версия)
|
||||
document.documentElement.style.zoom = `${settings.uiScale}%`;
|
||||
|
||||
// Reduce motion
|
||||
document.body.classList.toggle('reduce-motion', settings.reduceMotion);
|
||||
|
||||
// Blur effects (можно использовать этот класс в sx, если захочешь)
|
||||
document.body.classList.toggle('no-blur', !settings.blurEffects);
|
||||
|
||||
// Persist
|
||||
save();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [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;
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
39
src/renderer/utils/notifications.ts
Normal file
39
src/renderer/utils/notifications.ts
Normal 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' };
|
||||
}
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
34
src/renderer/utils/settings.ts
Normal file
34
src/renderer/utils/settings.ts
Normal 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' };
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user