add OnlinePlayersPanel in Profile
This commit is contained in:
72
src/renderer/components/HeadAvatar.tsx
Normal file
72
src/renderer/components/HeadAvatar.tsx
Normal 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',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
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>
|
||||||
|
);
|
||||||
|
};
|
||||||
@ -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">
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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)',
|
||||||
},
|
},
|
||||||
|
|||||||
@ -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>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|||||||
21
src/renderer/utils/serverTranslator.ts
Normal file
21
src/renderer/utils/serverTranslator.ts
Normal 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;
|
||||||
|
}
|
||||||
|
};
|
||||||
Reference in New Issue
Block a user