2025-12-16 08:40:32 +09:00
|
|
|
import React, { useState, useEffect, useRef } from 'react';
|
|
|
|
|
import { useParams } from 'react-router-dom';
|
2025-12-24 16:20:36 +09:00
|
|
|
import { Activity, Skull, Heart, LocateFixed, Box, Sword, Clock, Calendar, RefreshCw, ImageOff } from 'lucide-react';
|
2025-12-16 08:40:32 +09:00
|
|
|
import { motion } from 'framer-motion';
|
|
|
|
|
import { io } from 'socket.io-client';
|
|
|
|
|
import { formatDate, formatPlayTimeMs } from '../utils/formatters';
|
2025-12-24 16:30:53 +09:00
|
|
|
import Tooltip from '../components/Tooltip';
|
2025-12-16 08:40:32 +09:00
|
|
|
|
|
|
|
|
|
|
|
|
|
// 스티브 3D 스킨 기본 이미지 (로딩 전/실패 시 사용)
|
|
|
|
|
const STEVE_BODY_BASE64 = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAFAAAADACAYAAACTd+TuAAAACXBIWXMAAA7EAAAOxAGVKw4bAAAgAElEQVR4nO2deawd133fP2eZmXvv2/get0eKmzZqsSTLsmLTduzIrmM0rhO4qZMUcmqkjZu2SfqHawRFEKSCEwT5yyiKpkmQKqnhRkZqp4mARoZj2DGR2opsSd4iidook5RIkY/rW+42M+ec/nFm7rvvvXtn5r6FpNx8Af7Bd89s3znnt5/fCOcc/4D1Q17rG3ij4x8I3CD+gcANQl/rGxiEuw5OSuBjwHuzP30N+OwzJxfstburwRDXkxK5+9DkIef4ReDngUOrfj4B/Cnwx8+cXDhxde9sOK4LAu+/deYQ8BDwC85BaizGFk62zwCfuh6IvGYE3n/rjAb24on7V6t/r0jknwCfAs48c3Ih3ZIbLcFVJfD+W2ckXu7+LPCbwOEqxxnrSI2l4F5fBH5bSfH5QKv0qZcuXTVZeVUIzJTCXiHEQ1qKjyu1VvlLIdBS4BwkQ2adc47UuIGzUgiBVhIlxcNks/JqELmlBGbEzeIf6OO9iwrQUqKURApBqCRait5xzkFsbCUi+4hbPexhIXhoLNBnjz47t2VEbgmBdx2cDIGfxpshvzRojJICJSW1QKGlQIqVBHQSQ2otqXUoKRCrfjfG4vrOtfr3QEmUn9V/hDeD/uLos3PxpjxgHzaNwPtvnQmT1MxY6z7h4IPAXWsuJkBJOfCBtRQIvLzrpgZjV96XkpL8EGMtq29bSYlWgkgrgr6xfXgG+CLwn4FLm0Xmhgm8/9aZBvAh4DeAewBsJvTtkHMrKXrLdxBWKw1PTj+BK3/3S3j590BKQjWQxBzfB34nsfavvnHsQmvkh+7Dugm86+DkLgEPKSV/WQ9QCkL4B0mMpROnDLpKvywcFc45jHW9Wb0aSgiCVbK1H8Y5YmMx1v0+8KmnXro0N/JNsA4C7zo4uQOvFH65/+9aLc+SUEmCvodyztGKDZ1k40R6BWJXLHGvSLxMHaSU+tFH3Oqfft8699C3X758ofQm+u+9CoGZUvgQXin86qAxSgpkn1JQq9ZPYv1NdxKDtW7N8lZSIqUYpE0BrxS8jLR0krUyMr+HUClqgVpznthYTDZrByEXOda638Mrnb965uRCqZwsJDAj7neA9wH3DRqjlX/w1fKsX7OmAwgz1vXJuLVKBZbFwCCl0EkMibF0UzPw3j2J/t7SPo3dj4wwUjPQyvk28DfAbxQRWUbgOLA4dEAGKb0tJoXXpH4myBUEJtkMAD+LUrOSwCKlorPz5SSnxhL3aeKBSiWbsYOQGjuMtEGYeObkwtKwHzclnGWtI7YGIaAeaCKlVswYPxslsfHLb/XNG+sw2fGDZKF/AelQpeFtSlV6n4PkZw7ZZ0aNgk2NBzoHrTilnaTUA0090Di80E7tytlhBswC5yAxltTaHpGDHjoVrqc0RoEQgkArdN85pRTobKkbYzEDF/twbJhArWRPluToERmnfmmukpGC3KgWA994TmQyZJk550hSRypcNvtWylDBstIBSFbJYCkEY1GAda73Ytf9/Bs5WAhPINDTrP2zyuHlDYYVJPYrkI0g94lT41+GVtJ7IqvkX6CyAEV2SZ39nlhbSOAg5bgam7aEpRRI/EMMEtLWOgbrwhWRFJzLlczy8VJ4JZP/nlqLGSJHU2O9HA7UShJHWO79SrEMIxMopVixXAeeNDOqy7TdoEhKPqu1kl5pZNfs/z33MFLjsHblazHWsdRN6CSGeqiIdLlyAXrXCaWqRFzvWSuPzC4SZjc0TJv1Q0nBajPN36h3/AfafngCwz7bL7VeETnnemR5hSAA2TNj/BoV1ANFPVADz7/yWqInL0Pl3c5udfMG2MAS9jMH4tT4YGhFf9Y6hxYMNZy1lARSrDCDlBQETmAcA2WWloJ6EPSCsmVweCUlhWAs3JgUKzxaQKlSz2VbaqyXU0KsWHL9cM4RJylxkqK1QktJ1PcAy9p3ObwFkDq3JnwFXjYGmbyqsuhMpnVzzySQw33mqigkMAp1z0RZLbQH3qCxGOjZVkUrKE0NRlgQy+GqHNY52rFZYTgPmuEyk4dVkb+gKihb/jlK568UAqlET6irTIkUzcx+z8Rmxuow9GffpJSwyuFf/t0NC91vCiKtcRjirfREtBTUMyUSG0tc8jatdSSpzzZqpRAlRA6a5QIyE6Z4Rq8HSgqiQHPnnllu37OLY2fP842XT4x0jnVL0DzmZ60jtbbUtEmNgcGBk6GQQhAG1cwQ47whX8Xe01Kyf8cM9x24gZ1j9dFuavW5in50jtK3nttOVYnsx0YnlGNlgFRLQVDAn5KSG3dOc+TWmziwY4Y4jmm3h0f0tZQEqvgFFhLYTVIvA7NwU9EDSykIhKJrqxcICCEItcI6tyLLVhXGOtq2fFrXw5C33nyI3VOTzG7bVjq+FgS8Zf9edk2OMTs5UTi2dAlb57AmM1OkRImUSOtK8khk5BfNytwFVFJincVkccLVR+S+c2XtqBQ3zExz697d3HHDXqJAY4wlHhKAFUJww7ZJ3n3rIW7bvbNypGckGZhay8XFLqFWTNTDnldShEBrbwZZU0ikEKCEN85zf9g6l9mOhm6SoqRgrB4VP5BS3LBjO++48w4O7pzBdDuF44UQKKWJooharcZ0YzSZuC4lEqeGi4ttQq2IwgAhROGMlFIgpcY577uWRWNyfzg1loVmPwHDL9KIQvbMTPPAPXdx4+xuAJxJCp9Da0WjMYbWnoY4HlHLscFoTGIMIvVTPc+NFEFKST0KerNqhLD6QGglqQchb735IG+75RBj9QYqqj6DpFQUrVQvLq5SOCs1FmFZVgoDqgf6bywKNXUhiJTEOJ8zqapEaoHmTXtnObhzO/fceKB0vDfGzdBE/6D709ova6WKKRo5GiMEQ4mBlXFBky3XItmnpEAhfBLeFhvnM2MNjtxykEM7pqkHAVEUFt6vMY4kNSNoeEEQBJWIyzEygWO1iNRaunFKWajB18HQsxGLhuch/kHGtg+oKraN1bljz67S+3QmxaYxJkkqiYna5DZuf+9PcunUcU595/HS8f0YeQmrLCkUBpokSaliN+fGdh5pHpYHXg0f89OlsrUHazHdFs6UK4N2EjB7y2Fuffs7OXDfu1BByMtf//LmEli0XAXeROkmo1XWNttdb0AHinoYZCHQ4devTB7gnC10F1MriVPFC+d2cnxuB+/d/xY+8Pb3Dh3vXHnupjicFeielzBqvrQIzjm6cZpp4pBGqCsHZD0EWiuisNgm7L+eMSnH56b5zulbKo1P05Rut4Mpmc3VwllaoZzDmM0vxkyMZb4dEyhfV1OMXDvW0FqXhracgzRN6Ha7pGmKMcVumcORJEkl4nJUloGeyNHcf5+/tVnRUPEMy/PAeSqxf3wjDLhn/x72b59hbGy8wnW92dKNY5rNZun49vwlnv+b/8PZ48dotVaO/8QHPvYH//rRR78G/IX78IevrD52y3cq5b60MZbxWsj2mToXlpo048FeQp4iMNayfazBfYdv4NCOaRph2PMYBqEVa8ChZYzNzBZbsEVCK8v8yb/nyT87wavf/TtMEhPH3TXjUql+Hvh5nPuE+PznnyJJftt99KOv9M5TmYkNwucgFD/55tvoJCnPnpnjudfPkzK4Oso5HxW5c+/uoedMrSTuBrzw+gwvnJ1m58QiP3rLicL70MoyO7nI7bNz7IianHiywp2nKSTJXVh7F/AL4pFHPgt8yn30o68UEtjuxGitCiun1oNaoHnrwb38yI37uNDq8NSJ1zh9ZaGya6eCkB033sZS7U7++I9fqHRMkhq21Rd54PBxdoyXL2tjLXGa4rpdBuzJ/BjwMfHII58tJDBOTS/8EwaaKBjBJquI/TPb2D+zjVOXrvDN46c4fWVh6FgVhOw+fDe3ve8n2X3rXTz1+HFgOIHWep/7/HyTC4stdk13uX1HMHx85oIudTq04hgHuDACISFJ/LJYadZ8rPISjpOUJEmpRWGvoqAMzkGSpI
|
|
|
|
|
|
2025-12-22 11:42:37 +09:00
|
|
|
// 플레이어 3D 스킨 컴포넌트 - 스킨 캐싱 API 사용
|
2025-12-16 08:40:32 +09:00
|
|
|
const PlayerSkinImage = ({ uuid, playerName }) => {
|
|
|
|
|
const [src, setSrc] = useState(STEVE_BODY_BASE64);
|
|
|
|
|
|
|
|
|
|
useEffect(() => {
|
2025-12-22 11:42:37 +09:00
|
|
|
// 스킨 캐싱 API 호출 (body/uuid/size)
|
|
|
|
|
fetch(`/link/skin/body/${uuid}/80`)
|
|
|
|
|
.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/body/${uuid}/80`);
|
|
|
|
|
img.src = data.url;
|
|
|
|
|
}
|
|
|
|
|
})
|
|
|
|
|
.catch(() => {
|
|
|
|
|
// 폴백: mc-heads 직접 사용
|
|
|
|
|
setSrc(`https://mc-heads.net/body/${uuid}/80`);
|
|
|
|
|
});
|
2025-12-16 08:40:32 +09:00
|
|
|
}, [uuid]);
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
<img
|
|
|
|
|
src={src}
|
|
|
|
|
alt={playerName}
|
|
|
|
|
className="h-20 w-auto"
|
|
|
|
|
/>
|
|
|
|
|
);
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
// 아이템/몹 아이콘 기본 이미지 (로딩 실패 시 사용)
|
|
|
|
|
const DEFAULT_ICON = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAACAAAAAgCAYAAABzenr0AAAAAXNSR0IArs4c6QAAAARnQU1BAACxjwv8YQUAAAAJcEhZcwAADsMAAA7DAcdvqGQAAABhSURBVFhH7c4xDQAgDATAH/+fxsaCj8FqJndzCSFJkiRJkvRnvW+v+wGAzwfgM/4MAH7HD/Bh/BkA/Iof4MP4fgDwd/wZAPwaP8CH8f0A4O/4MwD4NT6AD+MbAPr9ALgBj+YLdQAAAABJRU5ErkJggg==';
|
|
|
|
|
|
|
|
|
|
// 거리 포맷 함수 - 1만 이상이면 "1.2만m" 형식으로 표시
|
|
|
|
|
const formatDistance = (meters) => {
|
|
|
|
|
if (meters >= 100000000) {
|
|
|
|
|
// 1억 이상
|
|
|
|
|
return `${(meters / 100000000).toFixed(1)}억m`;
|
|
|
|
|
} else if (meters >= 10000000) {
|
|
|
|
|
// 천만 이상
|
|
|
|
|
return `${(meters / 10000000).toFixed(1)}천만m`;
|
|
|
|
|
} else if (meters >= 10000) {
|
|
|
|
|
// 만 이상
|
|
|
|
|
return `${(meters / 10000).toFixed(1)}만m`;
|
|
|
|
|
}
|
|
|
|
|
return `${meters.toLocaleString()}m`;
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
// 플레이어 통계 페이지
|
|
|
|
|
const PlayerStatsPage = ({ isMobile = false }) => {
|
|
|
|
|
const { uuid } = useParams();
|
|
|
|
|
const [playerName, setPlayerName] = useState('');
|
|
|
|
|
const [playerDetail, setPlayerDetail] = useState(null);
|
|
|
|
|
const [stats, setStats] = useState(null);
|
|
|
|
|
const [translations, setTranslations] = useState({});
|
|
|
|
|
const [icons, setIcons] = useState({}); // 아이콘 캐시
|
|
|
|
|
const [loading, setLoading] = useState(true);
|
|
|
|
|
const socketRef = useRef(null);
|
|
|
|
|
|
|
|
|
|
// 번역 및 아이콘 데이터 로드
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
Promise.all([
|
|
|
|
|
fetch('/api/translations').then(res => res.json()),
|
|
|
|
|
fetch('/api/icons').then(res => res.json())
|
|
|
|
|
])
|
|
|
|
|
.then(([transData, iconsData]) => {
|
|
|
|
|
setTranslations(transData);
|
|
|
|
|
setIcons(iconsData);
|
|
|
|
|
})
|
|
|
|
|
.catch(err => console.error('데이터 로드 실패:', err));
|
|
|
|
|
}, []);
|
|
|
|
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
const socket = io('/', {
|
|
|
|
|
path: '/socket.io',
|
|
|
|
|
transports: ['websocket', 'polling']
|
|
|
|
|
});
|
|
|
|
|
socketRef.current = socket;
|
|
|
|
|
|
|
|
|
|
socket.on('player_stats', (data) => {
|
|
|
|
|
setStats(data);
|
|
|
|
|
setLoading(false);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
socket.on('player_detail', (data) => {
|
|
|
|
|
if (data) {
|
|
|
|
|
setPlayerName(data.name);
|
|
|
|
|
setPlayerDetail(data);
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
socket.emit('get_player', uuid);
|
|
|
|
|
socket.emit('get_player_stats', uuid);
|
|
|
|
|
|
|
|
|
|
const interval = setInterval(() => {
|
|
|
|
|
socket.emit('get_player', uuid);
|
|
|
|
|
socket.emit('get_player_stats', uuid);
|
|
|
|
|
}, 1000);
|
|
|
|
|
|
|
|
|
|
return () => {
|
|
|
|
|
clearInterval(interval);
|
|
|
|
|
socket.disconnect();
|
|
|
|
|
};
|
|
|
|
|
}, [uuid]);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
const firstPlayed = formatDate(playerDetail?.firstJoin);
|
|
|
|
|
const lastPlayed = formatDate(playerDetail?.lastLeave > 0 ? playerDetail?.lastLeave : playerDetail?.lastJoin);
|
|
|
|
|
|
2025-12-24 00:53:29 +09:00
|
|
|
// 번역 함수 - 아이템 통계용 (items + blocks)
|
|
|
|
|
const translateItem = (id) => translations.itemsAndBlocks?.[id] || translations.items?.[id] || translations.blocks?.[id] || id.replace(/_/g, ' ');
|
|
|
|
|
// 번역 함수 - 몹 통계용 (entities)
|
|
|
|
|
const translateMob = (id) => translations.entities?.[id] || id.replace(/_/g, ' ');
|
2025-12-16 08:40:32 +09:00
|
|
|
|
|
|
|
|
// 아이템 통계 정렬 (이름순)
|
|
|
|
|
const sortedItems = stats?.items
|
|
|
|
|
? Object.entries(stats.items)
|
|
|
|
|
.map(([id, stat]) => ({
|
|
|
|
|
id,
|
|
|
|
|
...stat,
|
|
|
|
|
total: (stat.mined || 0) + (stat.used || 0) + (stat.pickedUp || 0) + (stat.crafted || 0)
|
|
|
|
|
}))
|
2025-12-24 00:53:29 +09:00
|
|
|
.sort((a, b) => translateItem(a.id).localeCompare(translateItem(b.id), 'ko'))
|
2025-12-16 08:40:32 +09:00
|
|
|
: [];
|
|
|
|
|
|
|
|
|
|
// 몹 통계 정렬 (이름순)
|
|
|
|
|
const sortedMobs = stats?.mobs
|
|
|
|
|
? Object.entries(stats.mobs)
|
|
|
|
|
.map(([id, stat]) => ({
|
|
|
|
|
id,
|
|
|
|
|
...stat,
|
|
|
|
|
total: (stat.killed || 0) + (stat.killedBy || 0)
|
|
|
|
|
}))
|
2025-12-24 00:53:29 +09:00
|
|
|
.sort((a, b) => translateMob(a.id).localeCompare(translateMob(b.id), 'ko'))
|
2025-12-16 08:40:32 +09:00
|
|
|
: [];
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
<div className="min-h-screen p-4 md:p-6 lg:p-8">
|
|
|
|
|
<div className="max-w-5xl mx-auto">
|
|
|
|
|
{/* 헤더 */}
|
|
|
|
|
<div className="mb-8">
|
|
|
|
|
<div className="flex items-center gap-4">
|
|
|
|
|
<PlayerSkinImage uuid={uuid} playerName={playerName} />
|
|
|
|
|
<div>
|
|
|
|
|
<h1 className="text-3xl md:text-4xl font-bold text-white">{playerName || '로딩중...'}</h1>
|
|
|
|
|
<p className="text-gray-400">플레이어 통계</p>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</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>
|
|
|
|
|
) : !stats ? (
|
|
|
|
|
<div className="text-center py-20 text-gray-400">
|
|
|
|
|
<Activity size={48} className="mx-auto mb-4 opacity-50" />
|
|
|
|
|
<p>통계를 불러올 수 없습니다. 플레이어가 접속 중이어야 합니다.</p>
|
|
|
|
|
</div>
|
|
|
|
|
) : (
|
|
|
|
|
<div className="space-y-6">
|
|
|
|
|
{/* 플레이어 정보 */}
|
|
|
|
|
{playerDetail && (
|
|
|
|
|
<motion.section
|
|
|
|
|
initial={{ opacity: 0, y: 20 }}
|
|
|
|
|
animate={{ opacity: 1, y: 0 }}
|
|
|
|
|
transition={{ delay: 0.1, duration: 0.3 }}
|
|
|
|
|
>
|
|
|
|
|
<h2 className="text-xl font-bold text-white mb-4 flex items-center gap-2">
|
|
|
|
|
<Clock className="text-mc-diamond" />
|
|
|
|
|
플레이어 정보
|
|
|
|
|
</h2>
|
|
|
|
|
|
|
|
|
|
<div className={`grid gap-4 ${isMobile ? 'grid-cols-1' : 'grid-cols-4'}`}>
|
2025-12-24 00:35:36 +09:00
|
|
|
{/* 현재 세션 플레이타임 (항상 표시) */}
|
|
|
|
|
<div className={`glow-card rounded-xl p-5 ${playerDetail.isOnline ? 'border border-mc-green/30' : ''}`}>
|
|
|
|
|
<div className="text-gray-400 text-xs md:text-sm mb-2 font-medium flex items-center gap-2">
|
|
|
|
|
<div className="p-1.5 rounded-md bg-mc-green/10">
|
|
|
|
|
<Clock size={14} className="text-mc-green icon-glow" />
|
2025-12-16 08:40:32 +09:00
|
|
|
</div>
|
2025-12-24 00:35:36 +09:00
|
|
|
현재 세션 플레이타임
|
|
|
|
|
</div>
|
|
|
|
|
<div className={`font-bold text-2xl md:text-3xl ${playerDetail.isOnline ? 'text-white text-gradient' : 'text-zinc-500'}`}>
|
|
|
|
|
{playerDetail.isOnline ? formatPlayTimeMs(playerDetail.currentSessionMs) : '0분'}
|
2025-12-16 08:40:32 +09:00
|
|
|
</div>
|
2025-12-24 00:35:36 +09:00
|
|
|
</div>
|
2025-12-16 08:40:32 +09:00
|
|
|
|
|
|
|
|
{/* 누적 플레이타임 */}
|
|
|
|
|
<div className="glow-card rounded-xl p-5">
|
|
|
|
|
<div className="text-gray-400 text-xs md:text-sm mb-2 font-medium flex items-center gap-2">
|
|
|
|
|
<div className="p-1.5 rounded-md bg-mc-emerald/10">
|
|
|
|
|
<Activity size={14} className="text-mc-emerald" />
|
|
|
|
|
</div>
|
|
|
|
|
누적 플레이타임
|
|
|
|
|
</div>
|
|
|
|
|
<div className="text-white font-bold text-2xl md:text-3xl">
|
|
|
|
|
{formatPlayTimeMs(playerDetail.totalPlayTimeMs + (playerDetail.isOnline ? playerDetail.currentSessionMs : 0))}
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
{/* 첫 접속 */}
|
|
|
|
|
<div className="glow-card rounded-xl p-5">
|
|
|
|
|
<div className="text-gray-400 text-xs md:text-sm mb-2 font-medium flex items-center gap-2">
|
|
|
|
|
<div className="p-1.5 rounded-md bg-mc-diamond/10">
|
|
|
|
|
<Calendar size={14} className="text-mc-diamond" />
|
|
|
|
|
</div>
|
|
|
|
|
첫 접속
|
|
|
|
|
</div>
|
|
|
|
|
<div className="text-white font-bold text-base md:text-lg">
|
|
|
|
|
<div>{firstPlayed.date}</div>
|
|
|
|
|
<div className="text-gray-400 text-sm md:text-base font-normal">{firstPlayed.time}</div>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
{/* 마지막 접속 */}
|
|
|
|
|
<div className="glow-card rounded-xl p-5">
|
|
|
|
|
<div className="text-gray-400 text-xs md:text-sm mb-2 font-medium flex items-center gap-2">
|
|
|
|
|
<div className="p-1.5 rounded-md bg-mc-emerald/10">
|
|
|
|
|
<RefreshCw size={14} className="text-mc-emerald" />
|
|
|
|
|
</div>
|
|
|
|
|
마지막 접속
|
|
|
|
|
</div>
|
|
|
|
|
<div className="text-white font-bold text-base md:text-lg">
|
|
|
|
|
<div>{lastPlayed.date}</div>
|
|
|
|
|
<div className="text-gray-400 text-sm md:text-base font-normal">{lastPlayed.time}</div>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</motion.section>
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
{/* 일반 통계 */}
|
|
|
|
|
<motion.section
|
|
|
|
|
initial={{ opacity: 0, y: 20 }}
|
|
|
|
|
animate={{ opacity: 1, y: 0 }}
|
|
|
|
|
transition={{ delay: 0.2, duration: 0.3 }}
|
|
|
|
|
>
|
|
|
|
|
<h2 className="text-xl font-bold text-white mb-4 flex items-center gap-2">
|
|
|
|
|
<Activity className="text-mc-green" />
|
|
|
|
|
일반 통계
|
|
|
|
|
</h2>
|
|
|
|
|
<div className="grid grid-cols-2 md:grid-cols-5 gap-3">
|
|
|
|
|
<StatCard icon={Skull} label="사망 횟수" value={stats.general.deaths} color="text-red-400" />
|
|
|
|
|
<StatCard icon={Sword} label="몹 처치" value={stats.general.mobKills} color="text-orange-400" />
|
|
|
|
|
<StatCard icon={Sword} label="입힌 피해" value={stats.general.damageDealt} color="text-yellow-400" />
|
|
|
|
|
<StatCard icon={Heart} label="받은 피해" value={stats.general.damageTaken} color="text-pink-400" />
|
|
|
|
|
<StatCard icon={LocateFixed} label="점프 횟수" value={stats.general.jumps} color="text-blue-400" />
|
|
|
|
|
</div>
|
|
|
|
|
</motion.section>
|
|
|
|
|
|
|
|
|
|
{/* 이동 거리 */}
|
|
|
|
|
<motion.section
|
|
|
|
|
initial={{ opacity: 0, y: 20 }}
|
|
|
|
|
animate={{ opacity: 1, y: 0 }}
|
|
|
|
|
transition={{ delay: 0.3, duration: 0.3 }}
|
|
|
|
|
>
|
|
|
|
|
<h2 className="text-xl font-bold text-white mb-4 flex items-center gap-2">
|
|
|
|
|
<LocateFixed className="text-blue-400" />
|
|
|
|
|
이동 거리
|
|
|
|
|
</h2>
|
|
|
|
|
<div className="grid grid-cols-3 gap-3">
|
|
|
|
|
<div className="glow-card rounded-xl p-4 flex flex-col items-center justify-center">
|
|
|
|
|
<p className="text-gray-400 text-sm mb-1">걸은 거리</p>
|
|
|
|
|
<p className={`font-bold text-white ${isMobile ? 'text-lg' : 'text-2xl'}`}>
|
|
|
|
|
{isMobile ? formatDistance(stats.general.distanceWalked) : `${stats.general.distanceWalked.toLocaleString()}m`}
|
|
|
|
|
</p>
|
|
|
|
|
</div>
|
|
|
|
|
<div className="glow-card rounded-xl p-4 flex flex-col items-center justify-center">
|
|
|
|
|
<p className="text-gray-400 text-sm mb-1">비행 거리</p>
|
|
|
|
|
<p className={`font-bold text-white ${isMobile ? 'text-lg' : 'text-2xl'}`}>
|
|
|
|
|
{isMobile ? formatDistance(stats.general.distanceFlown) : `${stats.general.distanceFlown.toLocaleString()}m`}
|
|
|
|
|
</p>
|
|
|
|
|
</div>
|
|
|
|
|
<div className="glow-card rounded-xl p-4 flex flex-col items-center justify-center">
|
|
|
|
|
<p className="text-gray-400 text-sm mb-1">수영 거리</p>
|
|
|
|
|
<p className={`font-bold text-white ${isMobile ? 'text-lg' : 'text-2xl'}`}>
|
|
|
|
|
{isMobile ? formatDistance(stats.general.distanceSwum) : `${stats.general.distanceSwum.toLocaleString()}m`}
|
|
|
|
|
</p>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</motion.section>
|
|
|
|
|
|
|
|
|
|
{/* 아이템 통계 - 4열 그리드 + 내부 스크롤 */}
|
|
|
|
|
{sortedItems.length > 0 && (
|
|
|
|
|
<motion.section
|
|
|
|
|
initial={{ opacity: 0, y: 20 }}
|
|
|
|
|
animate={{ opacity: 1, y: 0 }}
|
|
|
|
|
transition={{ delay: 0.4, duration: 0.3 }}
|
|
|
|
|
>
|
|
|
|
|
<h2 className="text-xl font-bold text-white mb-4 flex items-center gap-2">
|
|
|
|
|
<Box className="text-yellow-400" />
|
|
|
|
|
아이템 통계
|
|
|
|
|
<span className="text-gray-500 text-sm font-normal">({sortedItems.length}개)</span>
|
|
|
|
|
</h2>
|
|
|
|
|
<div className="glow-card rounded-xl overflow-hidden">
|
|
|
|
|
<div className="max-h-[400px] overflow-y-auto custom-scrollbar">
|
|
|
|
|
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4">
|
|
|
|
|
{sortedItems.map((item) => (
|
2025-12-24 00:53:29 +09:00
|
|
|
<ItemStatRow key={item.id} item={item} translate={translateItem} icons={icons} />
|
2025-12-16 08:40:32 +09:00
|
|
|
))}
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</motion.section>
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
{/* 몹 통계 - 4열 그리드 + 내부 스크롤 */}
|
|
|
|
|
{sortedMobs.length > 0 && (
|
|
|
|
|
<motion.section
|
|
|
|
|
initial={{ opacity: 0, y: 20 }}
|
|
|
|
|
animate={{ opacity: 1, y: 0 }}
|
|
|
|
|
transition={{ delay: 0.5, duration: 0.3 }}
|
|
|
|
|
>
|
|
|
|
|
<h2 className="text-xl font-bold text-white mb-4 flex items-center gap-2">
|
|
|
|
|
<Sword className="text-red-400" />
|
|
|
|
|
몹 통계
|
|
|
|
|
<span className="text-gray-500 text-sm font-normal">({sortedMobs.length}마리)</span>
|
|
|
|
|
</h2>
|
|
|
|
|
<div className="glow-card rounded-xl overflow-hidden">
|
|
|
|
|
<div className="max-h-[400px] overflow-y-auto custom-scrollbar">
|
|
|
|
|
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4">
|
|
|
|
|
{sortedMobs.map((mob) => (
|
2025-12-24 00:53:29 +09:00
|
|
|
<MobStatRow key={mob.id} mob={mob} translate={translateMob} icons={icons} />
|
2025-12-16 08:40:32 +09:00
|
|
|
))}
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</motion.section>
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
{sortedItems.length === 0 && sortedMobs.length === 0 && (
|
|
|
|
|
<div className="text-center py-10 text-gray-500">
|
|
|
|
|
<p>아직 기록된 통계가 없습니다.</p>
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
// 통계 카드 컴포넌트
|
|
|
|
|
const StatCard = ({ icon: Icon, label, value, color }) => (
|
|
|
|
|
<div className="glow-card rounded-xl p-4 flex items-center gap-3 transition-colors hover:bg-white/5">
|
|
|
|
|
<div className={`p-2 rounded-lg bg-white/5 ${color}`}>
|
|
|
|
|
<Icon size={20} />
|
|
|
|
|
</div>
|
|
|
|
|
<div>
|
|
|
|
|
<p className="text-gray-400 text-xs">{label}</p>
|
|
|
|
|
<p className="text-lg font-bold text-white">{typeof value === 'number' ? value.toLocaleString() : value}</p>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
// 아이템 통계 행 컴포넌트 (온디맨드 아이콘 지원)
|
|
|
|
|
const ItemStatRow = ({ item, translate, icons }) => {
|
2025-12-24 16:20:36 +09:00
|
|
|
const [iconSrc, setIconSrc] = useState(icons[item.id] || null);
|
2025-12-16 08:40:32 +09:00
|
|
|
const [loading, setLoading] = useState(!icons[item.id]);
|
2025-12-24 16:20:36 +09:00
|
|
|
const [hasIcon, setHasIcon] = useState(!!icons[item.id]);
|
2025-12-16 08:40:32 +09:00
|
|
|
|
|
|
|
|
// 아이콘이 없으면 온디맨드로 가져오기
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
if (!icons[item.id]) {
|
|
|
|
|
fetch(`/api/icon/item/${item.id}`)
|
|
|
|
|
.then(res => res.json())
|
|
|
|
|
.then(data => {
|
|
|
|
|
if (data.icon) {
|
|
|
|
|
setIconSrc(data.icon);
|
2025-12-24 16:20:36 +09:00
|
|
|
setHasIcon(true);
|
|
|
|
|
} else {
|
|
|
|
|
setHasIcon(false);
|
2025-12-16 08:40:32 +09:00
|
|
|
}
|
|
|
|
|
})
|
2025-12-24 16:20:36 +09:00
|
|
|
.catch(() => { setHasIcon(false); })
|
2025-12-16 08:40:32 +09:00
|
|
|
.finally(() => setLoading(false));
|
|
|
|
|
}
|
|
|
|
|
}, [item.id, icons]);
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
<div className="p-3 flex items-center gap-3 hover:bg-white/5 transition-colors border-b border-r border-white/5">
|
2025-12-24 16:20:36 +09:00
|
|
|
{hasIcon && iconSrc ? (
|
|
|
|
|
<img
|
|
|
|
|
src={iconSrc}
|
|
|
|
|
alt={item.id}
|
|
|
|
|
className="w-8 h-8 object-contain pixelated"
|
|
|
|
|
onError={() => { setHasIcon(false); }}
|
|
|
|
|
/>
|
|
|
|
|
) : (
|
|
|
|
|
<div className="w-8 h-8 flex items-center justify-center">
|
|
|
|
|
<ImageOff size={20} className="text-zinc-400" />
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
2025-12-16 08:40:32 +09:00
|
|
|
<div className="flex-1 min-w-0">
|
2025-12-24 16:30:53 +09:00
|
|
|
<Tooltip content={translate(item.id)}>
|
|
|
|
|
<p className="text-white text-sm font-medium truncate">
|
|
|
|
|
{translate(item.id)}
|
|
|
|
|
</p>
|
|
|
|
|
</Tooltip>
|
2025-12-16 08:40:32 +09:00
|
|
|
<div className="flex flex-wrap gap-x-2 text-xs text-gray-400">
|
|
|
|
|
{item.mined > 0 && <span>채굴 <span className="text-yellow-400">{item.mined}</span></span>}
|
|
|
|
|
{item.used > 0 && <span>사용 <span className="text-blue-400">{item.used}</span></span>}
|
|
|
|
|
{item.pickedUp > 0 && <span>획득 <span className="text-green-400">{item.pickedUp}</span></span>}
|
|
|
|
|
{item.crafted > 0 && <span>제작 <span className="text-purple-400">{item.crafted}</span></span>}
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
// 몹 통계 행 컴포넌트 (온디맨드 아이콘 지원)
|
|
|
|
|
const MobStatRow = ({ mob, translate, icons }) => {
|
2025-12-24 16:20:36 +09:00
|
|
|
const [iconSrc, setIconSrc] = useState(icons[mob.id] || null);
|
2025-12-16 08:40:32 +09:00
|
|
|
const [loading, setLoading] = useState(!icons[mob.id]);
|
2025-12-24 16:20:36 +09:00
|
|
|
const [hasIcon, setHasIcon] = useState(!!icons[mob.id]);
|
2025-12-16 08:40:32 +09:00
|
|
|
|
|
|
|
|
// 아이콘이 없으면 온디맨드로 가져오기
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
if (!icons[mob.id]) {
|
|
|
|
|
fetch(`/api/icon/entity/${mob.id}`)
|
|
|
|
|
.then(res => res.json())
|
|
|
|
|
.then(data => {
|
|
|
|
|
if (data.icon) {
|
|
|
|
|
setIconSrc(data.icon);
|
2025-12-24 16:20:36 +09:00
|
|
|
setHasIcon(true);
|
|
|
|
|
} else {
|
|
|
|
|
setHasIcon(false);
|
2025-12-16 08:40:32 +09:00
|
|
|
}
|
|
|
|
|
})
|
2025-12-24 16:20:36 +09:00
|
|
|
.catch(() => { setHasIcon(false); })
|
2025-12-16 08:40:32 +09:00
|
|
|
.finally(() => setLoading(false));
|
|
|
|
|
}
|
|
|
|
|
}, [mob.id, icons]);
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
<div className="p-3 flex items-center gap-3 hover:bg-white/5 transition-colors border-b border-r border-white/5">
|
2025-12-24 16:20:36 +09:00
|
|
|
{hasIcon && iconSrc ? (
|
|
|
|
|
<img
|
|
|
|
|
src={iconSrc}
|
|
|
|
|
alt={mob.id}
|
|
|
|
|
className="w-8 h-8 object-contain pixelated"
|
|
|
|
|
onError={() => { setHasIcon(false); }}
|
|
|
|
|
/>
|
|
|
|
|
) : (
|
|
|
|
|
<div className="w-8 h-8 flex items-center justify-center">
|
|
|
|
|
<ImageOff size={20} className="text-zinc-400" />
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
2025-12-16 08:40:32 +09:00
|
|
|
<div className="flex-1 min-w-0">
|
2025-12-24 16:30:53 +09:00
|
|
|
<Tooltip content={translate(mob.id)}>
|
|
|
|
|
<p className="text-white text-sm font-medium truncate">
|
|
|
|
|
{translate(mob.id)}
|
|
|
|
|
</p>
|
|
|
|
|
</Tooltip>
|
2025-12-16 08:40:32 +09:00
|
|
|
<div className="flex flex-wrap gap-x-2 text-xs text-gray-400">
|
|
|
|
|
{mob.killed > 0 && <span>처치 <span className="text-red-400">{mob.killed}</span></span>}
|
|
|
|
|
{mob.killedBy > 0 && <span>죽음 <span className="text-gray-300">{mob.killedBy}</span></span>}
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
export default PlayerStatsPage;
|