add OnlinePlayersPanel in Profile
This commit is contained in:
281
src/renderer/components/OnlinePlayersPanel.tsx
Normal file
281
src/renderer/components/OnlinePlayersPanel.tsx
Normal file
@ -0,0 +1,281 @@
|
||||
// src/renderer/components/OnlinePlayersPanel.tsx
|
||||
import { useEffect, useState, useMemo } from 'react';
|
||||
import {
|
||||
Box,
|
||||
Typography,
|
||||
Paper,
|
||||
Chip,
|
||||
TextField,
|
||||
MenuItem,
|
||||
Select,
|
||||
FormControl,
|
||||
InputLabel,
|
||||
} from '@mui/material';
|
||||
import {
|
||||
fetchActiveServers,
|
||||
fetchOnlinePlayers,
|
||||
fetchPlayer,
|
||||
Server,
|
||||
} from '../api';
|
||||
import { FullScreenLoader } from './FullScreenLoader';
|
||||
import { HeadAvatar } from './HeadAvatar';
|
||||
import { translateServer } from '../utils/serverTranslator';
|
||||
|
||||
type OnlinePlayerFlat = {
|
||||
username: string;
|
||||
uuid: string;
|
||||
serverId: string;
|
||||
serverName: string;
|
||||
onlineSince: string;
|
||||
};
|
||||
|
||||
interface OnlinePlayersPanelProps {
|
||||
currentUsername: string;
|
||||
}
|
||||
|
||||
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;
|
||||
|
||||
try {
|
||||
// Просто по очереди, чтобы не DDOS'ить API
|
||||
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);
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Ошибка при загрузке скинов:', e);
|
||||
}
|
||||
};
|
||||
|
||||
loadSkins();
|
||||
}, [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 color="error" sx={{ mt: 2 }}>
|
||||
{error}
|
||||
</Typography>
|
||||
);
|
||||
}
|
||||
|
||||
if (!onlinePlayers.length) {
|
||||
return (
|
||||
<Typography sx={{ mt: 2, opacity: 0.8 }}>
|
||||
Сейчас на серверах никого нет.
|
||||
</Typography>
|
||||
);
|
||||
}
|
||||
|
||||
const totalOnline = onlinePlayers.length;
|
||||
|
||||
return (
|
||||
<Paper
|
||||
sx={{
|
||||
mt: 3,
|
||||
p: 2,
|
||||
borderRadius: '1vw',
|
||||
background: 'rgba(255,255,255,0.03)',
|
||||
border: '1px solid rgba(255,255,255,0.06)',
|
||||
color: 'white',
|
||||
}}
|
||||
elevation={0}
|
||||
>
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
mb: 2,
|
||||
gap: 2,
|
||||
}}
|
||||
>
|
||||
<Box>
|
||||
<Typography
|
||||
sx={{
|
||||
fontFamily: 'Benzin-Bold',
|
||||
fontSize: '1.2vw',
|
||||
mb: 0.5,
|
||||
}}
|
||||
>
|
||||
Игроки онлайн
|
||||
</Typography>
|
||||
<Typography sx={{ fontSize: '0.9vw', opacity: 0.7 }}>
|
||||
Сейчас на серверах: {totalOnline}
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
<Box sx={{ display: 'flex', gap: 2, alignItems: 'center' }}>
|
||||
<FormControl size="small" sx={{ minWidth: 160 }}>
|
||||
<InputLabel sx={{ color: 'white' }}>Сервер</InputLabel>
|
||||
<Select
|
||||
label="Сервер"
|
||||
value={serverFilter}
|
||||
onChange={(e) => setServerFilter(e.target.value)}
|
||||
sx={{ color: 'white' }}
|
||||
>
|
||||
<MenuItem value="all" sx={{ color: 'black' }}>
|
||||
Все сервера
|
||||
</MenuItem>
|
||||
{servers.map((s) => (
|
||||
<MenuItem key={s.id} value={s.id} sx={{ color: 'black' }}>
|
||||
{s.name}
|
||||
</MenuItem>
|
||||
))}
|
||||
</Select>
|
||||
</FormControl>
|
||||
|
||||
<TextField
|
||||
size="small"
|
||||
label="Поиск по нику"
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
sx={{ color: 'white' }}
|
||||
/>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
<Box
|
||||
sx={{
|
||||
maxHeight: '35vh',
|
||||
overflowY: 'auto',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: 1,
|
||||
}}
|
||||
>
|
||||
{filteredPlayers.map((p) => (
|
||||
<Box
|
||||
key={p.uuid}
|
||||
sx={{
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
py: 0.6,
|
||||
px: 1.5,
|
||||
borderRadius: '999px',
|
||||
background: 'rgba(0,0,0,0.35)',
|
||||
}}
|
||||
>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
<HeadAvatar skinUrl={skinMap[p.uuid]} size={24} />
|
||||
<Typography sx={{ fontFamily: 'Benzin-Bold' }}>
|
||||
{p.username}
|
||||
</Typography>
|
||||
{p.username === currentUsername && (
|
||||
<Chip
|
||||
label="Вы"
|
||||
size="small"
|
||||
sx={{
|
||||
height: '1.4rem',
|
||||
fontSize: '0.7rem',
|
||||
bgcolor: 'rgb(255,77,77)',
|
||||
color: 'white',
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1.5 }}>
|
||||
<Chip
|
||||
label={translateServer({ name: p.serverName })}
|
||||
size="small"
|
||||
sx={{ bgcolor: 'rgba(255,255,255,0.08)', color: 'white' }}
|
||||
/>
|
||||
{/* Можно позже красиво форматировать onlineSince */}
|
||||
</Box>
|
||||
</Box>
|
||||
))}
|
||||
</Box>
|
||||
</Paper>
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user