2025-12-16 08:40:32 +09:00
|
|
|
import React, { useState, useEffect, useRef } from 'react';
|
|
|
|
|
import { Link } from 'react-router-dom';
|
|
|
|
|
import { Users, Clock, Circle, ServerOff } from 'lucide-react';
|
|
|
|
|
import { motion } from 'framer-motion';
|
|
|
|
|
import { io } from 'socket.io-client';
|
|
|
|
|
import { formatPlayTimeMs } from '../utils/formatters';
|
|
|
|
|
|
|
|
|
|
// 스티브 머리 기본 이미지 (로딩 전/실패 시 사용)
|
|
|
|
|
const STEVE_HEAD_BASE64 = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAADAAAAAwCAYAAABXAvmHAAAACXBIWXMAAA7EAAAOxAGVKw4bAAABWUlEQVRoge2ZPUsDQRCGk7DeGU1ysTDYpIuFjYUasMpHaZFKhJDWxj7gjwjYK/Y2wcJKsAppxEYkTcCU1oGcubt8gv6Bd4oBk3FhnvLZvb17GW7YvYsfF3Z+YgxSjuFMZxPMl9BPCZ9Y5cOsAw0gjQaQxvjBBA54qST0VJfgdqe/Wsf6CmgAaTSANMZ1NuDAVfkU+r3cLl5oKw39MhpDP5ktoPf9EfR33Tfora+ABpBGA0gTv788gycyz8vCC5Iu7loUVLfhQnUn6yugAaTRANIYqttc3DxAf15qQV8vfkBPdY/nzwr0j91r6NvNBvTWV0ADSKMBpGHvhSg6733W/MrRAWu+7oX+KxpAGusDmDCK4ECwCKF/ee1Bf5jfZ934tv0Efa16Av13iE921ldAA0ijAaQxxsU/6odj/NbPpnPoe18D1nx304Ge6jaZbfw9yvoKaABpNIA0v2NsVwyhlV0PAAAAAElFTkSuQmCC';
|
|
|
|
|
|
2025-12-22 11:42:37 +09:00
|
|
|
// 플레이어 아바타 컴포넌트 - 스킨 캐싱 API 사용
|
2025-12-16 08:40:32 +09:00
|
|
|
const PlayerAvatar = ({ uuid, name }) => {
|
|
|
|
|
const [src, setSrc] = useState(STEVE_HEAD_BASE64);
|
|
|
|
|
|
|
|
|
|
useEffect(() => {
|
2025-12-22 11:42:37 +09:00
|
|
|
// 스킨 캐싱 API 호출 (avatar/uuid/size)
|
|
|
|
|
fetch(`/link/skin/avatar/${uuid}/48`)
|
|
|
|
|
.then(res => res.json())
|
|
|
|
|
.then(data => {
|
|
|
|
|
if (data.url) {
|
|
|
|
|
const img = new Image();
|
|
|
|
|
img.onload = () => setSrc(data.url);
|
|
|
|
|
img.onerror = () => setSrc(`https://mc-heads.net/avatar/${uuid}/48`);
|
|
|
|
|
img.src = data.url;
|
|
|
|
|
}
|
|
|
|
|
})
|
|
|
|
|
.catch(() => {
|
|
|
|
|
// 폴백: mc-heads 직접 사용
|
|
|
|
|
setSrc(`https://mc-heads.net/avatar/${uuid}/48`);
|
|
|
|
|
});
|
2025-12-16 08:40:32 +09:00
|
|
|
}, [uuid]);
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
<img
|
|
|
|
|
src={src}
|
|
|
|
|
alt={name}
|
|
|
|
|
className="w-12 h-12 rounded-lg"
|
|
|
|
|
/>
|
|
|
|
|
);
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
// 전체 플레이어 목록 페이지
|
|
|
|
|
const PlayersPage = ({ isMobile = false }) => {
|
|
|
|
|
const [players, setPlayers] = useState([]);
|
|
|
|
|
const [loading, setLoading] = useState(true);
|
|
|
|
|
const [serverOnline, setServerOnline] = useState(null);
|
|
|
|
|
const socketRef = useRef(null);
|
|
|
|
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
// 소켓 연결
|
|
|
|
|
const socket = io('/', {
|
|
|
|
|
path: '/socket.io',
|
|
|
|
|
transports: ['websocket', 'polling']
|
|
|
|
|
});
|
|
|
|
|
socketRef.current = socket;
|
|
|
|
|
|
|
|
|
|
socket.on('status', (data) => {
|
|
|
|
|
setServerOnline(data.online);
|
|
|
|
|
if (!data.online) {
|
|
|
|
|
setLoading(false);
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
socket.on('players', (data) => {
|
|
|
|
|
setPlayers(data);
|
|
|
|
|
setLoading(false);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// 1초마다 갱신
|
|
|
|
|
const interval = setInterval(() => {
|
|
|
|
|
socket.emit('get_players');
|
|
|
|
|
}, 1000);
|
|
|
|
|
|
|
|
|
|
// 초기 요청
|
|
|
|
|
socket.emit('get_players');
|
|
|
|
|
|
|
|
|
|
return () => {
|
|
|
|
|
clearInterval(interval);
|
|
|
|
|
socket.disconnect();
|
|
|
|
|
};
|
|
|
|
|
}, []);
|
|
|
|
|
|
|
|
|
|
// 온라인 플레이어 먼저, 그 다음 이름순 정렬
|
|
|
|
|
const sortedPlayers = [...players].sort((a, b) => {
|
|
|
|
|
if (a.isOnline !== b.isOnline) return b.isOnline ? 1 : -1;
|
|
|
|
|
return a.name.localeCompare(b.name, 'ko');
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
const onlineCount = players.filter(p => p.isOnline).length;
|
|
|
|
|
|
|
|
|
|
// 서버 오프라인 상태 - 전체 화면 가운데 정렬
|
|
|
|
|
if (serverOnline === false) {
|
|
|
|
|
return (
|
|
|
|
|
<div className="min-h-screen flex items-center justify-center">
|
|
|
|
|
<motion.div
|
|
|
|
|
initial={{ opacity: 0, y: 20 }}
|
|
|
|
|
animate={{ opacity: 1, y: 0 }}
|
|
|
|
|
className="text-center p-8"
|
|
|
|
|
>
|
|
|
|
|
<div className="w-20 h-20 mx-auto mb-6 rounded-2xl bg-gradient-to-br from-red-500/20 to-orange-500/20 border border-red-500/30 flex items-center justify-center">
|
|
|
|
|
<ServerOff className="w-10 h-10 text-red-400" />
|
|
|
|
|
</div>
|
|
|
|
|
<h2 className="text-2xl font-bold text-white mb-2">서버 오프라인</h2>
|
|
|
|
|
<p className="text-gray-400">
|
|
|
|
|
마인크래프트 서버가 현재 오프라인 상태입니다.<br />
|
|
|
|
|
서버가 시작되면 플레이어 정보를 확인할 수 있습니다.
|
|
|
|
|
</p>
|
|
|
|
|
</motion.div>
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
<div className="min-h-screen p-4 md:p-6 lg:p-8">
|
|
|
|
|
<div className="max-w-4xl mx-auto">
|
|
|
|
|
{/* 헤더 */}
|
|
|
|
|
<div className="mb-8">
|
|
|
|
|
<h1 className="text-3xl md:text-4xl font-bold text-white flex items-center gap-3">
|
|
|
|
|
<Users className="text-mc-diamond" />
|
|
|
|
|
플레이어
|
|
|
|
|
</h1>
|
|
|
|
|
<p className="text-gray-400 mt-2">
|
|
|
|
|
전체 플레이어 <span className="text-white font-bold">{players.length}</span>명
|
|
|
|
|
<span className="mx-2">•</span>
|
|
|
|
|
<span className="text-mc-green">온라인 {onlineCount}명</span>
|
|
|
|
|
</p>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
{/* 플레이어 목록 */}
|
|
|
|
|
{loading ? (
|
|
|
|
|
<div className="flex items-center justify-center py-20">
|
|
|
|
|
<div className="w-12 h-12 border-4 border-mc-green/30 border-t-mc-green rounded-full animate-spin" />
|
|
|
|
|
</div>
|
|
|
|
|
) : sortedPlayers.length > 0 ? (
|
|
|
|
|
<div className="grid gap-3">
|
|
|
|
|
{sortedPlayers.map((player, index) => (
|
|
|
|
|
<motion.div
|
|
|
|
|
key={player.uuid}
|
|
|
|
|
initial={{ opacity: 0, x: -20 }}
|
|
|
|
|
animate={{ opacity: 1, x: 0 }}
|
|
|
|
|
transition={{ delay: index * 0.03 }}
|
|
|
|
|
>
|
|
|
|
|
<Link
|
|
|
|
|
to={`/player/${player.uuid}/stats`}
|
|
|
|
|
className="glow-card rounded-xl p-4 flex items-center gap-4 group hover:bg-white/5 transition-colors"
|
|
|
|
|
>
|
|
|
|
|
{/* 플레이어 아바타 */}
|
|
|
|
|
<div className="relative shrink-0">
|
|
|
|
|
<PlayerAvatar uuid={player.uuid} name={player.name} />
|
|
|
|
|
{/* 온라인 상태 표시 */}
|
|
|
|
|
<div className={`absolute -bottom-1 -right-1 w-4 h-4 rounded-full border-2 border-mc-dark ${
|
|
|
|
|
player.isOnline ? 'bg-mc-green' : 'bg-gray-500'
|
|
|
|
|
}`} />
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
{/* 플레이어 정보 */}
|
|
|
|
|
<div className="flex-1 min-w-0">
|
|
|
|
|
<div className="flex items-center gap-2 flex-wrap">
|
|
|
|
|
<h3 className="text-white font-bold truncate">{player.name}</h3>
|
|
|
|
|
<span className={`text-xs px-2 py-0.5 rounded-full ${
|
|
|
|
|
player.isOnline
|
|
|
|
|
? 'bg-mc-green/20 text-mc-green'
|
|
|
|
|
: 'bg-gray-500/20 text-gray-400'
|
|
|
|
|
}`}>
|
|
|
|
|
{player.isOnline ? '온라인' : '오프라인'}
|
|
|
|
|
</span>
|
|
|
|
|
</div>
|
|
|
|
|
<div className="flex items-center gap-1 text-gray-400 text-sm mt-1">
|
|
|
|
|
<Clock size={14} />
|
|
|
|
|
<span>{formatPlayTimeMs(player.totalPlayTimeMs)}</span>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
{/* 통계 보기 안내 */}
|
|
|
|
|
<div className="text-gray-500 group-hover:text-mc-green transition-colors">
|
|
|
|
|
<span className="text-sm hidden md:block">통계 보기 →</span>
|
|
|
|
|
</div>
|
|
|
|
|
</Link>
|
|
|
|
|
</motion.div>
|
|
|
|
|
))}
|
|
|
|
|
</div>
|
|
|
|
|
) : (
|
|
|
|
|
<div className="text-center py-20 text-gray-400">
|
|
|
|
|
<Users size={48} className="mx-auto mb-4 opacity-50" />
|
|
|
|
|
<p>등록된 플레이어가 없습니다.</p>
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
export default PlayersPage;
|