diff --git a/README.md b/README.md index 116921d..152d57f 100644 --- a/README.md +++ b/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(''); +const [notifSeverity, setNotifSeverity] = useState< + 'success' | 'info' | 'warning' | 'error' +>('info'); + +const [notifPos, setNotifPos] = useState({ + vertical: 'bottom', + horizontal: 'center', +}); + +# ВМЕСТО setNotification +setNotifMsg('Ошибка при загрузке прокачки!'); // string +setNotifSeverity('error'); // 'success' || 'info' || 'warning' || 'error' +setNotifPos(getNotificationPosition()); // top || bottom & center || right || left +setNotifOpen(true); // Не изменять + +# СРАЗУ ПОСЛЕ ПЕРВОГО + + setNotifOpen(false)} + autoHideDuration={2500} +/> \ No newline at end of file diff --git a/src/renderer/App.tsx b/src/renderer/App.tsx index efd009c..def4045 100644 --- a/src/renderer/App.tsx +++ b/src/renderer/App.tsx @@ -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(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 = () => { } /> + + + + } + /> = ({ )} + + {!isBuyMode && onToggleActive && ( - + )} diff --git a/src/renderer/components/CapeCard.tsx b/src/renderer/components/CapeCard.tsx index 6d39242..f8b176f 100644 --- a/src/renderer/components/CapeCard.tsx +++ b/src/renderer/components/CapeCard.tsx @@ -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 ( - - + - {/* Ценник для магазина */} - {mode === 'shop' && cape.price !== undefined && ( + {/* градиентная полоска-акцент (как у твоих блоков) */} + + + {/* chip справа сверху */} + {topRightChip && ( )} - - - - {capeName} - - - - - - + {/* Здесь показываем ЛЕВУЮ половину текстуры (лицевую часть) */} + + + + + {/* content */} + + + {capeName} + + + {/* действия */} + + + + + ); } diff --git a/src/renderer/components/CoinsDisplay.tsx b/src/renderer/components/CoinsDisplay.tsx index 16866ab..47eb6a7 100644 --- a/src/renderer/components/CoinsDisplay.tsx +++ b/src/renderer/components/CoinsDisplay.tsx @@ -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(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(() => { + // 1) если пришло значение извне — оно приоритетнее + if (externalValue !== undefined) return externalValue; + + // 2) иначе пробуем localStorage + const cached = readCachedCoins(); + if (cached !== null) return cached; + + // 3) иначе 0 + return 0; + }); + const [isLoading, setIsLoading] = useState(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)} ); @@ -207,34 +237,3 @@ export default function CoinsDisplay({ return coinsDisplay; } - -// Примеры использования в комментариях для разработчика: -/* -// Пример 1: Простое отображение числа - - -// Пример 2: Получение данных по username с автообновлением - - -// Пример 3: Кастомная стилизация без иконки - - -// Пример 4: Большой отображение для профиля - -*/ diff --git a/src/renderer/components/FullScreenLoader.tsx b/src/renderer/components/FullScreenLoader.tsx index cbed7ed..85ccb34 100644 --- a/src/renderer/components/FullScreenLoader.tsx +++ b/src/renderer/components/FullScreenLoader.tsx @@ -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 ( - {/* Градиентное вращающееся кольцо */} - + {/* Плавное появление фона */} + {fullScreen && ( + + + + )} - {message && ( - + - {message} - - )} + + + {message && ( + + {message} + + )} + + ); }; diff --git a/src/renderer/components/HeadAvatar.tsx b/src/renderer/components/HeadAvatar.tsx index 0fb4159..6be1d5c 100644 --- a/src/renderer/components/HeadAvatar.tsx +++ b/src/renderer/components/HeadAvatar.tsx @@ -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 = ({ skinUrl, size = 24, style, + version = 0, // ✅ дефолт ...canvasProps }) => { const canvasRef = useRef(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 = ({ 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 ( = ({ height: size, borderRadius: 4, imageRendering: 'pixelated', - ...style, // 👈 даём переопределять снаружи + ...style, }} /> ); diff --git a/src/renderer/components/OnlinePlayersPanel.tsx b/src/renderer/components/OnlinePlayersPanel.tsx index 59d6b35..e43e3f4 100644 --- a/src/renderer/components/OnlinePlayersPanel.tsx +++ b/src/renderer/components/OnlinePlayersPanel.tsx @@ -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 = ({ 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 ( = ({ {/* Select в “нашем” стиле */} Сервер @@ -274,25 +295,55 @@ export const OnlinePlayersPanel: React.FC = ({ {/* Поиск через ваш GradientTextField */} - 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)', + }, + }} + /> + + {/* 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, }, }} - /> + /> */} @@ -378,7 +429,7 @@ export const OnlinePlayersPanel: React.FC = ({ (null); const viewerRef = useRef(null); + const animRef = useRef(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 ( - - ); + // 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 ; } diff --git a/src/renderer/components/TopBar.tsx b/src/renderer/components/TopBar.tsx index 726bf75..8524874 100644 --- a/src/renderer/components/TopBar.tsx +++ b/src/renderer/components/TopBar.tsx @@ -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(''); + const [skinVersion, setSkinVersion] = useState(0); const [avatarAnchorEl, setAvatarAnchorEl] = useState( 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 ( {/* Левая часть */} @@ -433,6 +467,7 @@ export default function TopBar({ onRegister, username }: TopBarProps) { @@ -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) { Ежедневная награда + { + handleAvatarMenuClose(); + navigate('/settings'); + }} + sx={{ + fontFamily: 'Benzin-Bold', + fontSize: '1.5vw', + gap: '0.5vw', + py: '0.7vw', + '&:hover': { + bgcolor: 'rgba(255,77,77,0.15)', + }, + }} + > + Настройки + + {!isLoginPage && !isRegistrationPage && username && ( + + + + {/* RIGHT COLUMN */} - - {/* dropzone */} - { - e.preventDefault(); - setIsDragOver(true); - }} - onDragLeave={() => setIsDragOver(false)} - onDrop={handleFileDrop} - onClick={() => fileInputRef.current?.click()} - > - - - - {skinFile - ? `Выбран файл: ${skinFile.name}` - : 'Перетащите PNG файл скина или кликните для выбора'} - - - - Только .png • Рекомендуется 64×64 - - - - {/* select */} - - Модель скина - - - - - {/* button */} - - - - Ваши плащи + + Ваши плащи + + {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} /> ))} - + + + {/* Онлайн */} - + )} ); diff --git a/src/renderer/pages/Settings.tsx b/src/renderer/pages/Settings.tsx new file mode 100644 index 0000000..826ed37 --- /dev/null +++ b/src/renderer/pages/Settings.tsx @@ -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 ( + + + Позиция уведомлений + + + + + {POSITIONS.map((p) => { + const selected = value === p.key; + + return ( + 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', + }} + > + {/* мини-уведомление */} + + + + + + ); + })} + + + + ); +}; + + +const Settings = () => { + const [notifOpen, setNotifOpen] = useState(false); + const [notifMsg, setNotifMsg] = useState(''); + const [notifSeverity, setNotifSeverity] = useState< + 'success' | 'info' | 'warning' | 'error' + >('info'); + + const [notifPos, setNotifPos] = useState({ + vertical: 'bottom', + horizontal: 'center', + }); + + const [settings, setSettings] = useState(() => { + 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 }) => ( + + {children} + + ); + + const Glass = ({ children }: { children: React.ReactNode }) => ( + + {children} + + ); + + 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 ( + + setNotifOpen(false)} + autoHideDuration={2500} + /> + {/* header */} + + + + {dirty && ( + + )} + + + + + + + + {/* LEFT */} + + + Интерфейс + + + + + Масштаб интерфейса: {settings.uiScale}% + + setSettings((s) => ({ ...s, uiScale: v as number }))} + sx={{ + mt: 0.4, + '& .MuiSlider-thumb': { boxShadow: '0 10px 22px rgba(0,0,0,0.45)' }, + }} + /> + + + + + + setSettings((s) => ({ ...s, reduceMotion: e.target.checked })) + } + /> + } + label="Уменьшить анимации" + sx={controlSx} + /> + + + setSettings((s) => ({ ...s, blurEffects: e.target.checked })) + } + /> + } + label="Эффекты размытия (blur)" + sx={controlSx} + /> + + + + + Уведомления + + + + setSettings((s) => ({ ...s, notifications: e.target.checked })) + } + /> + } + label="Включить уведомления" + sx={controlSx} + /> + + + setSettings((s) => ({ + ...s, + notificationPosition: pos, + })) + } + /> + + + + + + Нажмите сюда, чтобы проверить уведомление. + + + + + + + {/* RIGHT */} + + + Игра + + + + setSettings((s) => ({ ...s, autoRotateSkinViewer: e.target.checked })) + } + /> + } + label="Автоповорот персонажа в профиле" + sx={controlSx} + /> + + + + Скорость ходьбы в просмотрщике: {settings.walkingSpeed.toFixed(2)} + + setSettings((s) => ({ ...s, walkingSpeed: v as number }))} + sx={{ mt: 0.4 }} + /> + + + + Эти значения можно прокинуть в Profile: autoRotate и walkingSpeed. + + + + + + Лаунчер + + + setSettings((s) => ({ ...s, autoUpdate: e.target.checked }))} + /> + } + label="Автообновление данных (где поддерживается)" + sx={controlSx} + /> + + setSettings((s) => ({ ...s, startInTray: e.target.checked }))} + /> + } + label="Запускать свернутым (в трей)" + sx={controlSx} + /> + + + + + + + Кнопка-заглушка: можно подключить к вашим реальным ключам localStorage. + + + + + + + + ); +}; + +export default Settings; diff --git a/src/renderer/pages/Shop.tsx b/src/renderer/pages/Shop.tsx index cc46336..d73df40 100644 --- a/src/renderer/pages/Shop.tsx +++ b/src/renderer/pages/Shop.tsx @@ -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(''); + // Уведомления + + const [notifOpen, setNotifOpen] = useState(false); + const [notifMsg, setNotifMsg] = useState(''); + const [notifSeverity, setNotifSeverity] = useState< + 'success' | 'info' | 'warning' | 'error' + >('info'); + + const [notifPos, setNotifPos] = useState({ + vertical: 'bottom', + horizontal: 'center', + }); + // Прокачка const [bonusTypes, setBonusTypes] = useState([]); @@ -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() { /> {/* Уведомления */} - - - {notification.message} - - + setNotifOpen(false)} + autoHideDuration={2500} + /> ); } diff --git a/src/renderer/utils/notifications.ts b/src/renderer/utils/notifications.ts new file mode 100644 index 0000000..a23423b --- /dev/null +++ b/src/renderer/utils/notifications.ts @@ -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' }; + } +} diff --git a/src/renderer/utils/serverTranslator.ts b/src/renderer/utils/serverTranslator.ts index b2b2423..5c49234 100644 --- a/src/renderer/utils/serverTranslator.ts +++ b/src/renderer/utils/serverTranslator.ts @@ -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; } } diff --git a/src/renderer/utils/settings.ts b/src/renderer/utils/settings.ts new file mode 100644 index 0000000..ad947de --- /dev/null +++ b/src/renderer/utils/settings.ts @@ -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' }; + } +}