Files
popa-launcher/src/renderer/components/TopBar.tsx
2026-01-02 17:23:23 +05:00

925 lines
27 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import {
Box,
Button,
Tab,
Tabs,
Typography,
Menu,
MenuItem,
Divider,
} from '@mui/material';
import CloseRoundedIcon from '@mui/icons-material/CloseRounded';
import { useLocation, useNavigate } from 'react-router-dom';
import ArrowBackRoundedIcon from '@mui/icons-material/ArrowBackRounded';
import { useEffect, useRef, useState, useCallback } from 'react';
import CustomTooltip from './Notifications/CustomTooltip';
import CoinsDisplay from './CoinsDisplay';
import { HeadAvatar } from './HeadAvatar';
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';
import { useTheme } from '@mui/material/styles';
import InventoryIcon from '@mui/icons-material/Inventory';
import { RiCoupon3Fill } from 'react-icons/ri';
import type { NotificationPosition } from '../components/Notifications/CustomNotification';
import {
isNotificationsEnabled,
getNotifPositionFromSettings,
} from '../utils/notifications';
declare global {
interface Window {
electron: {
ipcRenderer: {
invoke(channel: string, ...args: unknown[]): Promise<any>;
on(channel: string, func: (...args: unknown[]) => void): void;
removeAllListeners(channel: string): void;
};
};
}
}
// Определяем пропсы
interface TopBarProps {
onRegister?: () => void; // Опционально, если нужен обработчик регистрации
username?: string;
}
export default function TopBar({ onRegister, username }: TopBarProps) {
// Получаем текущий путь
const location = useLocation();
const isLoginPage = location.pathname === '/login';
const [isAuthed, setIsAuthed] = useState(false);
const isLaunchPage = location.pathname.startsWith('/launch');
const isRegistrationPage = location.pathname === '/registration';
const navigate = useNavigate();
const tabsWrapperRef = useRef<HTMLDivElement | null>(null);
const tabsRootRef = useRef<HTMLDivElement | null>(null);
const theme = useTheme();
const updateGradientVars = useCallback(() => {
const root = tabsRootRef.current;
if (!root) return;
const tabsRect = root.getBoundingClientRect();
const active = root.querySelector<HTMLElement>('.MuiTab-root.Mui-selected');
if (!active) return;
const activeRect = active.getBoundingClientRect();
const x = activeRect.left - tabsRect.left;
root.style.setProperty('--tabs-w', `${tabsRect.width}px`);
root.style.setProperty('--active-x', `${x}px`);
}, []);
const [skinUrl, setSkinUrl] = useState<string>('');
const [skinVersion, setSkinVersion] = useState(0);
const [avatarAnchorEl, setAvatarAnchorEl] = useState<null | HTMLElement>(
null,
);
// ===== QUICK LAUNCH ===== \\
const [lastVersion, setLastVersion] = useState<null | any>(null);
useEffect(() => {
try {
const raw = localStorage.getItem('last_launched_version');
if (!raw) return;
setLastVersion(JSON.parse(raw));
} catch {
setLastVersion(null);
}
}, []);
// ===== QUICK LAUNCH ===== \\
const path = location.pathname || '';
const isAuthPage =
path.startsWith('/login') || path.startsWith('/registration');
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 showNotification = (
message: React.ReactNode,
severity: 'success' | 'info' | 'warning' | 'error' = 'info',
position: NotificationPosition = getNotifPositionFromSettings(),
) => {
if (!isNotificationsEnabled()) return;
setNotifMsg(message);
setNotifSeverity(severity);
setNotifPos(position);
setNotifOpen(true);
};
const TAB_ROUTES: Array<{
value: number;
match: (p: string) => boolean;
to: string;
}> = [
{
value: 0,
match: (p) => p === '/news',
to: '/news',
},
{
value: 1,
match: (p) => p === '/',
to: '/',
},
{
value: 2,
match: (p) => p.startsWith('/shop'),
to: '/shop',
},
{
value: 3,
match: (p) => p.startsWith('/marketplace'),
to: '/marketplace',
},
{
value: 4,
match: (p) => p.startsWith('/voice'),
to: '/voice',
},
];
const selectedTab =
TAB_ROUTES.find((r) => r.match(location.pathname))?.value ?? false;
useEffect(() => {
updateGradientVars();
window.addEventListener('resize', updateGradientVars);
return () => window.removeEventListener('resize', updateGradientVars);
}, [updateGradientVars, selectedTab, location.pathname]);
useEffect(() => {
const saved = localStorage.getItem('launcher_config');
try {
const cfg = saved ? JSON.parse(saved) : null;
setIsAuthed(Boolean(cfg?.accessToken)); // или cfg?.uuid/username — как у тебя принято
} catch {
setIsAuthed(false);
}
}, [location.pathname]); // можно и без dependency, но так надёжнее при logout/login
const avatarMenuOpen = Boolean(avatarAnchorEl);
const handleAvatarClick = (event: React.MouseEvent<HTMLElement>) => {
setAvatarAnchorEl(event.currentTarget);
};
const handleAvatarMenuClose = () => {
setAvatarAnchorEl(null);
};
// useEffect(() => {
// if (location.pathname === '/news') {
// setValue(0);
// setActivePage('news');
// } else if (location.pathname === '/') {
// setValue(1);
// setActivePage('versions');
// } else if (location.pathname.startsWith('/shop')) {
// setValue(3);
// setActivePage('shop');
// } else if (location.pathname.startsWith('/marketplace')) {
// setValue(4);
// setActivePage('marketplace');
// } else {
// // любые страницы не из TopBar: /profile, /daily, /dailyquests, и т.д.
// setValue(false);
// setActivePage('');
// }
// }, [location.pathname]);
const handleLaunchPage = () => {
navigate('/');
};
const handleTabsWheel = (event: React.WheelEvent<HTMLDivElement>) => {
// чтобы страница не скроллилась вертикально
event.preventDefault();
if (!tabsWrapperRef.current) return;
// Находим внутренний скроллер MUI Tabs
const scroller = tabsWrapperRef.current.querySelector(
'.MuiTabs-scroller',
) as HTMLDivElement | null;
if (!scroller) return;
// Прокручиваем горизонтально, используя вертикальный скролл мыши
scroller.scrollLeft += event.deltaY * 0.3;
requestAnimationFrame(updateGradientVars);
};
// const getPageTitle = () => {
// if (isLoginPage) {
// return 'Вход';
// }
// if (isLaunchPage) {
// return 'Запуск';
// }
// if (isVersionsExplorerPage) {
// if (activePage === 'versions') {
// return 'Версии';
// }
// if (activePage === 'profile') {
// return 'Профиль';
// }
// if (activePage === 'shop') {
// return 'Магазин';
// }
// if (activePage === 'marketplace') {
// return 'Рынок';
// }
// }
// return 'Неизвестная страница';
// };
// Функция для получения количества монет
const tabBaseSx = [{ fontSize: '0.7em' }, theme.launcher.topbar.tabBase];
const logout = () => {
localStorage.removeItem('launcher_config');
localStorage.removeItem(`coins:${username}`);
localStorage.removeItem('last_route');
navigate('/login');
window.electron.ipcRenderer.invoke('auth-changed', { isAuthed: false });
};
const loadSkin = useCallback(async () => {
if (!isAuthed) {
setSkinUrl('');
return;
}
const savedConfig = localStorage.getItem('launcher_config');
if (!savedConfig) return;
let cfg: any = null;
try {
cfg = JSON.parse(savedConfig);
} catch {
return;
}
const uuid = cfg?.uuid;
if (!uuid) return;
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]);
useEffect(() => {
const handler = () => {
requestAnimationFrame(updateGradientVars);
};
window.addEventListener('settings-updated', handler as EventListener);
return () =>
window.removeEventListener('settings-updated', handler as EventListener);
}, [updateGradientVars]);
const handleQuickLaunch = async () => {
const raw = localStorage.getItem('last_launched_version');
if (!raw) {
showNotification('Вы не запускали ни одну из сборок!', 'warning');
return;
}
const ctx = JSON.parse(raw);
const savedConfig = JSON.parse(
localStorage.getItem('launcher_config') || '{}',
);
if (!savedConfig.accessToken) {
showNotification('Вы не авторизованы', 'error');
return;
}
await window.electron.ipcRenderer.invoke('launch-minecraft', {
accessToken: savedConfig.accessToken,
uuid: savedConfig.uuid,
username: savedConfig.username,
memory: ctx.memory,
baseVersion: ctx.baseVersion,
fabricVersion: ctx.fabricVersion,
packName: ctx.packName,
serverIp: ctx.serverIp,
isVanillaVersion: ctx.isVanillaVersion,
versionToLaunchOverride: ctx.versionToLaunchOverride,
});
};
const getLastLaunchLabel = (v: any) => {
if (!v) return '';
const title = v.isVanillaVersion
? `Minecraft ${v.versionId}`
: `Сборка ${v.packName}`;
const details = [
v.baseVersion ? `MC ${v.baseVersion}` : null,
v.memory ? `${v.memory} MB RAM` : null,
]
.filter(Boolean)
.join(' · ');
return (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: '0.2vw' }}>
<Typography sx={{ fontSize: '0.9vw', fontWeight: 600 }}>
{title}
</Typography>
<Typography sx={{ fontSize: '0.75vw', opacity: 0.7 }}>
{details}
</Typography>
</Box>
);
};
return (
<Box
className={isAuthPage ? undefined : 'glass-ui'}
sx={[
{
display: 'flex',
position: 'fixed',
top: 0,
left: 0,
right: 0,
height: '8vh',
zIndex: 1000,
width: '100%',
WebkitAppRegion: 'drag',
overflow: 'hidden',
justifyContent: 'space-between',
alignItems: 'center',
},
theme.launcher.topbar.firstBox,
]}
>
{/* Левая часть */}
<Box
sx={{
display: 'flex',
WebkitAppRegion: 'no-drag',
gap: '2vw',
alignItems: 'center',
marginLeft: '1vw',
}}
>
{(isLaunchPage || isRegistrationPage) && (
<Button
variant="outlined"
onClick={() => handleLaunchPage()}
sx={[
{
width: '3em',
height: '3em',
borderRadius: '50%',
minWidth: 'unset',
minHeight: 'unset',
},
theme.launcher.topbar.backButton,
]}
>
<ArrowBackRoundedIcon />
</Button>
)}
{isAuthed && !isLaunchPage && (
<Box
ref={tabsWrapperRef}
onWheel={handleTabsWheel}
// старый вариант
sx={{
borderBottom: 1,
...theme.launcher.topbar.tabsBox,
// '& .MuiTabs-indicator': {
// backgroundColor: 'rgba(255, 77, 77, 1)',
// },
}}
// sx={{
// borderBottom: 'none',
// borderRadius: '2vw',
// px: '0.6vw',
// py: '0.4vw',
// background: 'rgba(0,0,0,0.35)',
// border: '1px solid rgba(255,255,255,0.08)',
// boxShadow: '0 8px 20px rgba(0,0,0,0.25)',
// '& .MuiTabs-indicator': {
// height: '100%',
// borderRadius: '1.6vw',
// background:
// 'linear-gradient(71deg, #F27121 0%, #E940CD 70%, #8A2387 100%)',
// opacity: 0.18,
// },
// }}
>
<CustomTooltip
title={
'Покрути колесиком мыши чтобы увидеть остальные элементы меню'
}
arrow
placement="bottom"
TransitionProps={{ timeout: 100 }}
>
<Tabs
ref={tabsRootRef}
value={selectedTab}
onChange={(_, newValue) => {
const route = TAB_ROUTES.find((r) => r.value === newValue);
if (route) navigate(route.to);
}}
aria-label="basic tabs example"
variant="scrollable"
scrollButtons={false}
disableRipple={true}
sx={{
...theme.launcher.topbar.tabs,
}}
>
<Tab
label="Новости"
disableRipple={true}
sx={[
...tabBaseSx,
selectedTab === 0 ? theme.launcher.topbar.tabActive : null,
]}
/>
<Tab
label="Версии"
disableRipple={true}
sx={[
...tabBaseSx,
selectedTab === 1 ? theme.launcher.topbar.tabActive : null,
]}
/>
<Tab
label="Магазин"
disableRipple={true}
sx={[
...tabBaseSx,
selectedTab === 2 ? theme.launcher.topbar.tabActive : null,
]}
/>
<Tab
label="Рынок"
disableRipple={true}
sx={[
...tabBaseSx,
selectedTab === 3 ? theme.launcher.topbar.tabActive : null,
]}
/>
</Tabs>
</CustomTooltip>
</Box>
)}
</Box>
{/* Центр */}
<Box
sx={{
position: 'absolute',
left: '50%',
transform: 'translateX(-50%)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
flexGrow: 1,
WebkitAppRegion: 'drag',
}}
>
{/* <Typography
variant="h6"
sx={{ color: 'white', fontFamily: 'Benzin-Bold' }}
>
{getPageTitle()}
</Typography> */}
</Box>
{/* Правая часть со всеми кнопками */}
<Box
sx={{
display: 'flex',
WebkitAppRegion: 'no-drag',
gap: '1vw',
alignItems: 'center',
marginRight: '1vw',
}}
>
{lastVersion && (
<CustomTooltip
title={getLastLaunchLabel(lastVersion)}
arrow
placement="bottom"
essential
TransitionProps={{ timeout: 120 }}
>
<Button
onClick={handleQuickLaunch}
disableRipple
disableFocusRipple
sx={{
minWidth: 'unset',
width: '3vw',
height: '3vw',
borderRadius: '3vw',
display: 'flex',
alignItems: 'center',
justifyContent: 'flex-end',
overflow: 'hidden',
px: '0.8vw',
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)',
color: 'white',
backdropFilter: 'blur(14px)',
transition: 'all 0.3s ease',
'& .quick-text': {
opacity: 0,
whiteSpace: 'nowrap',
marginRight: '0.6vw',
fontSize: '0.9vw',
fontFamily: 'Benzin-Bold',
transform: 'translateX(10px)',
transition: 'all 0.25s ease',
},
'&:hover': {
width: '16.5vw',
transform: 'scale(1.05)',
'& .quick-text': {
opacity: 1,
transform: 'translateX(0)',
},
},
'&:after': {
content: '""',
position: 'absolute',
left: '0%',
right: '0%',
bottom: 0,
height: '0.15vw',
borderRadius: '999px',
// background:
// 'linear-gradient(71deg, #F27121 0%, #E940CD 70%, #8A2387 100%)',
opacity: 0.9,
},
}}
>
<span className="quick-text">Быстрый запуск</span>
<span style={{ fontSize: '1vw' }}></span>
</Button>
</CustomTooltip>
)}
<Button
onClick={() => navigate('/voice')}
disableRipple
disableFocusRipple
sx={{
minWidth: 'unset',
width: '3vw',
height: '3vw',
borderRadius: '3vw',
display: 'flex',
alignItems: 'center',
justifyContent: 'flex-end',
overflow: 'hidden',
px: '0.8vw',
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)',
color: 'white',
backdropFilter: 'blur(14px)',
transition: 'all 0.3s ease',
'& .quick-text': {
opacity: 0,
whiteSpace: 'nowrap',
marginRight: '0.6vw',
fontSize: '0.9vw',
fontFamily: 'Benzin-Bold',
transform: 'translateX(10px)',
transition: 'all 0.25s ease',
},
'&:hover': {
width: '16.5vw',
transform: 'scale(1.05)',
'& .quick-text': {
opacity: 1,
transform: 'translateX(0)',
},
},
'&:after': {
content: '""',
position: 'absolute',
left: '0%',
right: '0%',
bottom: 0,
height: '0.15vw',
borderRadius: '999px',
// background:
// 'linear-gradient(71deg, #F27121 0%, #E940CD 70%, #8A2387 100%)',
opacity: 0.9,
},
}}
>
<span className="quick-text">Голосовой чат</span>
<span style={{ fontSize: '1vw' }}>🎙</span>
</Button>
{!isLoginPage && !isRegistrationPage && username && (
<Box sx={{ display: 'flex', alignItems: 'center', gap: '1vw' }}>
<HeadAvatar
skinUrl={skinUrl}
size={44}
version={skinVersion}
style={{
borderRadius: '3vw',
cursor: 'pointer',
}}
onClick={handleAvatarClick}
/>
</Box>
)}
{/* Кнопка регистрации, если на странице логина */}
{!isLoginPage && !isRegistrationPage && username && (
<CoinsDisplay
username={username}
size="medium"
autoUpdate={true}
showTooltip={true}
onClick={() => navigate('/fakepaymentpage')}
disableRefreshOnClick={true} // чтобы клик не дёргал fetchCoins
/>
)}
{/* Кнопки управления окном */}
<Button
onClick={() => {
window.electron.ipcRenderer.invoke('minimize-app');
}}
sx={{
minWidth: 'unset',
minHeight: 'unset',
width: '3em',
height: '3em',
borderRadius: '50%',
...theme.launcher.topbar.windowControlButton,
}}
>
<svg
className="MuiSvgIcon-root MuiSvgIcon-fontSizeMedium"
focusable="false"
aria-hidden="true"
viewBox="0 0 24 24"
>
<path
d="M 7 19 h 10 c 0.55 0 1 0.45 1 1 s -0.45 1 -1 1 H 7 c -0.55 0 -1 -0.45 -1 -1 s 0.45 -1 1 -1"
fill={theme.launcher.topbar.windowControlIcon.color}
></path>
</svg>
</Button>
<Button
onClick={() => {
window.electron.ipcRenderer.invoke('close-app');
}}
sx={{
minWidth: 'unset',
minHeight: 'unset',
width: '3em',
height: '3em',
borderRadius: '50%',
...theme.launcher.topbar.windowControlButton,
}}
>
<CloseRoundedIcon
sx={{ color: theme.launcher.topbar.windowControlIcon.color }}
/>
</Button>
</Box>
<Menu
anchorEl={avatarAnchorEl}
open={avatarMenuOpen}
onClose={handleAvatarMenuClose}
anchorOrigin={{
vertical: 'bottom',
horizontal: 'right',
}}
transformOrigin={{
vertical: 'top',
horizontal: 'right',
}}
PaperProps={{
sx: {
mt: '0.5vw',
borderRadius: '1vw',
minWidth: '16vw',
...theme.launcher.topbar.menuPaper,
},
}}
>
{/* ===== 1 строка: аватар + ник + валюта ===== */}
<Box
sx={{
display: 'grid',
gridTemplateColumns: 'auto 1fr',
gap: '1.5vw',
alignItems: 'center',
px: '2vw',
py: '0.8vw',
}}
>
<HeadAvatar
skinUrl={skinUrl}
size={40}
version={skinVersion}
style={{ borderRadius: '3vw' }}
/>
<Box sx={{ display: 'flex', flexDirection: 'column' }}>
<Typography
sx={[{ fontSize: '2vw' }, theme.launcher.topbar.menuUsername]}
>
{username || 'Игрок'}
</Typography>
<CoinsDisplay
username={username}
size="medium"
autoUpdate={true}
showTooltip={false}
sx={{
border: 'none',
padding: '0vw',
}}
backgroundColor={'rgba(0, 0, 0, 0)'}
/>
</Box>
</Box>
<Divider sx={{ my: '0.4vw', ...theme.launcher.topbar.menuDivider }} />
<MenuItem
onClick={() => {
handleAvatarMenuClose();
navigate('/profile');
}}
sx={[
{ fontSize: '1.5vw', gap: '0.5vw', py: '0.7vw' },
theme.launcher.topbar.menuItem,
]}
>
<PersonIcon sx={{ fontSize: '2vw' }} /> Профиль
</MenuItem>
<MenuItem
onClick={() => {
handleAvatarMenuClose();
navigate('/inventory');
}}
sx={[
{ fontSize: '1.5vw', gap: '0.5vw', py: '0.7vw' },
theme.launcher.topbar.menuItem,
]}
>
<InventoryIcon sx={{ fontSize: '2vw' }} /> Инвентарь
</MenuItem>
{/* ===== 2 строка: ежедневные задания ===== */}
<MenuItem
onClick={() => {
handleAvatarMenuClose();
navigate('/dailyquests');
}}
sx={[
{ fontSize: '1.5vw', gap: '0.5vw', py: '0.7vw' },
theme.launcher.topbar.menuItem,
]}
>
<CalendarMonthIcon sx={{ fontSize: '2vw' }} /> Ежедневные задания
</MenuItem>
{/* ===== 3 строка: ежедневная награда ===== */}
<MenuItem
onClick={() => {
handleAvatarMenuClose();
navigate('/daily');
}}
sx={[
{ fontSize: '1.5vw', gap: '0.5vw', py: '0.7vw' },
theme.launcher.topbar.menuItem,
]}
>
<EmojiEventsIcon sx={{ fontSize: '2vw' }} /> Ежедневная награда
</MenuItem>
<MenuItem
onClick={() => {
handleAvatarMenuClose();
navigate('/settings');
}}
sx={[
{ fontSize: '1.5vw', gap: '0.5vw', py: '0.7vw' },
theme.launcher.topbar.menuItem,
]}
>
<SettingsIcon sx={{ fontSize: '2vw' }} /> Настройки
</MenuItem>
<MenuItem
onClick={() => {
handleAvatarMenuClose();
navigate('/promocode');
}}
sx={[
{ fontSize: '1.5vw', gap: '0.5vw', py: '0.7vw' },
theme.launcher.topbar.menuItem,
]}
>
<RiCoupon3Fill style={{ fontSize: '2vw' }} /> Промокоды
</MenuItem>
<Divider sx={{ my: '0.4vw', ...theme.launcher.topbar.menuDivider }} />
{!isLoginPage && !isRegistrationPage && username && (
<Box sx={{ display: 'flex', justifyContent: 'center' }}>
<Button
variant="outlined"
color="primary"
onClick={() => {
handleAvatarMenuClose();
logout();
}}
sx={[
{
width: '90%',
height: '3vw',
fontSize: '1.2vw',
mx: '1vw',
},
theme.launcher.topbar.logoutButton,
]}
>
Выйти
</Button>
</Box>
)}
{/* ↓↓↓ дальше ты сам добавишь пункты ↓↓↓ */}
</Menu>
</Box>
);
}