/** * 관리자 페이지 * - 탭 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'; // 더미 로그 데이터 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 사용) // 더미 게임규칙 데이터 const DUMMY_GAMERULES = [ { name: 'keepInventory', value: false, label: '인벤토리 유지' }, { name: 'doDaylightCycle', value: true, label: '낮/밤 주기' }, { name: 'doMobSpawning', value: true, label: '몹 스폰' }, { name: 'doFireTick', value: true, label: '불 번짐' }, { name: 'mobGriefing', value: true, label: '몹 그리핑' }, { name: 'pvp', value: true, label: 'PvP' }, ]; export default function Admin({ isMobile = false }) { const { isLoggedIn, isAdmin, user, loading } = useAuth(); const navigate = useNavigate(); const location = useLocation(); const [toast, setToast] = useState(null); // 탭 상태 const [activeTab, setActiveTab] = useState('console'); // 콘솔 관련 상태 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(DUMMY_GAMERULES); const [difficulty, setDifficulty] = useState('normal'); const [timeOfDay, setTimeOfDay] = useState('day'); const [weather, setWeather] = useState('clear'); // 권한 확인 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); } }, []); // 플레이어 탭 활성화 시 데이터 로드 useEffect(() => { if (activeTab === 'players' && isAdmin) { fetchPlayers(); fetchBanList(); } }, [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); } }); return () => { socket.disconnect(); }; }, [isAdmin]); // 명령어 실행 (실제 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(''); }; // 게임규칙 토글 const toggleGamerule = (name) => { setGamerules(prev => prev.map(rule => rule.name === name ? { ...rule, value: !rule.value } : rule )); 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.filter(p => { if (playerFilter === 'online') return p.isOnline; if (playerFilter === 'offline') return !p.isOnline; return true; }); 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' && ( {/* 로그 영역 */}
{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: 'online', label: '온라인' }, { id: 'offline', label: '오프라인' }, { id: 'banned', label: '차단됨' }, ].map(filter => ( ))}
{/* 플레이어 그리드 */}
{filteredPlayers.map((player) => (
{/* OP 뱃지 */} {player.isOp && (
)} {/* 온/오프라인 표시 */}
{/* 전신 아바타 */} {player.name}

{player.displayName || player.name}

{player.name}

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

🎮 게임규칙

{gamerules.map(rule => ( ))}
{/* 난이도 */}

⚔️ 난이도

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

🕐 시간

{[ { id: 'day', label: '아침', icon: Sun }, { id: 'noon', label: '낮', icon: Sun }, { id: 'night', label: '밤', icon: Moon }, ].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.name}

{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} ))}
); })}
)}
{/* 하단 버튼 */}
)}
); }