454 lines
15 KiB
TypeScript
454 lines
15 KiB
TypeScript
// 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>
|
||
);
|
||
};
|