- 콘솔 탭 스크롤 동작 개선 (조건부 자동 스크롤, 맨 아래로 버튼) - 탭 전환 시 레이아웃 쉬프트 방지 (scrollbar-gutter: stable) - 맨 아래로 버튼에 그림자 효과 추가 - Sidebar에 소켓 기반 닉네임 실시간 동기화 로직 추가 - /link/status API에서 displayName 사용하도록 수정
1292 lines
53 KiB
JavaScript
1292 lines
53 KiB
JavaScript
/**
|
||
* 관리자 페이지
|
||
* - 탭 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>
|
||
);
|
||
}
|