Files
popa-launcher/src/renderer/components/OnlinePlayersPanel.tsx
2025-12-14 21:14:59 +05:00

454 lines
15 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

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.

// src/renderer/components/OnlinePlayersPanel.tsx
import { useEffect, useState, useMemo } from 'react';
import {
Box,
Typography,
Paper,
Chip,
MenuItem,
Select,
FormControl,
InputLabel,
TextField,
} from '@mui/material';
import {
fetchActiveServers,
fetchOnlinePlayers,
fetchPlayer,
Server,
} from '../api';
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;
uuid: string;
serverId: string;
serverName: string;
onlineSince: string;
};
interface OnlinePlayersPanelProps {
currentUsername: string;
}
const GRADIENT =
'linear-gradient(71deg, #F27121 0%, #E940CD 70%, #8A2387 100%)';
export const OnlinePlayersPanel: React.FC<OnlinePlayersPanelProps> = ({
currentUsername,
}) => {
const [servers, setServers] = useState<Server[]>([]);
const [onlinePlayers, setOnlinePlayers] = useState<OnlinePlayerFlat[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [serverFilter, setServerFilter] = useState<string>('all');
const [search, setSearch] = useState('');
const [skinMap, setSkinMap] = useState<Record<string, string>>({});
useEffect(() => {
const load = async () => {
try {
setLoading(true);
setError(null);
const activeServers = await fetchActiveServers();
setServers(activeServers);
const results = await Promise.all(
activeServers.map((s) => fetchOnlinePlayers(s.id)),
);
const flat: OnlinePlayerFlat[] = [];
results.forEach((res) => {
res.online_players.forEach((p) => {
flat.push({
username: p.username,
uuid: p.uuid,
serverId: res.server.id,
serverName: res.server.name,
onlineSince: p.online_since,
});
});
});
setOnlinePlayers(flat);
} catch (e: any) {
setError(e?.message || 'Не удалось загрузить онлайн игроков');
} finally {
setLoading(false);
}
};
load();
}, []);
// Догружаем скины по uuid
useEffect(() => {
const loadSkins = async () => {
const uuids = Array.from(new Set(onlinePlayers.map((p) => p.uuid)));
const toLoad = uuids.filter((uuid) => !skinMap[uuid]);
if (!toLoad.length) return;
for (const uuid of toLoad) {
try {
const player = await fetchPlayer(uuid);
if (player.skin_url) {
setSkinMap((prev) => ({ ...prev, [uuid]: player.skin_url }));
}
} catch (e) {
console.warn('Не удалось получить скин для', uuid, e);
}
}
};
loadSkins();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [onlinePlayers]);
const filteredPlayers = useMemo(() => {
return onlinePlayers
.filter((p) => (serverFilter === 'all' ? true : p.serverId === serverFilter))
.filter((p) =>
search.trim()
? p.username.toLowerCase().includes(search.toLowerCase())
: true,
)
.sort((a, b) => {
if (a.username === currentUsername && b.username !== currentUsername) return -1;
if (b.username === currentUsername && a.username !== currentUsername) return 1;
return a.username.localeCompare(b.username);
});
}, [onlinePlayers, serverFilter, search, currentUsername]);
if (loading) return <FullScreenLoader message="Загружаем игроков онлайн..." />;
if (error) {
return (
<Typography sx={{ mt: 2, color: '#ff8080', fontFamily: 'Benzin-Bold' }}>
{error}
</Typography>
);
}
if (!onlinePlayers.length) {
return (
<Typography sx={{ mt: 2, color: 'rgba(255,255,255,0.75)', fontWeight: 700 }}>
Сейчас на серверах никого нет.
</Typography>
);
}
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}
sx={{
mt: 3,
borderRadius: '1.2vw',
overflow: 'hidden',
background:
'radial-gradient(circle at 10% 10%, rgba(242,113,33,0.16), transparent 55%), radial-gradient(circle at 90% 20%, rgba(233,64,205,0.12), transparent 55%), rgba(10,10,20,0.92)',
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',
}}
>
{/* header */}
<Box
sx={{
px: '1.8vw',
pt: '1.2vw',
pb: '1.1vw',
position: 'sticky',
top: 0,
zIndex: 2,
background:
'radial-gradient(circle at 10% 10%, rgba(242,113,33,0.16), transparent 55%), radial-gradient(circle at 90% 20%, rgba(233,64,205,0.12), transparent 55%), rgba(10,10,20,0.92)',
backdropFilter: 'blur(14px)',
borderBottom: '1px solid rgba(255,255,255,0.08)',
}}
>
<Box
sx={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'flex-end',
gap: '1.6vw',
flexWrap: 'wrap',
}}
>
<Box sx={{ minWidth: 240 }}>
<Typography
sx={{
fontFamily: 'Benzin-Bold',
fontSize: '1.35vw',
lineHeight: 1.1,
backgroundImage: GRADIENT,
WebkitBackgroundClip: 'text',
WebkitTextFillColor: 'transparent',
}}
>
Игроки онлайн
</Typography>
<Typography sx={{ fontSize: '0.9vw', color: 'rgba(255,255,255,0.70)', fontWeight: 700 }}>
Сейчас на серверах: {totalOnline}
</Typography>
</Box>
<Box sx={{ display: 'flex', gap: '1vw', alignItems: 'center', flexWrap: 'wrap' }}>
{/* Select в “нашем” стиле */}
<FormControl
size="small"
sx={controlSx}
>
<InputLabel>Сервер</InputLabel>
<Select
label="Сервер"
value={serverFilter}
onChange={(e) => setServerFilter(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={{
color: 'white',
fontFamily: 'Benzin-Bold',
borderRadius: '999px',
bgcolor: 'rgba(255,255,255,0.04)',
'& .MuiSelect-select': {
py: '0.7vw',
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="all">Все сервера</MenuItem>
{servers.map((s) => (
<MenuItem key={s.id} value={s.id}>
{translateServer(s.name)}
</MenuItem>
))}
</Select>
</FormControl>
{/* Поиск через ваш GradientTextField */}
<Box sx={{ minWidth: '16vw' }}>
<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={{
'& .MuiInputBase-input': {
padding: 'none',
fontFamily: 'none',
},
'& .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>
</Box>
{/* list */}
<Box
sx={{
px: '1.8vw',
py: '1.3vw',
maxHeight: '35vh',
overflowY: 'auto',
display: 'flex',
flexDirection: 'column',
gap: '0.65vw',
// аккуратный скроллбар (webkit)
'&::-webkit-scrollbar': { width: '0.55vw' },
'&::-webkit-scrollbar-thumb': {
borderRadius: '999px',
background: 'rgba(255,255,255,0.12)',
},
'&::-webkit-scrollbar-thumb:hover': {
background: 'rgba(242,113,33,0.25)',
},
}}
>
{filteredPlayers.map((p) => {
const isMe = p.username === currentUsername;
return (
<Paper
key={p.uuid}
elevation={0}
sx={{
px: '1.1vw',
py: '0.75vw',
borderRadius: '1.1vw',
background: 'rgba(255,255,255,0.04)',
border: '1px solid rgba(255,255,255,0.08)',
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
gap: '1vw',
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 0.8vw 2.4vw rgba(0,0,0,0.45)',
},
}}
>
<Box sx={{ display: 'flex', alignItems: 'center', gap: '0.8vw', minWidth: 0 }}>
<HeadAvatar skinUrl={skinMap[p.uuid]} size={26} />
<Typography
sx={{
fontFamily: 'Benzin-Bold',
color: 'rgba(255,255,255,0.92)',
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
}}
>
{p.username}
</Typography>
{isMe && (
<Chip
label="Вы"
size="small"
sx={{
height: '1.55rem',
fontSize: '0.72rem',
fontWeight: 900,
color: 'white',
borderRadius: '999px',
backgroundImage: GRADIENT,
boxShadow: '0 10px 22px rgba(0,0,0,0.45)',
}}
/>
)}
</Box>
<Box sx={{ display: 'flex', alignItems: 'center', gap: '0.6vw', flexShrink: 0 }}>
<Chip
label={translateServer(p.serverName)}
size="small"
sx={{
fontFamily: 'Benzin-Bold',
fontSize: '0.72rem',
borderRadius: '999px',
color: 'rgba(255,255,255,0.88)',
background:
'linear-gradient(120deg, rgba(242,113,33,0.18), rgba(233,64,205,0.12), rgba(138,35,135,0.16))',
border: '1px solid rgba(255,255,255,0.10)',
backdropFilter: 'blur(12px)',
}}
/>
{/* onlineSince можно потом красиво форматировать */}
</Box>
</Paper>
);
})}
</Box>
</Paper>
);
};