minecraft-web/frontend/src/pages/Admin.jsx
caadiq 6fe6d0dda0 feat: 콘솔 스크롤 개선 및 닉네임 실시간 동기화 구현
- 콘솔 탭 스크롤 동작 개선 (조건부 자동 스크롤, 맨 아래로 버튼)
- 탭 전환 시 레이아웃 쉬프트 방지 (scrollbar-gutter: stable)
- 맨 아래로 버튼에 그림자 효과 추가
- Sidebar에 소켓 기반 닉네임 실시간 동기화 로직 추가
- /link/status API에서 displayName 사용하도록 수정
2025-12-23 10:07:34 +09:00

1292 lines
53 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';
// 더미 로그 데이터
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 (
<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 left-1/2 -translate-x-1/2 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 left-1/2 -translate-x-1/2 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={() => setActiveTab(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 overflow-hidden relative">
<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: 'online', label: '온라인' },
{ id: 'offline', label: '오프라인' },
{ id: 'banned', 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' : '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>
)}
</button>
))}
</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'}`} />
{/* 전신 아바타 */}
<img
src={`https://mc-heads.net/body/${player.uuid}/100`}
alt={player.name}
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">🎮 게임규칙</h3>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-2">
{gamerules.map(rule => (
<button
key={rule.name}
onClick={() => toggleGamerule(rule.name)}
className={`flex items-center justify-between p-3 rounded-xl transition-colors border ${
rule.value ? 'bg-mc-green/20 border-mc-green/30' : 'bg-zinc-800/50 border-transparent'
}`}
>
<span className="text-white text-sm">{rule.label}</span>
<div className={`w-10 h-6 rounded-full relative transition-colors ${rule.value ? 'bg-mc-green' : 'bg-zinc-600'}`}>
<div className={`absolute top-1 w-4 h-4 rounded-full bg-white transition-transform ${rule.value ? 'left-5' : 'left-1'}`} />
</div>
</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: 'peaceful', label: '평화로움' },
{ id: 'easy', label: '쉬움' },
{ id: 'normal', label: '보통' },
{ id: 'hard', label: '어려움' },
].map(d => (
<button
key={d.id}
onClick={() => { setDifficulty(d.id); 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 },
{ id: 'noon', label: '낮', icon: Sun },
{ id: 'night', label: '밤', icon: Moon },
].map(t => (
<button
key={t.id}
onClick={() => { setTimeOfDay(t.id); 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={() => { setWeather(w.id); 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">
<img
src={`https://mc-heads.net/body/${selectedPlayer.uuid}/60`}
alt={selectedPlayer.name}
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>
</div>
);
}