/** * 관리자 페이지 * - 탭 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'; // 스티브 기본 스킨 (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 ( {name} { 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 (
); } if (!isLoggedIn || !isAdmin) { return ( <> {toast && ( {toast} )}
); } // 탭 설정 const tabs = [ { id: 'console', label: '콘솔', icon: Terminal }, { id: 'players', label: '플레이어', icon: Users }, { id: 'settings', label: '설정', icon: Settings }, ]; return (
{/* 토스트 */} {toast && ( {toast} )} {/* 모바일용 헤더 */} {isMobile && (

관리자 콘솔

)}
{/* 데스크탑용 타이틀 */} {!isMobile && (

관리자 콘솔

서버 관리 및 설정

)} {/* 탭 네비게이션 */}
{tabs.map(tab => ( ))}
{/* 탭 콘텐츠 */} {/* 콘솔 탭 */} {activeTab === 'console' && ( {/* 서버 성능 모니터링 */}

📊 서버 성능

{/* TPS */}
TPS = 18 ? 'text-mc-green' : serverPerformance.tps >= 15 ? 'text-yellow-400' : 'text-red-400' }`}> {serverPerformance.tps.toFixed(1)}
= 18 ? 'bg-mc-green' : serverPerformance.tps >= 15 ? 'bg-yellow-400' : 'bg-red-400' }`} style={{ width: `${Math.min(100, (serverPerformance.tps / 20) * 100)}%` }} />
{/* MSPT */}
MSPT {serverPerformance.mspt.toFixed(1)}ms
{/* 메모리 */}
메모리 {(serverPerformance.memory.used / 1024).toFixed(1)}GB / {(serverPerformance.memory.max / 1024).toFixed(1)}GB
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}%` }} />
{/* 로그 영역 */}

📝 콘솔 로그

{logs.map((log, index) => (
[{log.time}]{' '} {parseMinecraftColors(log.message).map((part, i) => ( {part.text} ))}
))}
{/* 맨 아래로 스크롤 버튼 */} {!isAtBottom && logs.length > 0 && ( )} {/* 명령어 입력 */}
{'>'} { 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" />
{/* 로그 파일 목록 */}

로그 파일 {logFiles.length > 0 && ({logFiles.length})}

{/* 필터 드롭다운 */}
{/* 서버 선택 드롭다운 */}
{serverDropdownOpen && (
{logServers.map(server => ( ))}
)}
{/* 종류 선택 드롭다운 */}
{typeDropdownOpen && (
{[ { value: 'all', label: '모든 종류' }, { value: 'dated', label: '📅 날짜별' }, { value: 'debug', label: '🐛 디버그' }, { value: 'latest', label: '⚡ 최신' } ].map(item => ( ))}
)}
{logFiles.length === 0 ? (

로그 파일이 없습니다

) : ( logFiles.map((file) => (
viewLogContent(file)} >

{file.fileName}

{file.fileSize} • {file.serverId} • {file.fileType}

)) )}
)} {/* 플레이어 탭 */} {activeTab === 'players' && ( {/* 필터 */}
{[ { id: 'all', label: '전체' }, { id: 'banned', label: '밴' }, { id: 'whitelist', label: '화이트리스트' }, ].map(filter => ( ))}
{/* 화이트리스트 필터 선택 시 */} {playerFilter === 'whitelist' ? (
{/* 화이트리스트 On/Off 토글 */}
화이트리스트 활성화
{/* 플레이어 추가 */}
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" />
{/* 화이트리스트 플레이어 그리드 */} {whitelistPlayers.length > 0 ? (
{whitelistPlayers.map(player => (
{player.name}
))}
) : (
화이트리스트가 비어있습니다
)}
) : ( /* 플레이어 그리드 */
{filteredPlayers.map((player) => (
{/* OP 뱃지 */} {player.isOp && (
)} {/* 온/오프라인 표시 */}
{/* 전신 아바타 */}

{player.displayName || player.name}

{player.name}

{player.isBanned && ( 차단됨 )} {/* 액션 버튼 - mt-2 추가 */}
{player.isOnline && ( )} {player.isBanned ? ( ) : ( )}
))}
)} )} {/* 설정 탭 */} {activeTab === 'settings' && ( {/* 게임규칙 */}

🎮 게임규칙 ({Object.keys(gameRules).length})

{Object.keys(gameRules).length > 0 ? (
{Object.entries(gameRules).map(([rule, value]) => ( ))}
) : (
게임 규칙을 불러오는 중...
)}
{/* 난이도 */}

⚔️ 난이도

{[ { id: 'peaceful', label: '평화로움' }, { id: 'easy', label: '쉬움' }, { id: 'normal', label: '보통' }, { id: 'hard', label: '어려움' }, ].map(d => ( ))}
{/* 시간 */}

🕐 시간

{[ { id: 'day', label: '아침', icon: Sun, time: '1000' }, { id: 'noon', label: '낮', icon: Sun, time: '6000' }, { id: 'night', label: '밤', icon: Moon, time: '13000' }, ].map(t => ( ))}
{/* 날씨 */}

🌤️ 날씨

{[ { id: 'clear', label: '맑음', icon: Sun }, { id: 'rain', label: '비', icon: CloudRain }, { id: 'thunder', label: '천둥', icon: CloudLightning }, ].map(w => ( ))}
)}
{/* 플레이어 액션 다이얼로그 */} {showPlayerDialog && selectedPlayer && ( setShowPlayerDialog(false)} > e.stopPropagation()} >

{dialogAction === 'kick' && <> 플레이어 킥} {dialogAction === 'ban' && <> 플레이어 밴} {dialogAction === 'unban' && <> 차단 해제} {dialogAction === 'op' && <> OP {selectedPlayer?.isOp ? '해제' : '부여'}}

{selectedPlayer.displayName || selectedPlayer.name}

{selectedPlayer.uuid}

{(dialogAction === 'kick' || dialogAction === 'ban') && ( 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" /> )}
)} {/* 로그 뷰어 다이얼로그 */} {logViewerOpen && ( setLogViewerOpen(false)} > e.stopPropagation()} > {/* 헤더 */}

{viewingLog?.fileName}

{viewingLog?.fileSize} • {viewingLog?.serverId} • {viewingLog?.fileType}

{/* 로그 내용 */}
{logLoading ? (
) : (
{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 (
{parseMinecraftColors(line).map((part, pIdx) => ( {part.text} ))}
); })}
)}
{/* 하단 버튼 */}
)}
{/* 화이트리스트 삭제 확인 다이얼로그 */} {whitelistRemoveTarget && ( setWhitelistRemoveTarget(null)} > e.stopPropagation()} >

화이트리스트 제거

{whitelistRemoveTarget.name}님을 화이트리스트에서 제거하시겠습니까?

)}
); }