minecraft-web/frontend/src/pages/Admin.jsx
caadiq f6e7a8922a feat(admin): 성능 모니터링 API 연동 및 UI 개선
- TPS, MSPT, 메모리 사용량 실시간 표시
- CPU → MSPT로 변경 (서버 틱 처리 시간)
- formatStatusForClient에 성능 필드 추가
- 모바일 성능 UI 반응형 레이아웃 (1열/3열)
- 타이틀 '관리자 콘솔'로 통일
2025-12-23 12:45:40 +09:00

1795 lines
75 KiB
JavaScript
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* 관리자 페이지
* - 탭 UI: 콘솔 / 플레이어 / 설정
*/
import { useEffect, useState, useRef, useCallback } from 'react';
import { useNavigate, useLocation, Link } from 'react-router-dom';
import { useAuth } from '../contexts/AuthContext';
import {
Shield, ArrowLeft, ArrowDown, Loader2, Terminal, Users, Settings,
Send, Ban, UserX, Crown, Sun, Moon, Cloud, CloudRain, CloudLightning,
ChevronDown, FileText, Download, Trash2, Check, RefreshCw, Eye, X
} from 'lucide-react';
import { motion, AnimatePresence } from 'framer-motion';
import { io } from 'socket.io-client';
import Tooltip from '../components/Tooltip';
// 더미 로그 데이터
const DUMMY_LOGS = [
{ time: '15:01:23', type: 'info', message: '[Server] Starting minecraft server version 1.21.1' },
{ time: '15:01:24', type: 'info', message: '[Server] Loading properties' },
{ time: '15:01:25', type: 'info', message: '[Server] Preparing level "world"' },
{ time: '15:01:28', type: 'info', message: '[Server] Done (3.245s)! For help, type "help"' },
{ time: '15:05:12', type: 'info', message: '[Server] 비머[/127.0.0.1:54321] logged in' },
{ time: '15:05:15', type: 'info', message: '[Server] 비머 joined the game' },
{ time: '15:10:30', type: 'warning', message: '[Server] Can\'t keep up! Is the server overloaded?' },
{ time: '15:15:00', type: 'info', message: '[Server] 비머부캐 joined the game' },
];
// 더미 로그 파일 데이터 (삭제됨 - API 사용)
// 더미 게임규칙 데이터 제거됨 - 소켓에서 실시간으로 가져옴
// 스티브 기본 스킨 (Base64)
const STEVE_BODY_BASE64 = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAACAAAAAwCAYAAABwrHhvAAABWklEQVRYR+2WsQ3CMBBFfxBFJBpGYA8GYA4WoKAio0BFQUGV0pEo6GMGdmAE0kQKcq785jg5O7HOckVx0cXv/t/+FxuiH/uh+ckfATgNwKkTmDuBJQG4DsAzAbRG4E4ARyRYJoLrRDCNCBIC8AkguQVuGMBNhUkJAG2pj3MA3CVwVgBuLXCqAKAGvLkGIBKC0wJgWUCNACA/AIYLhSoXpuoEHGMo6wIaJIRBQvBBQiyI8EpuLOAZBqCYYCQAnAhghACKCYYCQEGHc4wDkIkQEYAQgDQBJELoAohpQViEoC4gVQJhAmBaBL4BsKyBYRH4BYBYArYB3N8EpjYAS8DCDUB9Q+YGwK4FaBdBigCkC2grQvBpIWQDkEYA61pwLQK7ACQBQGQEMksAkQKQcBpIE4C1ECISAJ0F7E4AsZdBiC4A6S2AWAvsBJAgALYF6hKA+hZgAvhXwP8D8ANSsjw4hB3aNwAAAABJRU5ErkJggg==';
// 캐시된 스킨 컴포넌트 - S3 캐싱 API 사용
const CachedSkin = ({ uuid, name, type = 'body', size = 100, className }) => {
const [src, setSrc] = useState(STEVE_BODY_BASE64);
const fallbackUrl = `https://mc-heads.net/${type}/${uuid}/${size}`;
useEffect(() => {
if (!uuid) return;
fetch(`/link/skin/${type}/${uuid}/${size}`)
.then(res => res.json())
.then(data => {
if (data.url) {
const img = new Image();
img.onload = () => setSrc(data.url);
img.onerror = () => setSrc(fallbackUrl);
img.src = data.url;
} else {
setSrc(fallbackUrl);
}
})
.catch(() => setSrc(fallbackUrl));
}, [uuid, type, size, fallbackUrl]);
return (
<img
src={src}
alt={name}
className={className}
onError={(e) => { e.target.src = fallbackUrl; }}
/>
);
};
export default function Admin({ isMobile = false }) {
const { isLoggedIn, isAdmin, user, loading } = useAuth();
const navigate = useNavigate();
const location = useLocation();
const [toast, setToast] = useState(null);
// 탭 상태 (URL 해시에서 초기값 로드)
const getInitialTab = () => {
const hash = window.location.hash.replace('#', '');
return ['console', 'players', 'settings'].includes(hash) ? hash : 'console';
};
const [activeTab, setActiveTab] = useState(getInitialTab);
// 탭 변경 시 URL 해시 업데이트
const handleTabChange = (tab) => {
setActiveTab(tab);
window.location.hash = tab;
};
// 콘솔 관련 상태
const [logs, setLogs] = useState([]);
const [command, setCommand] = useState('');
const [logFiles, setLogFiles] = useState([]);
const [logServers, setLogServers] = useState([]); // 서버 ID 목록
const [selectedLogServer, setSelectedLogServer] = useState('all'); // 선택된 서버
const [selectedLogType, setSelectedLogType] = useState('all'); // 로그 종류 필터
const [logViewerOpen, setLogViewerOpen] = useState(false); // 로그 뷰어 다이얼로그
const [viewingLog, setViewingLog] = useState(null); // 보고 있는 로그 파일
const [logContent, setLogContent] = useState(''); // 로그 내용
const [logLoading, setLogLoading] = useState(false); // 로그 로딩
const [serverDropdownOpen, setServerDropdownOpen] = useState(false); // 서버 드롭다운
const [typeDropdownOpen, setTypeDropdownOpen] = useState(false); // 타입 드롭다운
const logEndRef = useRef(null);
const logContainerRef = useRef(null);
const isInitialLoad = useRef(true);
const [isAtBottom, setIsAtBottom] = useState(true); // 스크롤이 맨 아래에 있는지 추적
// 명령어 히스토리
const [commandHistory, setCommandHistory] = useState([]);
const [historyIndex, setHistoryIndex] = useState(-1);
// 플레이어 관련 상태
const [players, setPlayers] = useState([]);
const [banList, setBanList] = useState([]); // 밴 목록
const [playerFilter, setPlayerFilter] = useState('all'); // all, online, offline, banned
const [selectedPlayer, setSelectedPlayer] = useState(null);
const [showPlayerDialog, setShowPlayerDialog] = useState(false);
const [dialogAction, setDialogAction] = useState(null); // kick, ban, op, unban
const [actionReason, setActionReason] = useState('');
// 설정 관련 상태
const [gameRules, setGameRules] = useState({}); // 소켓에서 가져온 게임 규칙
const [gameRuleDescriptions, setGameRuleDescriptions] = useState({}); // 게임 규칙 설명
const [difficulty, setDifficulty] = useState('normal');
const [timeOfDay, setTimeOfDay] = useState('day');
const [weather, setWeather] = useState('clear');
// 화이트리스트 상태 (API 연동)
const [whitelistEnabled, setWhitelistEnabled] = useState(false);
const [whitelistPlayers, setWhitelistPlayers] = useState([]);
const [newWhitelistPlayer, setNewWhitelistPlayer] = useState('');
const [whitelistRemoveTarget, setWhitelistRemoveTarget] = useState(null); // 삭제 확인 다이얼로그용
const [whitelistLoading, setWhitelistLoading] = useState(false);
// 성능 모니터링 상태 (소켓에서 업데이트)
const [serverPerformance, setServerPerformance] = useState({
tps: 0,
mspt: 0,
memory: { used: 0, max: 0 },
});
// 권한 확인
useEffect(() => {
if (!loading) {
if (!isLoggedIn) {
navigate('/login', { state: { from: location.pathname } });
} else if (!isAdmin) {
setToast('관리자 권한이 필요합니다.');
setTimeout(() => navigate('/'), 1500);
}
}
}, [isLoggedIn, isAdmin, loading, navigate, location.pathname]);
// 토스트 자동 숨기기
useEffect(() => {
if (toast) {
const timer = setTimeout(() => setToast(null), 3000);
return () => clearTimeout(timer);
}
}, [toast]);
// 플레이어 목록 fetch (안정적인 참조)
const fetchPlayers = useCallback(async () => {
try {
const token = localStorage.getItem('token');
const response = await fetch('/api/admin/players', {
headers: { 'Authorization': `Bearer ${token}` }
});
const data = await response.json();
if (data.players) {
setPlayers(data.players);
}
} catch (error) {
console.error('플레이어 목록 조회 실패:', error);
}
}, []);
// 밴 목록 fetch (안정적인 참조)
const fetchBanList = useCallback(async () => {
try {
const token = localStorage.getItem('token');
const response = await fetch('/api/admin/banlist', {
headers: { 'Authorization': `Bearer ${token}` }
});
const data = await response.json();
if (data.banList) {
setBanList(data.banList);
}
} catch (error) {
console.error('밴 목록 조회 실패:', error);
}
}, []);
// 화이트리스트 API 함수들
const fetchWhitelist = useCallback(async () => {
try {
setWhitelistLoading(true);
const token = localStorage.getItem('token');
const response = await fetch('/api/admin/whitelist', {
headers: { 'Authorization': `Bearer ${token}` }
});
const data = await response.json();
setWhitelistEnabled(data.enabled || false);
setWhitelistPlayers(data.players || []);
} catch (error) {
console.error('화이트리스트 조회 실패:', error);
} finally {
setWhitelistLoading(false);
}
}, []);
// 화이트리스트 토글 (on/off)
const toggleWhitelist = useCallback(async () => {
const command = whitelistEnabled ? 'whitelist off' : 'whitelist on';
try {
const token = localStorage.getItem('token');
const response = await fetch('/api/admin/command', {
method: 'POST',
headers: {
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json'
},
body: JSON.stringify({ command })
});
if (response.ok) {
setWhitelistEnabled(!whitelistEnabled);
setToast(`화이트리스트: ${!whitelistEnabled ? '활성화' : '비활성화'}`);
}
} catch (error) {
console.error('화이트리스트 토글 실패:', error);
setToast('화이트리스트 변경 실패');
}
}, [whitelistEnabled]);
// 화이트리스트 플레이어 추가
const addWhitelistPlayer = useCallback(async (playerName) => {
if (!playerName.trim()) return;
try {
const token = localStorage.getItem('token');
const response = await fetch('/api/admin/command', {
method: 'POST',
headers: {
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json'
},
body: JSON.stringify({ command: `whitelist add ${playerName.trim()}` })
});
if (response.ok) {
setToast(`${playerName.trim()} 추가됨`);
setNewWhitelistPlayer('');
// 즉시 프론트엔드 상태 업데이트 (임시 uuid)
setWhitelistPlayers(prev => [...prev, {
uuid: crypto.randomUUID(),
name: playerName.trim()
}]);
// 백그라운드에서 서버 동기화 (정확한 uuid 가져오기)
fetchWhitelist();
}
} catch (error) {
console.error('화이트리스트 추가 실패:', error);
setToast('플레이어 추가 실패');
}
}, [fetchWhitelist]);
// 화이트리스트 플레이어 제거
const removeWhitelistPlayer = useCallback(async (playerName) => {
try {
const token = localStorage.getItem('token');
const response = await fetch('/api/admin/command', {
method: 'POST',
headers: {
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json'
},
body: JSON.stringify({ command: `whitelist remove ${playerName}` })
});
if (response.ok) {
setToast(`${playerName} 제거됨`);
setWhitelistRemoveTarget(null);
// 약간의 딜레이 후 목록 새로고침 (서버 반영 시간)
setTimeout(fetchWhitelist, 500);
}
} catch (error) {
console.error('화이트리스트 제거 실패:', error);
setToast('플레이어 제거 실패');
}
}, [fetchWhitelist]);
// 플레이어 탭 활성화 시 데이터 로드
useEffect(() => {
if (activeTab === 'players' && isAdmin) {
fetchPlayers();
fetchBanList();
fetchWhitelist();
}
}, [activeTab, isAdmin]);
// 스크롤 위치가 맨 아래인지 확인하는 함수
const checkIsAtBottom = useCallback((container) => {
if (!container) return true;
// 5px 오차 허용 (스크롤 정밀도 문제 대응)
return container.scrollHeight - container.scrollTop - container.clientHeight < 5;
}, []);
// 스크롤 이벤트 핸들러 - isAtBottom 상태 업데이트
const handleLogScroll = useCallback((e) => {
const container = e.target;
setIsAtBottom(checkIsAtBottom(container));
}, [checkIsAtBottom]);
// 맨 아래로 스크롤하는 함수
const scrollToBottom = useCallback(() => {
const container = logContainerRef.current;
if (container) {
container.scrollTop = container.scrollHeight;
setIsAtBottom(true);
}
}, []);
// 로그 스크롤 - 새 로그 추가 시 부드럽게 스크롤 (맨 아래에 있을 때만)
// CSS scroll-behavior: smooth가 컨테이너에 적용되어 있으므로 scrollTop 설정만으로 부드럽게 동작
useEffect(() => {
if (activeTab !== 'console' || logs.length === 0) return;
const container = logContainerRef.current;
if (!container) return;
if (isInitialLoad.current) {
// 초기 로드 시 즉시 스크롤 (애니메이션 없이)
container.style.scrollBehavior = 'auto';
container.scrollTop = container.scrollHeight;
setIsAtBottom(true);
requestAnimationFrame(() => {
container.style.scrollBehavior = 'smooth';
});
isInitialLoad.current = false;
} else if (isAtBottom) {
// 맨 아래에 있을 때만 새 로그에 따라 자동 스크롤
container.scrollTop = container.scrollHeight;
}
}, [logs, activeTab, isAtBottom]);
// 탭 전환 시 맨 아래로 스크롤 (ref 콜백 방식)
// AnimatePresence로 인해 컴포넌트가 리마운트될 때 useEffect가 다시 트리거되지 않으므로
// ref 콜백을 사용하여 DOM이 마운트될 때마다 스크롤 처리
// 주의: logs.length를 의존성에서 제거하여 새 로그 추가 시에는 위의 useEffect에서 부드럽게 스크롤
const setLogContainerRef = useCallback((node) => {
logContainerRef.current = node;
if (node) {
// DOM 마운트 시 (탭 전환 시) 즉시 맨 아래로 스크롤
// CSS smooth가 설정되어 있으므로 일시적으로 비활성화
requestAnimationFrame(() => {
node.style.scrollBehavior = 'auto'; // 즉시 스크롤
node.scrollTop = node.scrollHeight;
setIsAtBottom(true);
// 다음 프레임에서 smooth 복원 (새 로그 추가 시 부드럽게)
requestAnimationFrame(() => {
node.style.scrollBehavior = 'smooth';
});
});
}
}, []); // 의존성 없음 - 마운트 시에만 실행
// 로그 파일 목록 fetch 함수
const fetchLogFiles = async () => {
try {
const token = localStorage.getItem('token');
const params = new URLSearchParams();
if (selectedLogServer !== 'all') params.append('serverId', selectedLogServer);
if (selectedLogType !== 'all') params.append('fileType', selectedLogType);
const response = await fetch(`/api/admin/logfiles?${params}`, {
headers: { 'Authorization': `Bearer ${token}` }
});
const data = await response.json();
if (data.files) {
setLogFiles(data.files);
}
if (data.servers) {
setLogServers(data.servers);
}
} catch (error) {
console.error('로그 파일 목록 조회 실패:', error);
}
};
// 로그 파일 목록 자동 fetch
useEffect(() => {
fetchLogFiles();
}, [selectedLogServer, selectedLogType]);
// 로그 파일 내용 보기
const viewLogContent = async (file) => {
setViewingLog(file);
setLogViewerOpen(true);
setLogLoading(true);
setLogContent('');
try {
const token = localStorage.getItem('token');
const response = await fetch(`/api/admin/logfile?id=${file.id}`, {
headers: { 'Authorization': `Bearer ${token}` }
});
if (response.ok) {
const blob = await response.blob();
// .gz 파일만 압축 해제
if (file.fileName.endsWith('.gz')) {
const ds = new DecompressionStream('gzip');
const decompressedStream = blob.stream().pipeThrough(ds);
const decompressedBlob = await new Response(decompressedStream).blob();
const text = await decompressedBlob.text();
setLogContent(text);
} else {
// .log 파일은 직접 텍스트로 읽기
const text = await blob.text();
setLogContent(text);
}
} else {
setLogContent('로그 파일을 불러올 수 없습니다.');
}
} catch (error) {
console.error('로그 파일 로드 실패:', error);
setLogContent('로그 파일을 불러오는 중 오류가 발생했습니다.');
} finally {
setLogLoading(false);
}
};
// 로그 파일 삭제
const deleteLogFile = async (file, e) => {
if (e) e.stopPropagation();
if (!confirm(`${file.fileName} 파일을 삭제하시겠습니까?`)) return;
try {
const token = localStorage.getItem('token');
const response = await fetch(`/api/admin/logfile?id=${file.id}`, {
method: 'DELETE',
headers: { 'Authorization': `Bearer ${token}` }
});
if (response.ok) {
fetchLogFiles(); // 목록 새로고침
}
} catch (error) {
console.error('로그 파일 삭제 실패:', error);
}
};
// 마인크래프트 색상 코드 매핑
const MC_COLORS = {
'0': '#000000', // Black
'1': '#0000AA', // Dark Blue
'2': '#00AA00', // Dark Green
'3': '#00AAAA', // Dark Aqua
'4': '#AA0000', // Dark Red
'5': '#AA00AA', // Dark Purple
'6': '#FFAA00', // Gold
'7': '#AAAAAA', // Gray
'8': '#555555', // Dark Gray
'9': '#5555FF', // Blue
'a': '#55FF55', // Green
'b': '#55FFFF', // Aqua
'c': '#FF5555', // Red
'd': '#FF55FF', // Light Purple
'e': '#FFFF55', // Yellow
'f': '#FFFFFF', // White
};
// 마인크래프트 색상 코드를 HTML span으로 변환
const parseMinecraftColors = (text) => {
if (!text) return [];
const parts = [];
let currentColor = null;
let buffer = '';
let i = 0;
while (i < text.length) {
if (text[i] === '§' && i + 1 < text.length) {
// 현재 버퍼 저장
if (buffer) {
parts.push({ text: buffer, color: currentColor });
buffer = '';
}
const code = text[i + 1].toLowerCase();
if (MC_COLORS[code]) {
currentColor = MC_COLORS[code];
} else if (code === 'r') {
currentColor = '#FFFFFF'; // Reset to white
}
// k, l, m, n, o는 스타일 코드 (무시)
i += 2;
} else {
buffer += text[i];
i++;
}
}
// 남은 버퍼 저장
if (buffer) {
parts.push({ text: buffer, color: currentColor });
}
return parts;
};
// 실제 서버 로그 Socket.io로 수신
useEffect(() => {
if (!isAdmin) return;
// 초기 로그 fetch (Socket.io 연결 전)
const fetchInitialLogs = async () => {
try {
const token = localStorage.getItem('token');
const response = await fetch('/api/admin/logs', {
headers: { 'Authorization': `Bearer ${token}` }
});
const data = await response.json();
if (data.logs && data.logs.length > 0) {
setLogs(data.logs.map(log => ({
time: log.time,
type: log.level === 'ERROR' ? 'error' : log.level === 'WARN' ? 'warning' : 'info',
message: log.message
})));
}
} catch (error) {
console.error('초기 로그 조회 오류:', error);
}
};
fetchInitialLogs();
const socket = io('/', {
path: '/socket.io',
transports: ['websocket', 'polling']
});
socket.on('logs', (serverLogs) => {
if (serverLogs && Array.isArray(serverLogs)) {
setLogs(serverLogs.map(log => ({
time: log.time,
type: log.level === 'ERROR' ? 'error' : log.level === 'WARN' ? 'warning' : 'info',
message: log.message
})));
}
});
// 플레이어 목록 실시간 업데이트
socket.on('players', (playersList) => {
if (playersList && Array.isArray(playersList)) {
setPlayers(playersList);
}
});
// 서버 상태에서 게임 규칙 가져오기
socket.on('status', (status) => {
if (status?.gameRules) {
setGameRules(status.gameRules);
}
if (status?.difficulty) {
// 난이도를 영문 ID로 변환
const difficultyMap = {
'평화로움': 'peaceful',
'쉬움': 'easy',
'보통': 'normal',
'어려움': 'hard'
};
setDifficulty(difficultyMap[status.difficulty] || 'normal');
}
// 성능 모니터링 데이터 업데이트
if (status?.tps !== undefined || status?.memoryUsedMb !== undefined) {
setServerPerformance(prev => ({
tps: status.tps ?? prev.tps,
mspt: status.mspt ?? prev.mspt,
memory: {
used: status.memoryUsedMb ?? prev.memory.used,
max: status.memoryMaxMb ?? prev.memory.max
}
}));
}
});
// 월드 정보에서 시간/날씨 가져오기
socket.on('worlds', (data) => {
const worlds = data?.worlds || data;
// 오버월드 찾기
const overworld = worlds?.find(w => w.dimension === 'minecraft:overworld');
if (overworld) {
// 날씨 설정 (thunderstorm -> thunder로 변환)
if (overworld.weather?.type) {
const weatherMap = {
'clear': 'clear',
'rain': 'rain',
'thunderstorm': 'thunder'
};
setWeather(weatherMap[overworld.weather.type] || 'clear');
}
// 시간 설정 (틱 기반으로 대략적인 시간대 결정)
if (overworld.time?.dayTime !== undefined) {
const dayTime = overworld.time.dayTime;
// 0-6000: 아침/낮, 6000-12000: 낮/오후, 12000-24000: 밤
if (dayTime >= 0 && dayTime < 6000) {
setTimeOfDay('day');
} else if (dayTime >= 6000 && dayTime < 12000) {
setTimeOfDay('noon');
} else {
setTimeOfDay('night');
}
}
}
});
// 월드 정보 요청
socket.emit('get_worlds');
return () => {
socket.disconnect();
};
}, [isAdmin]);
// 게임 규칙 설명 데이터 로드
useEffect(() => {
fetch('/api/gamerules')
.then(res => res.json())
.then(data => setGameRuleDescriptions(data))
.catch(err => console.error('게임 규칙 설명 로드 실패:', err));
}, []);
// 명령어 실행 (실제 API 호출)
const handleCommand = async () => {
if (!command.trim()) return;
try {
const token = localStorage.getItem('token');
await fetch('/api/admin/command', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${token}`
},
body: JSON.stringify({ command: command.trim() })
});
} catch (error) {
// 오류 무시 (로그에서 확인 가능)
}
setCommand('');
};
// 플레이어 액션 핸들러
const handlePlayerAction = async () => {
if (!selectedPlayer || !dialogAction) return;
const token = localStorage.getItem('token');
let command = '';
let message = '';
switch (dialogAction) {
case 'kick':
command = actionReason ? `kick ${selectedPlayer.name} ${actionReason}` : `kick ${selectedPlayer.name}`;
message = `${selectedPlayer.displayName || selectedPlayer.name}님을 추방했습니다.`;
break;
case 'ban':
command = actionReason ? `ban ${selectedPlayer.name} ${actionReason}` : `ban ${selectedPlayer.name}`;
message = `${selectedPlayer.displayName || selectedPlayer.name}님을 차단했습니다.`;
break;
case 'unban':
command = `pardon ${selectedPlayer.name}`;
message = `${selectedPlayer.displayName || selectedPlayer.name}님의 차단을 해제했습니다.`;
break;
case 'op':
const isOp = selectedPlayer.isOp;
command = isOp ? `deop ${selectedPlayer.name}` : `op ${selectedPlayer.name}`;
message = isOp ? `${selectedPlayer.displayName || selectedPlayer.name}님의 OP를 해제했습니다.` : `${selectedPlayer.displayName || selectedPlayer.name}님에게 OP를 부여했습니다.`;
break;
}
try {
const response = await fetch('/api/admin/command', {
method: 'POST',
headers: {
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json'
},
body: JSON.stringify({ command })
});
if (response.ok) {
setToast(message);
// 데이터 새로고침
setTimeout(() => {
fetchPlayers();
fetchBanList();
}, 500);
} else {
setToast('명령어 실행에 실패했습니다.');
}
} catch (error) {
console.error('플레이어 액션 오류:', error);
setToast('서버 연결에 실패했습니다.');
}
setShowPlayerDialog(false);
setSelectedPlayer(null);
setDialogAction(null);
setActionReason('');
};
// 게임규칙 토글 (서버에 gamerule 명령어 전송)
const toggleGamerule = async (name) => {
const currentValue = gameRules[name];
const newValue = !currentValue;
// 낙관적 UI 업데이트
setGameRules(prev => ({ ...prev, [name]: newValue }));
try {
const token = localStorage.getItem('token');
await fetch('/api/admin/command', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${token}`
},
body: JSON.stringify({ command: `gamerule ${name} ${newValue}` })
});
setToast(`${name}: ${newValue ? 'true' : 'false'}`);
} catch (error) {
// 실패 시 롤백
setGameRules(prev => ({ ...prev, [name]: currentValue }));
setToast('게임규칙 변경 실패');
}
};
// 로그 색상 (hex 값 반환)
const getLogColorHex = (type) => {
switch (type) {
case 'error': return '#f87171'; // red-400
case 'warning': return '#facc15'; // yellow-400
case 'command': return '#22c55e'; // mc-green
default: return '#d4d4d8'; // zinc-300
}
};
// 로그 색상 (클래스명 반환 - 시간용)
const getLogColor = (type) => {
switch (type) {
case 'error': return 'text-red-400';
case 'warning': return 'text-yellow-400';
case 'command': return 'text-mc-green';
default: return 'text-zinc-300';
}
};
// 필터된 플레이어 (banned 필터는 banList 사용)
const filteredPlayers = playerFilter === 'banned'
? banList.map(ban => ({
name: ban.name,
uuid: ban.uuid,
displayName: ban.name,
isOnline: false,
isOp: false,
isBanned: true,
banReason: ban.reason,
banSource: ban.source
}))
: [...players].sort((a, b) => {
// 온라인 우선
if (a.isOnline !== b.isOnline) return b.isOnline ? 1 : -1;
// 같은 온라인 상태면 OP 우선
if (a.isOp !== b.isOp) return b.isOp ? 1 : -1;
// 그 외에는 닉네임 순
return (a.displayName || a.name).localeCompare(b.displayName || b.name);
});
if (loading) {
return (
<div className="flex items-center justify-center min-h-[60vh]">
<Loader2 className="w-8 h-8 text-mc-green animate-spin" />
</div>
);
}
if (!isLoggedIn || !isAdmin) {
return (
<>
<AnimatePresence>
{toast && (
<motion.div
initial={{ opacity: 0, y: 50 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: 50 }}
className="fixed bottom-8 inset-x-0 mx-auto w-fit z-[200] bg-red-500/90 backdrop-blur-sm text-white px-6 py-3 rounded-xl text-center font-medium shadow-lg"
>
{toast}
</motion.div>
)}
</AnimatePresence>
<div className="flex items-center justify-center min-h-[60vh]">
<Loader2 className="w-8 h-8 text-mc-green animate-spin" />
</div>
</>
);
}
// 탭 설정
const tabs = [
{ id: 'console', label: '콘솔', icon: Terminal },
{ id: 'players', label: '플레이어', icon: Users },
{ id: 'settings', label: '설정', icon: Settings },
];
return (
<div className="pb-8">
{/* 토스트 */}
<AnimatePresence>
{toast && (
<motion.div
initial={{ opacity: 0, y: 50 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: 50 }}
className="fixed bottom-8 inset-x-0 mx-auto w-fit z-[200] bg-mc-green/90 backdrop-blur-sm text-white px-6 py-3 rounded-xl text-center font-medium shadow-lg"
>
{toast}
</motion.div>
)}
</AnimatePresence>
{/* 모바일용 헤더 */}
{isMobile && (
<header className="bg-mc-dark border-b border-white/10 sticky top-0 z-50 backdrop-blur-xl">
<div className="flex items-center h-14 px-4">
<Link to="/" className="p-2 -ml-2 rounded-lg text-zinc-400 hover:text-white hover:bg-white/5 transition-colors">
<ArrowLeft size={20} />
</Link>
<div className="ml-2">
<h1 className="text-lg font-bold text-white">관리자 콘솔</h1>
</div>
</div>
</header>
)}
<main className={`max-w-4xl mx-auto px-4 sm:px-6 ${isMobile ? 'py-4' : 'py-8'}`}>
{/* 데스크탑용 타이틀 */}
{!isMobile && (
<div className="mb-6">
<h1 className="text-2xl font-bold text-white">관리자 콘솔</h1>
<p className="text-sm text-zinc-500 mt-1">서버 관리 설정</p>
</div>
)}
{/* 탭 네비게이션 */}
<div className="flex gap-1 p-1 bg-zinc-900 rounded-xl mb-6">
{tabs.map(tab => (
<button
key={tab.id}
onClick={() => handleTabChange(tab.id)}
className={`flex-1 flex items-center justify-center gap-2 py-3 px-4 rounded-lg font-medium transition-all ${
activeTab === tab.id
? 'bg-zinc-800 text-white'
: 'text-zinc-400 hover:text-white hover:bg-zinc-800/50'
}`}
>
<tab.icon size={18} />
<span className={isMobile ? 'hidden sm:inline' : ''}>{tab.label}</span>
</button>
))}
</div>
{/* 탭 콘텐츠 */}
<AnimatePresence mode="wait">
{/* 콘솔 탭 */}
{activeTab === 'console' && (
<motion.div
key="console"
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -10 }}
className="space-y-4"
>
{/* 서버 성능 모니터링 */}
<div className="bg-zinc-900 border border-zinc-800 rounded-2xl p-4">
<h3 className="text-white font-medium mb-3">📊 서버 성능</h3>
<div className="grid grid-cols-1 sm:grid-cols-3 gap-3">
{/* TPS */}
<div className="bg-zinc-800/50 rounded-xl p-3">
<div className="flex items-center justify-between mb-2">
<span className="text-zinc-400 text-xs">TPS</span>
<span className={`font-bold text-sm ${
serverPerformance.tps >= 18 ? 'text-mc-green' :
serverPerformance.tps >= 15 ? 'text-yellow-400' : 'text-red-400'
}`}>
{serverPerformance.tps.toFixed(1)}
</span>
</div>
<div className="h-1.5 bg-zinc-700 rounded-full overflow-hidden">
<div
className={`h-full transition-all ${
serverPerformance.tps >= 18 ? 'bg-mc-green' :
serverPerformance.tps >= 15 ? 'bg-yellow-400' : 'bg-red-400'
}`}
style={{ width: `${Math.min(100, (serverPerformance.tps / 20) * 100)}%` }}
/>
</div>
</div>
{/* MSPT */}
<div className="bg-zinc-800/50 rounded-xl p-3">
<div className="flex items-center justify-between mb-2">
<span className="text-zinc-400 text-xs">MSPT</span>
<span className={`font-bold text-sm ${
serverPerformance.mspt <= 40 ? 'text-mc-green' :
serverPerformance.mspt <= 50 ? 'text-yellow-400' : 'text-red-400'
}`}>
{serverPerformance.mspt.toFixed(1)}ms
</span>
</div>
<div className="h-1.5 bg-zinc-700 rounded-full overflow-hidden">
<div
className={`h-full transition-all ${
serverPerformance.mspt <= 40 ? 'bg-mc-green' :
serverPerformance.mspt <= 50 ? 'bg-yellow-400' : 'bg-red-400'
}`}
style={{ width: `${Math.min(100, (serverPerformance.mspt / 50) * 100)}%` }}
/>
</div>
</div>
{/* 메모리 */}
<div className="bg-zinc-800/50 rounded-xl p-3">
<div className="flex items-center justify-between mb-2">
<span className="text-zinc-400 text-xs">메모리</span>
<span className="font-bold text-sm text-mc-diamond">
{(serverPerformance.memory.used / 1024).toFixed(1)}GB / {(serverPerformance.memory.max / 1024).toFixed(1)}GB
</span>
</div>
<div className="h-1.5 bg-zinc-700 rounded-full overflow-hidden">
<div
className={`h-full transition-all ${
serverPerformance.memory.max === 0 ? 'bg-mc-diamond' :
(serverPerformance.memory.used / serverPerformance.memory.max) > 0.9 ? 'bg-red-400' :
(serverPerformance.memory.used / serverPerformance.memory.max) > 0.7 ? 'bg-yellow-400' : 'bg-mc-diamond'
}`}
style={{ width: `${serverPerformance.memory.max === 0 ? 0 : (serverPerformance.memory.used / serverPerformance.memory.max) * 100}%` }}
/>
</div>
</div>
</div>
</div>
{/* 로그 영역 */}
<div className="bg-zinc-900 border border-zinc-800 rounded-2xl overflow-hidden relative">
<h3 className="text-white font-medium p-4 pb-3">📝 콘솔 로그</h3>
<div
ref={setLogContainerRef}
onScroll={handleLogScroll}
className="h-[500px] overflow-y-auto p-4 font-mono text-sm custom-scrollbar"
style={{ backgroundColor: '#181818', scrollBehavior: 'smooth' }}
>
{logs.map((log, index) => (
<div
key={index}
className="leading-relaxed py-1 pl-3 mb-1"
style={{ borderLeft: `3px solid ${getLogColorHex(log.type)}` }}
>
<span className={getLogColor(log.type)}>[{log.time}]</span>{' '}
{parseMinecraftColors(log.message).map((part, i) => (
<span key={i} style={{ color: part.color || getLogColorHex(log.type) }}>
{part.text}
</span>
))}
</div>
))}
<div ref={logEndRef} />
</div>
{/* 맨 아래로 스크롤 버튼 */}
<AnimatePresence>
{!isAtBottom && logs.length > 0 && (
<motion.button
initial={{ opacity: 0, scale: 0.8 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.8 }}
onClick={scrollToBottom}
className="absolute bottom-[88px] right-4 p-3 bg-mc-green hover:bg-mc-green/80 text-white rounded-full shadow-xl shadow-black/50 transition-colors z-10"
title="맨 아래로 이동"
>
<ArrowDown size={20} />
</motion.button>
)}
</AnimatePresence>
{/* 명령어 입력 */}
<div className="p-4 border-t border-zinc-800">
<div className="flex gap-2">
<div className="flex-1 flex items-center bg-zinc-800 rounded-lg px-3">
<span className="text-mc-green mr-2">{'>'}</span>
<input
type="text"
value={command}
onChange={(e) => {
setCommand(e.target.value);
setHistoryIndex(-1);
}}
onKeyDown={(e) => {
// 히스토리 네비게이션
if (e.key === 'ArrowUp' && commandHistory.length > 0) {
e.preventDefault();
const newIndex = historyIndex < commandHistory.length - 1 ? historyIndex + 1 : historyIndex;
setHistoryIndex(newIndex);
setCommand(commandHistory[commandHistory.length - 1 - newIndex] || '');
return;
}
if (e.key === 'ArrowDown') {
e.preventDefault();
const newIndex = historyIndex > 0 ? historyIndex - 1 : -1;
setHistoryIndex(newIndex);
setCommand(newIndex >= 0 ? commandHistory[commandHistory.length - 1 - newIndex] : '');
return;
}
// Enter 키로 명령어 실행
if (e.key === 'Enter' && command.trim()) {
// 히스토리에 추가
setCommandHistory(prev => [...prev.slice(-49), command]);
setHistoryIndex(-1);
handleCommand();
}
}}
placeholder="명령어 입력..."
className="flex-1 bg-transparent py-3 text-white placeholder-zinc-500 focus:outline-none font-mono"
/>
</div>
<button
onClick={() => {
if (command.trim()) {
setCommandHistory(prev => [...prev.slice(-49), command]);
setHistoryIndex(-1);
handleCommand();
}
}}
disabled={!command.trim()}
className="px-4 py-3 bg-mc-green hover:bg-mc-green/80 disabled:bg-zinc-700 disabled:text-zinc-500 text-white rounded-lg transition-colors"
>
<Send size={18} />
</button>
</div>
</div>
</div>
{/* 로그 파일 목록 */}
<div className="bg-zinc-900 border border-zinc-800 rounded-2xl p-4">
<div className="flex items-center justify-between mb-3">
<h3 className="text-white font-medium flex items-center gap-2">
<FileText size={18} className="text-zinc-400" />
로그 파일
{logFiles.length > 0 && <span className="text-xs text-zinc-500">({logFiles.length})</span>}
</h3>
<button
onClick={fetchLogFiles}
className="p-2 text-zinc-400 hover:text-mc-green hover:bg-zinc-800 rounded-lg transition-all hover:rotate-180 duration-300"
title="새로고침"
>
<RefreshCw size={16} />
</button>
</div>
{/* 필터 드롭다운 */}
<div className="flex gap-2 mb-3">
{/* 서버 선택 드롭다운 */}
<div className="relative">
<button
onClick={() => { setServerDropdownOpen(!serverDropdownOpen); setTypeDropdownOpen(false); }}
className="flex items-center gap-2 bg-zinc-800/80 text-zinc-300 text-xs px-3 py-2 rounded-lg border border-zinc-700/50 hover:bg-zinc-700/80 transition-colors"
>
<span>{selectedLogServer === 'all' ? '모든 서버' : selectedLogServer}</span>
<ChevronDown size={14} className={`text-zinc-500 transition-transform ${serverDropdownOpen ? 'rotate-180' : ''}`} />
</button>
{serverDropdownOpen && (
<div className="absolute top-full left-0 mt-1 bg-zinc-800 border border-zinc-700 rounded-lg shadow-xl z-10 min-w-[120px] py-1">
<button
onClick={() => { setSelectedLogServer('all'); setServerDropdownOpen(false); }}
className={`w-full text-left px-3 py-2 text-xs hover:bg-zinc-700 transition-colors ${selectedLogServer === 'all' ? 'text-mc-green' : 'text-zinc-300'}`}
>
모든 서버
</button>
{logServers.map(server => (
<button
key={server}
onClick={() => { setSelectedLogServer(server); setServerDropdownOpen(false); }}
className={`w-full text-left px-3 py-2 text-xs hover:bg-zinc-700 transition-colors ${selectedLogServer === server ? 'text-mc-green' : 'text-zinc-300'}`}
>
{server}
</button>
))}
</div>
)}
</div>
{/* 종류 선택 드롭다운 */}
<div className="relative">
<button
onClick={() => { setTypeDropdownOpen(!typeDropdownOpen); setServerDropdownOpen(false); }}
className="flex items-center gap-2 bg-zinc-800/80 text-zinc-300 text-xs px-3 py-2 rounded-lg border border-zinc-700/50 hover:bg-zinc-700/80 transition-colors"
>
<span>
{selectedLogType === 'all' ? '모든 종류' :
selectedLogType === 'dated' ? '📅 날짜별' :
selectedLogType === 'debug' ? '🐛 디버그' : '⚡ 최신'}
</span>
<ChevronDown size={14} className={`text-zinc-500 transition-transform ${typeDropdownOpen ? 'rotate-180' : ''}`} />
</button>
{typeDropdownOpen && (
<div className="absolute top-full left-0 mt-1 bg-zinc-800 border border-zinc-700 rounded-lg shadow-xl z-10 min-w-[120px] py-1">
{[
{ value: 'all', label: '모든 종류' },
{ value: 'dated', label: '📅 날짜별' },
{ value: 'debug', label: '🐛 디버그' },
{ value: 'latest', label: '⚡ 최신' }
].map(item => (
<button
key={item.value}
onClick={() => { setSelectedLogType(item.value); setTypeDropdownOpen(false); }}
className={`w-full text-left px-3 py-2 text-xs hover:bg-zinc-700 transition-colors ${selectedLogType === item.value ? 'text-mc-green' : 'text-zinc-300'}`}
>
{item.label}
</button>
))}
</div>
)}
</div>
</div>
<div className="space-y-2 max-h-[300px] overflow-y-auto custom-scrollbar pr-2">
{logFiles.length === 0 ? (
<p className="text-zinc-500 text-sm text-center py-4">로그 파일이 없습니다</p>
) : (
logFiles.map((file) => (
<div
key={file.id}
className="flex items-center justify-between p-3 bg-zinc-800/50 rounded-xl hover:bg-zinc-800 transition-colors cursor-pointer group"
onClick={() => viewLogContent(file)}
>
<div className="flex-1 min-w-0">
<p className="text-white text-sm truncate group-hover:text-mc-green transition-colors">{file.fileName}</p>
<p className="text-xs text-zinc-500">
{file.fileSize} {file.serverId}
<span className={`ml-1 ${
file.fileType === 'debug' ? 'text-yellow-500' :
file.fileType === 'latest' ? 'text-blue-400' : 'text-zinc-400'
}`}>
{file.fileType}
</span>
</p>
</div>
<div className="flex gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
<button
onClick={(e) => deleteLogFile(file, e)}
className="p-2 text-zinc-400 hover:text-red-500 hover:bg-zinc-700 rounded-lg transition-colors"
title="삭제"
>
<Trash2 size={16} />
</button>
<button
onClick={async (e) => {
e.stopPropagation();
const token = localStorage.getItem('token');
const response = await fetch(`/api/admin/logfile?id=${file.id}`, {
headers: { 'Authorization': `Bearer ${token}` }
});
if (response.ok) {
const blob = await response.blob();
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = file.fileName;
a.click();
URL.revokeObjectURL(url);
}
}}
className="p-2 text-zinc-400 hover:text-white hover:bg-zinc-700 rounded-lg transition-colors"
title="다운로드"
>
<Download size={16} />
</button>
</div>
</div>
))
)}
</div>
</div>
</motion.div>
)}
{/* 플레이어 탭 */}
{activeTab === 'players' && (
<motion.div
key="players"
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -10 }}
className="space-y-4"
>
{/* 필터 */}
<div className="flex gap-2">
{[
{ id: 'all', label: '전체' },
{ id: 'banned', label: '밴' },
{ id: 'whitelist', label: '화이트리스트' },
].map(filter => (
<button
key={filter.id}
onClick={() => setPlayerFilter(filter.id)}
className={`px-4 py-2 rounded-lg text-sm font-medium transition-colors ${
playerFilter === filter.id
? filter.id === 'banned' ? 'bg-red-500 text-white'
: filter.id === 'whitelist' ? 'bg-blue-500 text-white'
: 'bg-mc-green text-white'
: 'bg-zinc-800 text-zinc-400 hover:text-white'
}`}
>
{filter.label}
{filter.id === 'banned' && banList.length > 0 && (
<span className="ml-1 text-xs">({banList.length})</span>
)}
{filter.id === 'whitelist' && whitelistPlayers.length > 0 && (
<span className="ml-1 text-xs">({whitelistPlayers.length})</span>
)}
</button>
))}
</div>
{/* 화이트리스트 필터 선택 시 */}
{playerFilter === 'whitelist' ? (
<div className="bg-zinc-900 border border-zinc-800 rounded-2xl p-4">
{/* 화이트리스트 On/Off 토글 */}
<div className="flex items-center justify-between mb-4">
<span className="text-white font-medium">화이트리스트 활성화</span>
<button
onClick={toggleWhitelist}
className={`w-12 h-7 rounded-full relative transition-colors ${
whitelistEnabled ? 'bg-mc-green' : 'bg-zinc-600'
}`}
>
<div className={`absolute top-1 w-5 h-5 rounded-full bg-white transition-transform ${
whitelistEnabled ? 'left-6' : 'left-1'
}`} />
</button>
</div>
{/* 플레이어 추가 */}
<div className="flex gap-2 mb-4">
<input
type="text"
value={newWhitelistPlayer}
onChange={(e) => setNewWhitelistPlayer(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') {
addWhitelistPlayer(newWhitelistPlayer);
}
}}
placeholder="플레이어 이름 입력..."
className="flex-1 bg-zinc-800 border border-zinc-700 rounded-lg px-3 py-2 text-white text-sm placeholder-zinc-500 focus:outline-none focus:border-mc-green/50"
/>
<button
onClick={() => addWhitelistPlayer(newWhitelistPlayer)}
className="px-4 py-2 bg-mc-green text-white rounded-lg font-medium hover:bg-mc-green/80 transition-colors text-sm"
>
추가
</button>
</div>
{/* 화이트리스트 플레이어 그리드 */}
{whitelistPlayers.length > 0 ? (
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 gap-3">
{whitelistPlayers.map(player => (
<div
key={player.uuid}
className="bg-zinc-800/50 rounded-xl p-3 text-center group hover:bg-zinc-800 transition-colors relative"
>
<button
onClick={() => setWhitelistRemoveTarget(player)}
className="absolute top-2 right-2 p-1 text-zinc-500 hover:text-red-400 opacity-0 group-hover:opacity-100 transition-all"
title="제거"
>
<X size={14} />
</button>
<CachedSkin
uuid={player.uuid}
name={player.name}
type="body"
size={100}
className="h-32 mx-auto mt-2 mb-2 drop-shadow-lg"
/>
<span className="text-white text-sm font-medium block truncate">{player.name}</span>
</div>
))}
</div>
) : (
<div className="text-zinc-500 text-sm py-8 text-center">
화이트리스트가 비어있습니다
</div>
)}
</div>
) : (
/* 플레이어 그리드 */
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 gap-4">
{filteredPlayers.map((player) => (
<div
key={player.uuid}
className="bg-zinc-900 border border-zinc-800 rounded-2xl p-4 text-center relative group"
>
{/* OP 뱃지 */}
{player.isOp && (
<div className="absolute top-2 right-2 bg-yellow-500 p-1 rounded-lg">
<Crown size={12} className="text-black" />
</div>
)}
{/* 온/오프라인 표시 */}
<div className={`absolute top-2 left-2 w-3 h-3 rounded-full ${player.isOnline ? 'bg-green-500' : 'bg-zinc-600'}`} />
{/* 전신 아바타 */}
<CachedSkin
uuid={player.uuid}
name={player.name}
type="body"
size={100}
className="w-16 h-32 mx-auto mb-2 drop-shadow-lg"
/>
<p className="text-white font-medium">{player.displayName || player.name}</p>
<p className="text-xs text-zinc-500">{player.name}</p>
{player.isBanned && (
<span className="text-xs text-red-400 block">차단됨</span>
)}
{/* 액션 버튼 - mt-2 추가 */}
<div className="flex justify-center gap-1 mt-2">
<button
onClick={() => {
setSelectedPlayer(player);
setDialogAction('op');
setShowPlayerDialog(true);
}}
className={`p-2 rounded-lg transition-colors ${
player.isOp
? 'bg-yellow-500/20 text-yellow-500 hover:bg-yellow-500/30'
: 'bg-zinc-800 text-zinc-400 hover:text-yellow-500'
}`}
title="OP"
>
<Crown size={16} />
</button>
{player.isOnline && (
<button
onClick={() => {
setSelectedPlayer(player);
setDialogAction('kick');
setShowPlayerDialog(true);
}}
className="p-2 bg-zinc-800 text-zinc-400 hover:text-orange-500 rounded-lg transition-colors"
title="킥"
>
<UserX size={16} />
</button>
)}
{player.isBanned ? (
<button
onClick={() => {
setSelectedPlayer(player);
setDialogAction('unban');
setShowPlayerDialog(true);
}}
className="p-2 bg-green-500/20 text-green-500 hover:bg-green-500/30 rounded-lg transition-colors"
title="차단 해제"
>
<Check size={16} />
</button>
) : (
<button
onClick={() => {
setSelectedPlayer(player);
setDialogAction('ban');
setShowPlayerDialog(true);
}}
className="p-2 bg-zinc-800 text-zinc-400 hover:text-red-500 rounded-lg transition-colors"
title="밴"
>
<Ban size={16} />
</button>
)}
</div>
</div>
))}
</div>
)}
</motion.div>
)}
{/* 설정 탭 */}
{activeTab === 'settings' && (
<motion.div
key="settings"
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -10 }}
className="space-y-4"
>
{/* 게임규칙 */}
<div className="bg-zinc-900 border border-zinc-800 rounded-2xl p-4">
<h3 className="text-white font-medium mb-4 flex items-center gap-2">
🎮 게임규칙
<span className="text-sm font-normal text-zinc-500">
({Object.keys(gameRules).length})
</span>
</h3>
{Object.keys(gameRules).length > 0 ? (
<div className="max-h-80 overflow-y-auto custom-scrollbar pr-2">
<div className="grid grid-cols-1 sm:grid-cols-2 gap-2">
{Object.entries(gameRules).map(([rule, value]) => (
<button
key={rule}
onClick={() => toggleGamerule(rule)}
className={`flex items-center justify-between p-3 rounded-xl transition-colors border ${
value ? 'bg-mc-green/20 border-mc-green/30' : 'bg-zinc-800/50 border-transparent hover:border-zinc-700'
}`}
>
<Tooltip
content={gameRuleDescriptions[rule]?.description || gameRuleDescriptions[rule]?.name || "설명이 없습니다."}
className="min-w-0 flex-1 mr-2 text-left"
>
<span className="text-white text-sm truncate block cursor-default hover:text-zinc-300 transition-colors">
{rule}
</span>
</Tooltip>
<div className={`w-10 h-6 rounded-full relative transition-colors shrink-0 ${value ? 'bg-mc-green' : 'bg-zinc-600'}`}>
<div className={`absolute top-1 w-4 h-4 rounded-full bg-white transition-transform ${value ? 'left-5' : 'left-1'}`} />
</div>
</button>
))}
</div>
</div>
) : (
<div className="text-zinc-500 text-sm py-4 text-center">
게임 규칙을 불러오는 ...
</div>
)}
</div>
{/* 난이도 */}
<div className="bg-zinc-900 border border-zinc-800 rounded-2xl p-4">
<h3 className="text-white font-medium mb-4"> 난이도</h3>
<div className="flex gap-2 flex-wrap">
{[
{ id: 'peaceful', label: '평화로움' },
{ id: 'easy', label: '쉬움' },
{ id: 'normal', label: '보통' },
{ id: 'hard', label: '어려움' },
].map(d => (
<button
key={d.id}
onClick={async () => {
setDifficulty(d.id);
try {
const token = localStorage.getItem('token');
await fetch('/api/admin/command', {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${token}` },
body: JSON.stringify({ command: `difficulty ${d.id}` })
});
setToast(`난이도: ${d.label}`);
} catch (error) {
setToast('난이도 변경 실패');
}
}}
className={`flex-1 min-w-[80px] py-3 px-4 rounded-xl font-medium transition-colors ${
difficulty === d.id
? 'bg-mc-green text-white'
: 'bg-zinc-800 text-zinc-400 hover:text-white'
}`}
>
{d.label}
</button>
))}
</div>
</div>
{/* 시간 */}
<div className="bg-zinc-900 border border-zinc-800 rounded-2xl p-4">
<h3 className="text-white font-medium mb-4">🕐 시간</h3>
<div className="flex gap-2 flex-wrap">
{[
{ id: 'day', label: '아침', icon: Sun, time: '1000' },
{ id: 'noon', label: '낮', icon: Sun, time: '6000' },
{ id: 'night', label: '밤', icon: Moon, time: '13000' },
].map(t => (
<button
key={t.id}
onClick={async () => {
setTimeOfDay(t.id);
try {
const token = localStorage.getItem('token');
await fetch('/api/admin/command', {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${token}` },
body: JSON.stringify({ command: `time set ${t.time}` })
});
setToast(`시간: ${t.label}`);
} catch (error) {
setToast('시간 변경 실패');
}
}}
className={`flex-1 flex items-center justify-center gap-2 py-3 px-4 rounded-xl font-medium transition-colors ${
timeOfDay === t.id
? 'bg-blue-500 text-white'
: 'bg-zinc-800 text-zinc-400 hover:text-white'
}`}
>
<t.icon size={18} />
{t.label}
</button>
))}
</div>
</div>
{/* 날씨 */}
<div className="bg-zinc-900 border border-zinc-800 rounded-2xl p-4">
<h3 className="text-white font-medium mb-4">🌤 날씨</h3>
<div className="flex gap-2 flex-wrap">
{[
{ id: 'clear', label: '맑음', icon: Sun },
{ id: 'rain', label: '비', icon: CloudRain },
{ id: 'thunder', label: '천둥', icon: CloudLightning },
].map(w => (
<button
key={w.id}
onClick={async () => {
setWeather(w.id);
try {
const token = localStorage.getItem('token');
await fetch('/api/admin/command', {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${token}` },
body: JSON.stringify({ command: `weather ${w.id}` })
});
setToast(`날씨: ${w.label}`);
} catch (error) {
setToast('날씨 변경 실패');
}
}}
className={`flex-1 flex items-center justify-center gap-2 py-3 px-4 rounded-xl font-medium transition-colors ${
weather === w.id
? 'bg-cyan-500 text-white'
: 'bg-zinc-800 text-zinc-400 hover:text-white'
}`}
>
<w.icon size={18} />
{w.label}
</button>
))}
</div>
</div>
</motion.div>
)}
</AnimatePresence>
</main>
{/* 플레이어 액션 다이얼로그 */}
<AnimatePresence>
{showPlayerDialog && selectedPlayer && (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
className="fixed inset-0 bg-black/60 backdrop-blur-sm z-[100] flex items-center justify-center p-4"
onClick={() => setShowPlayerDialog(false)}
>
<motion.div
initial={{ scale: 0.9, opacity: 0 }}
animate={{ scale: 1, opacity: 1 }}
exit={{ scale: 0.9, opacity: 0 }}
className="bg-zinc-900 border border-zinc-700 rounded-2xl p-6 w-full max-w-md"
onClick={(e) => e.stopPropagation()}
>
<h3 className="text-lg font-semibold text-white mb-4 flex items-center gap-2">
{dialogAction === 'kick' && <><UserX size={20} className="text-orange-500" /> 플레이어 </>}
{dialogAction === 'ban' && <><Ban size={20} className="text-red-500" /> 플레이어 </>}
{dialogAction === 'unban' && <><Check size={20} className="text-green-500" /> 차단 해제</>}
{dialogAction === 'op' && <><Crown size={20} className="text-yellow-500" /> OP {selectedPlayer?.isOp ? '해제' : '부여'}</>}
</h3>
<div className="flex items-center gap-4 mb-4">
<CachedSkin
uuid={selectedPlayer.uuid}
name={selectedPlayer.name}
type="body"
size={60}
className="w-10 h-20"
/>
<div>
<p className="text-white font-medium">{selectedPlayer.displayName || selectedPlayer.name}</p>
<p className="text-xs text-zinc-500">{selectedPlayer.uuid}</p>
</div>
</div>
{(dialogAction === 'kick' || dialogAction === 'ban') && (
<input
type="text"
value={actionReason}
onChange={(e) => setActionReason(e.target.value)}
placeholder="사유 (선택)"
className="w-full bg-zinc-800 rounded-xl p-3 text-white placeholder-zinc-500 focus:outline-none focus:ring-2 focus:ring-mc-green/50 mb-4"
/>
)}
<div className="flex gap-2">
<button
onClick={() => setShowPlayerDialog(false)}
className="flex-1 py-3 bg-zinc-800 hover:bg-zinc-700 text-white rounded-xl transition-colors"
>
취소
</button>
<button
onClick={handlePlayerAction}
className={`flex-1 py-3 font-medium rounded-xl transition-colors ${
dialogAction === 'kick' ? 'bg-orange-500 hover:bg-orange-600 text-white' :
dialogAction === 'ban' ? 'bg-red-500 hover:bg-red-600 text-white' :
dialogAction === 'unban' ? 'bg-green-500 hover:bg-green-600 text-white' :
'bg-yellow-500 hover:bg-yellow-600 text-black'
}`}
>
확인
</button>
</div>
</motion.div>
</motion.div>
)}
{/* 로그 뷰어 다이얼로그 */}
{logViewerOpen && (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
className="fixed inset-0 bg-black/80 backdrop-blur-sm flex items-center justify-center z-50 p-4"
onClick={() => setLogViewerOpen(false)}
>
<motion.div
initial={{ scale: 0.95, opacity: 0 }}
animate={{ scale: 1, opacity: 1 }}
exit={{ scale: 0.95, opacity: 0 }}
className="bg-zinc-900 border border-zinc-800 rounded-2xl p-6 w-full max-w-4xl max-h-[80vh] flex flex-col"
onClick={(e) => e.stopPropagation()}
>
{/* 헤더 */}
<div className="flex items-center justify-between mb-4">
<div className="flex items-center gap-3">
<FileText className="text-mc-green" size={24} />
<div>
<h3 className="text-white font-medium">{viewingLog?.fileName}</h3>
<p className="text-xs text-zinc-500">
{viewingLog?.fileSize} {viewingLog?.serverId} {viewingLog?.fileType}
</p>
</div>
</div>
<button
onClick={() => setLogViewerOpen(false)}
className="p-2 text-zinc-400 hover:text-white hover:bg-zinc-800 rounded-lg transition-colors"
>
<X size={20} />
</button>
</div>
{/* 로그 내용 */}
<div className="flex-1 bg-black/50 rounded-xl p-4 overflow-auto custom-scrollbar font-mono text-xs pr-2" style={{ backgroundColor: '#181818' }}>
{logLoading ? (
<div className="flex items-center justify-center h-full">
<Loader2 className="animate-spin text-mc-green" size={32} />
</div>
) : (
<div className="whitespace-pre-wrap break-words leading-5">
{logContent.split('\n').map((line, idx) => {
// 로그 레벨 추출 (INFO, WARN, ERROR)
const logType = line.includes('/ERROR]') || line.includes('[ERROR]') ? 'error' :
line.includes('/WARN]') || line.includes('[WARN]') ? 'warning' : 'info';
const colorHex = logType === 'error' ? '#f87171' :
logType === 'warning' ? '#facc15' : '#d4d4d8';
return (
<div
key={idx}
className="py-1 pl-3 mb-1"
style={{ borderLeft: `3px solid ${colorHex}` }}
>
{parseMinecraftColors(line).map((part, pIdx) => (
<span key={pIdx} style={{ color: part.color || colorHex }}>{part.text}</span>
))}
</div>
);
})}
</div>
)}
</div>
{/* 하단 버튼 */}
<div className="flex justify-end gap-2 mt-4">
<button
onClick={async () => {
if (!viewingLog) return;
const token = localStorage.getItem('token');
const response = await fetch(`/api/admin/logfile?id=${viewingLog.id}`, {
headers: { 'Authorization': `Bearer ${token}` }
});
if (response.ok) {
const blob = await response.blob();
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = viewingLog.fileName;
a.click();
URL.revokeObjectURL(url);
}
}}
className="flex items-center gap-2 px-4 py-2 bg-zinc-800 hover:bg-zinc-700 text-white rounded-lg transition-colors"
>
<Download size={16} />
다운로드
</button>
<button
onClick={() => setLogViewerOpen(false)}
className="px-4 py-2 bg-mc-green hover:bg-mc-green-dark text-white rounded-lg transition-colors"
>
닫기
</button>
</div>
</motion.div>
</motion.div>
)}
</AnimatePresence>
{/* 화이트리스트 삭제 확인 다이얼로그 */}
<AnimatePresence>
{whitelistRemoveTarget && (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
className="fixed inset-0 bg-black/80 backdrop-blur-sm z-50 flex items-center justify-center p-4"
onClick={() => setWhitelistRemoveTarget(null)}
>
<motion.div
initial={{ scale: 0.9, opacity: 0 }}
animate={{ scale: 1, opacity: 1 }}
exit={{ scale: 0.9, opacity: 0 }}
className="bg-zinc-900 border border-zinc-800 rounded-2xl p-6 max-w-sm w-full"
onClick={e => e.stopPropagation()}
>
<div className="text-center mb-6">
<CachedSkin
uuid={whitelistRemoveTarget.uuid}
name={whitelistRemoveTarget.name}
type="body"
size={80}
className="h-20 mx-auto mb-3"
/>
<h3 className="text-white text-lg font-bold mb-2">화이트리스트 제거</h3>
<p className="text-zinc-400 text-sm">
<span className="text-white font-medium">{whitelistRemoveTarget.name}</span>
화이트리스트에서 제거하시겠습니까?
</p>
</div>
<div className="flex gap-3">
<button
onClick={() => setWhitelistRemoveTarget(null)}
className="flex-1 px-4 py-2.5 bg-zinc-800 hover:bg-zinc-700 text-white rounded-xl font-medium transition-colors"
>
취소
</button>
<button
onClick={() => removeWhitelistPlayer(whitelistRemoveTarget.name)}
className="flex-1 px-4 py-2.5 bg-red-500 hover:bg-red-600 text-white rounded-xl font-medium transition-colors"
>
제거
</button>
</div>
</motion.div>
</motion.div>
)}
</AnimatePresence>
</div>
);
}