add OnlinePlayersPanel in Profile

This commit is contained in:
2025-12-06 21:08:26 +05:00
parent 6a7169e2ae
commit 5efeb9a5c1
7 changed files with 379 additions and 5 deletions

View File

@ -0,0 +1,72 @@
// src/renderer/components/HeadAvatar.tsx
import { useEffect, useRef } from 'react';
interface HeadAvatarProps {
skinUrl?: string;
size?: number; // финальный размер головы, px
}
export const HeadAvatar: React.FC<HeadAvatarProps> = ({
skinUrl,
size = 24,
}) => {
const canvasRef = useRef<HTMLCanvasElement>(null);
useEffect(() => {
if (!skinUrl || !canvasRef.current) return;
const img = new Image();
img.crossOrigin = 'anonymous'; // на всякий случай, если CDN
img.src = skinUrl;
img.onload = () => {
const canvas = canvasRef.current;
if (!canvas) return;
const ctx = canvas.getContext('2d');
if (!ctx) return;
canvas.width = size;
canvas.height = size;
ctx.clearRect(0, 0, size, size);
// Координаты головы в стандартном скине 64x64:
// База головы: (8, 8, 8, 8)
// Слой шляпы/маски: (40, 8, 8, 8)
// Рисуем основную голову
ctx.imageSmoothingEnabled = false;
ctx.drawImage(
img,
8, // sx
8, // sy
8, // sWidth
8, // sHeight
0, // dx
0, // dy
size, // dWidth
size, // dHeight
);
// Рисуем слой шляпы поверх (если есть)
ctx.drawImage(img, 40, 8, 8, 8, 0, 0, size, size);
};
img.onerror = (e) => {
console.error('Не удалось загрузить скин для HeadAvatar:', e);
};
}, [skinUrl, size]);
return (
<canvas
ref={canvasRef}
style={{
width: size,
height: size,
borderRadius: 4,
imageRendering: 'pixelated',
}}
/>
);
};

View 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>
);
};

View File

@ -260,7 +260,7 @@ export default function PlayerInventory({
p: '1vw', p: '1vw',
imageRendering: 'pixelated', imageRendering: 'pixelated',
}} }}
image={`/minecraft/${item.material.toLowerCase()}.png`} image={`https://cdn.minecraft.popa-popa.ru/textures/${item.material.toLowerCase()}.png`}
alt={item.material} alt={item.material}
/> />
<CardContent sx={{ p: 1 }}> <CardContent sx={{ p: 1 }}>
@ -312,7 +312,7 @@ export default function PlayerInventory({
objectFit: 'contain', objectFit: 'contain',
mr: 2, mr: 2,
}} }}
image={`/items/${selectedItem.material.toLowerCase()}.png`} image={`https://cdn.minecraft.popa-popa.ru/textures/${selectedItem.material.toLowerCase()}.png`}
alt={selectedItem.material} alt={selectedItem.material}
/> />
<Typography variant="h6"> <Typography variant="h6">

View File

@ -386,11 +386,10 @@ export default function Marketplace() {
minHeight: '10vw', minHeight: '10vw',
maxHeight: '10vw', maxHeight: '10vw',
objectFit: 'contain', objectFit: 'contain',
bgcolor: 'white',
p: '1vw', p: '1vw',
imageRendering: 'pixelated', imageRendering: 'pixelated',
}} }}
image={`/minecraft/${item.material.toLowerCase()}.png`} image={`https://cdn.minecraft.popa-popa.ru/textures/${item.material.toLowerCase()}.png`}
alt={item.material} alt={item.material}
/> />
<CardContent> <CardContent>

View File

@ -384,7 +384,6 @@ export const News = () => {
transition: transition:
'transform 0.25s ease, box-shadow 0.25s.ease, border-color 0.25s ease', 'transform 0.25s ease, box-shadow 0.25s.ease, border-color 0.25s ease',
'&:hover': { '&:hover': {
transform: 'translateY(-4px)',
boxShadow: '0 24px 60px rgba(0, 0, 0, 0.9)', boxShadow: '0 24px 60px rgba(0, 0, 0, 0.9)',
borderColor: 'rgba(242,113,33,0.5)', borderColor: 'rgba(242,113,33,0.5)',
}, },

View File

@ -23,6 +23,7 @@ import {
import CapeCard from '../components/CapeCard'; import CapeCard from '../components/CapeCard';
import { FullScreenLoader } from '../components/FullScreenLoader'; import { FullScreenLoader } from '../components/FullScreenLoader';
import { OnlinePlayersPanel } from '../components/OnlinePlayersPanel';
export default function Profile() { export default function Profile() {
const fileInputRef = useRef<HTMLInputElement>(null); const fileInputRef = useRef<HTMLInputElement>(null);
@ -449,6 +450,7 @@ export default function Profile() {
))} ))}
</Box> </Box>
</Box> </Box>
<OnlinePlayersPanel currentUsername={username} />
</Box> </Box>
</> </>
)} )}

View File

@ -0,0 +1,21 @@
// src/renderer/utils/serverTranslator.ts
import { Server } from '../api';
type ServerLike = Pick<Server, 'name'> | { name: string };
export const translateServer = (
server: ServerLike | null | undefined,
): string => {
if (!server?.name) return '';
switch (server.name) {
case 'Server minecraft.hub.popa-popa.ru':
return 'Хаб';
case 'Server survival.hub.popa-popa.ru':
return 'Выживание';
case 'Server minecraft.minigames.popa-popa.ru':
return 'Миниигры';
default:
return server.name;
}
};