/**
* 관리자 페이지
* - 탭 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, Package, Upload, Plus, Pencil
} from 'lucide-react';
import { motion, AnimatePresence } from 'framer-motion';
import { io } from 'socket.io-client';
import Tooltip from '../components/Tooltip';
// 스티브 기본 스킨 (Base64)
const STEVE_BODY_BASE64 = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAACAAAAAwCAYAAABwrHhvAAABWklEQVRYR+2WsQ3CMBBFfxBFJBpGYA8GYA4WoKAio0BFQUGV0pEo6GMGdmAE0kQKcq785jg5O7HOckVx0cXv/t/+FxuiH/uh+ckfATgNwKkTmDuBJQG4DsAzAbRG4E4ARyRYJoLrRDCNCBIC8AkguQVuGMBNhUkJAG2pj3MA3CVwVgBuLXCqAKAGvLkGIBKC0wJgWUCNACA/AIYLhSoXpuoEHGMo6wIaJIRBQvBBQiyI8EpuLOAZBqCYYCQAnAhghACKCYYCQEGHc4wDkIkQEYAQgDQBJELoAohpQViEoC4gVQJhAmBaBL4BsKyBYRH4BYBYArYB3N8EpjYAS8DCDUB9Q+YGwK4FaBdBigCkC2grQvBpIWQDkEYA61pwLQK7ACQBQGQEMksAkQKQcBpIE4C1ECISAJ0F7E4AsZdBiC4A6S2AWAvsBJAgALYF6hKA+hZgAvhXwP8D8ANSsjw4hB3aNwAAAABJRU5ErkJggg==';
// 캐시된 스킨 컴포넌트 - S3 캐싱 API 사용
const CachedSkin = ({ uuid, name, type = 'body', size = 100, className }) => {
const [src, setSrc] = useState(STEVE_BODY_BASE64);
const fallbackUrl = `https://mc-heads.net/${type}/${uuid}/${size}`;
useEffect(() => {
if (!uuid) return;
fetch(`/link/skin/${type}/${uuid}/${size}`)
.then(res => res.json())
.then(data => {
if (data.url) {
const img = new Image();
img.onload = () => setSrc(data.url);
img.onerror = () => setSrc(fallbackUrl);
img.src = data.url;
} else {
setSrc(fallbackUrl);
}
})
.catch(() => setSrc(fallbackUrl));
}, [uuid, type, size, fallbackUrl]);
return (
{ e.target.src = fallbackUrl; }}
/>
);
};
export default function Admin({ isMobile = false }) {
const { isLoggedIn, isAdmin, user, loading } = useAuth();
const navigate = useNavigate();
const location = useLocation();
const [toast, setToastState] = useState(null);
// 토스트 헬퍼 함수 (type: 'success' | 'warning' | 'error' | boolean)
const setToast = (message, type = 'success') => {
// 이전 호환성: true면 error, false면 success
const colorType = type === true ? 'error' : (type === false ? 'success' : type);
setToastState({ message, type: colorType });
};
// 탭 상태 (URL 해시에서 초기값 로드)
const getInitialTab = () => {
const hash = window.location.hash.replace('#', '');
return ['console', 'players', 'modpack', 'settings'].includes(hash) ? hash : 'console';
};
const [activeTab, setActiveTab] = useState(getInitialTab);
// 탭 변경 시 URL 해시 업데이트
const handleTabChange = (tab) => {
setActiveTab(tab);
window.location.hash = tab;
};
// 콘솔 관련 상태
const [logs, setLogs] = useState([]);
const [command, setCommand] = useState('');
const [logFiles, setLogFiles] = useState([]);
const [logServers, setLogServers] = useState([]); // 서버 ID 목록
const [selectedLogServer, setSelectedLogServer] = useState('all'); // 선택된 서버
const [selectedLogType, setSelectedLogType] = useState('all'); // 로그 종류 필터
const [logViewerOpen, setLogViewerOpen] = useState(false); // 로그 뷰어 다이얼로그
const [viewingLog, setViewingLog] = useState(null); // 보고 있는 로그 파일
const [logContent, setLogContent] = useState(''); // 로그 내용
const [logLoading, setLogLoading] = useState(false); // 로그 로딩
const [serverDropdownOpen, setServerDropdownOpen] = useState(false); // 서버 드롭다운
const [typeDropdownOpen, setTypeDropdownOpen] = useState(false); // 타입 드롭다운
const logEndRef = useRef(null);
const logContainerRef = useRef(null);
const isInitialLoad = useRef(true);
const [isAtBottom, setIsAtBottom] = useState(true); // 스크롤이 맨 아래에 있는지 추적
// 명령어 히스토리
const [commandHistory, setCommandHistory] = useState([]);
const [historyIndex, setHistoryIndex] = useState(-1);
// 플레이어 관련 상태
const [players, setPlayers] = useState([]);
const [banList, setBanList] = useState([]); // 밴 목록
const [playerFilter, setPlayerFilter] = useState('all'); // all, online, offline, banned
const [selectedPlayer, setSelectedPlayer] = useState(null);
const [showPlayerDialog, setShowPlayerDialog] = useState(false);
const [dialogAction, setDialogAction] = useState(null); // kick, ban, op, unban
const [actionReason, setActionReason] = useState('');
// 설정 관련 상태
const [gameRules, setGameRules] = useState({}); // 소켓에서 가져온 게임 규칙
const [gameRuleDescriptions, setGameRuleDescriptions] = useState({}); // 게임 규칙 설명
const [difficulty, setDifficulty] = useState('normal');
const [timeOfDay, setTimeOfDay] = useState('day');
const [weather, setWeather] = useState('clear');
// 화이트리스트 상태 (API 연동)
const [whitelistEnabled, setWhitelistEnabled] = useState(false);
const [whitelistPlayers, setWhitelistPlayers] = useState([]);
const [newWhitelistPlayer, setNewWhitelistPlayer] = useState('');
const [whitelistRemoveTarget, setWhitelistRemoveTarget] = useState(null); // 삭제 확인 다이얼로그용
const [whitelistLoading, setWhitelistLoading] = useState(false);
// 성능 모니터링 상태 (소켓에서 업데이트)
const [serverPerformance, setServerPerformance] = useState({
tps: 0,
mspt: 0,
memory: { used: 0, max: 0 },
});
// 모드팩 관리 상태
const [showModpackDialog, setShowModpackDialog] = useState(false);
const [modpackDialogMode, setModpackDialogMode] = useState('upload'); // 'upload' | 'edit'
const [editingModpack, setEditingModpack] = useState(null);
const [modpackForm, setModpackForm] = useState({ changelog: '' });
const [modpackFile, setModpackFile] = useState(null); // 업로드할 파일
const [modpacks, setModpacks] = useState([]);
const [modpackDeleteTarget, setModpackDeleteTarget] = useState(null); // 삭제 확인 다이얼로그용
const [modpackLoading, setModpackLoading] = useState(false); // 업로드/삭제 로딩
const [isDragging, setIsDragging] = useState(false); // 드래그 상태
// 모드 번역 상태
const [modTranslations, setModTranslations] = useState([]); // 업로드된 모드 목록
const [translationLoading, setTranslationLoading] = useState(false);
const [isTranslationDragging, setIsTranslationDragging] = useState(false);
// 파일 상태: { name, status: 'pending' | 'processing' | 'success' | 'error', error?: string, progress?: number }
const [pendingFiles, setPendingFiles] = useState([]);
const [isModListExpanded, setIsModListExpanded] = useState(false); // 등록된 모드 목록 펼치기/접기
const [deleteModDialog, setDeleteModDialog] = useState({ show: false, modId: null }); // 모드 삭제 확인 다이얼로그
const [clearingFiles, setClearingFiles] = useState(false); // 완료 항목 삭제 중 (애니메이션용)
// 권한 확인
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(() => setToastState(null), 3000);
return () => clearTimeout(timer);
}
}, [toast]);
// 모드팩 목록 fetch
const fetchModpacks = useCallback(async () => {
try {
const res = await fetch('/api/modpacks');
const data = await res.json();
// API 응답을 UI 형식에 맞게 변환
const formatted = data.map(mp => ({
id: mp.id,
version: mp.version,
name: mp.name,
changelog: mp.changelog || '',
date: new Date(mp.created_at).toISOString().split('T')[0],
size: (mp.file_size / (1024 * 1024)).toFixed(1) + ' MB',
}));
setModpacks(formatted);
} catch (error) {
console.error('모드팩 목록 로드 실패:', error);
}
}, []);
// 모드팩 탭 활성화 시 목록 로드
useEffect(() => {
if (activeTab === 'modpack') {
fetchModpacks();
}
}, [activeTab, fetchModpacks]);
// 모드팩 업로드
const handleModpackUpload = async () => {
if (!modpackFile) {
setToast('.mrpack 파일을 선택해주세요.');
return;
}
setModpackLoading(true);
try {
const token = localStorage.getItem('token');
const formData = new FormData();
formData.append('file', modpackFile);
formData.append('changelog', modpackForm.changelog);
const res = await fetch('/api/admin/modpacks', {
method: 'POST',
headers: { 'Authorization': `Bearer ${token}` },
body: formData,
});
const result = await res.json();
if (result.success) {
setToast(`${result.name} v${result.version} 업로드 완료!`);
setShowModpackDialog(false);
setModpackFile(null);
setModpackForm({ changelog: '' });
fetchModpacks();
} else {
setToast(result.error || '업로드 실패', true);
}
} catch (error) {
setToast('업로드 실패: ' + error.message, true);
} finally {
setModpackLoading(false);
}
};
// 모드팩 수정 (변경 로그)
const handleModpackEdit = async () => {
if (!editingModpack) return;
setModpackLoading(true);
try {
const token = localStorage.getItem('token');
const res = await fetch(`/api/admin/modpacks/${editingModpack.id}`, {
method: 'PUT',
headers: {
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({ changelog: modpackForm.changelog }),
});
const result = await res.json();
if (result.success) {
setToast('변경 로그가 수정되었습니다.');
setShowModpackDialog(false);
fetchModpacks();
} else {
setToast(result.error || '수정 실패', true);
}
} catch (error) {
setToast('수정 실패: ' + error.message, true);
} finally {
setModpackLoading(false);
}
};
// 모드팩 삭제
const handleModpackDelete = async () => {
if (!modpackDeleteTarget) return;
setModpackLoading(true);
try {
const token = localStorage.getItem('token');
const res = await fetch(`/api/admin/modpacks/${modpackDeleteTarget.id}`, {
method: 'DELETE',
headers: { 'Authorization': `Bearer ${token}` },
});
const result = await res.json();
if (result.success) {
setToast('모드팩이 삭제되었습니다.');
setModpackDeleteTarget(null);
fetchModpacks();
} else {
setToast(result.error || '삭제 실패', true);
}
} catch (error) {
setToast('삭제 실패: ' + error.message, true);
} finally {
setModpackLoading(false);
}
};
// 모드 번역 목록 조회
const fetchModTranslations = useCallback(async () => {
try {
const token = localStorage.getItem('token');
const res = await fetch('/api/admin/modtranslations', {
headers: { 'Authorization': `Bearer ${token}` }
});
const data = await res.json();
if (data.mods) {
setModTranslations(data.mods);
}
} catch (error) {
console.error('모드 번역 목록 로드 실패:', error);
}
}, []);
// 모드팩 탭 활성화 시 번역 목록 로드
useEffect(() => {
if (activeTab === 'modpack') {
fetchModTranslations();
}
}, [activeTab, fetchModTranslations]);
// 모드 번역 - 파일 추가 (대기열에 추가)
const addPendingFiles = (files) => {
const jarFiles = Array.from(files).filter(f => f.name.endsWith('.jar'));
if (jarFiles.length === 0) {
setToast('JAR 파일만 업로드 가능합니다', true);
return;
}
// 중복 제거 후 상태 객체로 추가
setPendingFiles(prev => {
const existingNames = new Set(prev.map(f => f.name));
const newFiles = jarFiles
.filter(f => !existingNames.has(f.name))
.map(f => ({ file: f, name: f.name, status: 'pending', error: null }));
return [...prev, ...newFiles];
});
};
// 모드 번역 - 대기열에서 파일 제거
const removePendingFile = (fileName) => {
setPendingFiles(prev => prev.filter(f => f.name !== fileName));
};
// 모드 번역 - 파일 상태 업데이트
const updateFileStatus = (fileName, status, error = null) => {
setPendingFiles(prev => prev.map(f =>
f.name === fileName ? { ...f, status, error } : f
));
};
// 모드 번역 - 일괄 업로드 시작
const startTranslationUpload = async () => {
const filesToUpload = pendingFiles.filter(f => f.status === 'pending');
if (filesToUpload.length === 0) return;
setTranslationLoading(true);
const token = localStorage.getItem('token');
// 로컬 카운터 사용 (상태는 비동기로 업데이트되므로)
let successCount = 0;
let failCount = 0;
for (const fileObj of filesToUpload) {
updateFileStatus(fileObj.name, 'processing');
try {
const formData = new FormData();
formData.append('file', fileObj.file);
const res = await fetch('/api/admin/modtranslations', {
method: 'POST',
headers: { 'Authorization': `Bearer ${token}` },
body: formData
});
const result = await res.json();
if (result.success) {
updateFileStatus(fileObj.name, 'success');
successCount++;
} else {
updateFileStatus(fileObj.name, 'error', result.error || '추출 실패');
failCount++;
}
} catch (error) {
updateFileStatus(fileObj.name, 'error', error.message);
failCount++;
}
}
setTranslationLoading(false);
fetchModTranslations();
// 결과 토스트 (색상: 성공만=초록, 혼합=노랑, 실패만=빨강)
if (successCount > 0 && failCount === 0) {
setToast(`${successCount}개 모드 번역 추가 완료!`, 'success');
} else if (successCount > 0 && failCount > 0) {
setToast(`완료: ${successCount}개 성공, ${failCount}개 실패`, 'warning');
} else {
setToast(`${failCount}개 모드 번역 추출 실패`, 'error');
}
};
// 모드 번역 - 완료된 항목 지우기
const clearCompletedFiles = () => {
setPendingFiles(prev => prev.filter(f => f.status === 'pending' || f.status === 'processing'));
};
// 모드 번역 삭제
const handleDeleteTranslation = async (modId) => {
try {
const token = localStorage.getItem('token');
const res = await fetch(`/api/admin/modtranslations/${modId}`, {
method: 'DELETE',
headers: { 'Authorization': `Bearer ${token}` }
});
const result = await res.json();
if (result.success) {
setToast(`${modId} 번역 삭제됨`);
fetchModTranslations();
} else {
setToast(result.error || '삭제 실패', true);
}
} catch (error) {
setToast('삭제 실패: ' + error.message, true);
}
};
// 플레이어 목록 fetch (안정적인 참조)
const fetchPlayers = useCallback(async () => {
try {
const token = localStorage.getItem('token');
const response = await fetch('/api/admin/players', {
headers: { 'Authorization': `Bearer ${token}` }
});
const data = await response.json();
if (data.players) {
setPlayers(data.players);
}
} catch (error) {
console.error('플레이어 목록 조회 실패:', error);
}
}, []);
// 밴 목록 fetch (안정적인 참조)
const fetchBanList = useCallback(async () => {
try {
const token = localStorage.getItem('token');
const response = await fetch('/api/admin/banlist', {
headers: { 'Authorization': `Bearer ${token}` }
});
const data = await response.json();
if (data.banList) {
setBanList(data.banList);
}
} catch (error) {
console.error('밴 목록 조회 실패:', error);
}
}, []);
// 화이트리스트 API 함수들
const fetchWhitelist = useCallback(async () => {
try {
setWhitelistLoading(true);
const token = localStorage.getItem('token');
const response = await fetch('/api/admin/whitelist', {
headers: { 'Authorization': `Bearer ${token}` }
});
const data = await response.json();
setWhitelistEnabled(data.enabled || false);
setWhitelistPlayers(data.players || []);
} catch (error) {
console.error('화이트리스트 조회 실패:', error);
} finally {
setWhitelistLoading(false);
}
}, []);
// 화이트리스트 토글 (on/off)
const toggleWhitelist = useCallback(async () => {
const command = whitelistEnabled ? 'whitelist off' : 'whitelist on';
try {
const token = localStorage.getItem('token');
const response = await fetch('/api/admin/command', {
method: 'POST',
headers: {
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json'
},
body: JSON.stringify({ command })
});
if (response.ok) {
setWhitelistEnabled(!whitelistEnabled);
setToast(`화이트리스트: ${!whitelistEnabled ? '활성화' : '비활성화'}`);
}
} catch (error) {
console.error('화이트리스트 토글 실패:', error);
setToast('화이트리스트 변경 실패');
}
}, [whitelistEnabled]);
// 화이트리스트 플레이어 추가
const addWhitelistPlayer = useCallback(async (playerName) => {
if (!playerName.trim()) return;
try {
const token = localStorage.getItem('token');
const response = await fetch('/api/admin/command', {
method: 'POST',
headers: {
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json'
},
body: JSON.stringify({ command: `whitelist add ${playerName.trim()}` })
});
if (response.ok) {
setToast(`${playerName.trim()} 추가됨`);
setNewWhitelistPlayer('');
// 즉시 프론트엔드 상태 업데이트 (임시 uuid)
setWhitelistPlayers(prev => [...prev, {
uuid: crypto.randomUUID(),
name: playerName.trim()
}]);
// 백그라운드에서 서버 동기화 (정확한 uuid 가져오기)
fetchWhitelist();
}
} catch (error) {
console.error('화이트리스트 추가 실패:', error);
setToast('플레이어 추가 실패');
}
}, [fetchWhitelist]);
// 화이트리스트 플레이어 제거
const removeWhitelistPlayer = useCallback(async (playerName) => {
try {
const token = localStorage.getItem('token');
const response = await fetch('/api/admin/command', {
method: 'POST',
headers: {
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json'
},
body: JSON.stringify({ command: `whitelist remove ${playerName}` })
});
if (response.ok) {
setToast(`${playerName} 제거됨`);
setWhitelistRemoveTarget(null);
// 약간의 딜레이 후 목록 새로고침 (서버 반영 시간)
setTimeout(fetchWhitelist, 500);
}
} catch (error) {
console.error('화이트리스트 제거 실패:', error);
setToast('플레이어 제거 실패');
}
}, [fetchWhitelist]);
// 플레이어 탭 활성화 시 데이터 로드
useEffect(() => {
if (activeTab === 'players' && isAdmin) {
fetchPlayers();
fetchBanList();
fetchWhitelist();
}
}, [activeTab, isAdmin]);
// 스크롤 위치가 맨 아래인지 확인하는 함수
const checkIsAtBottom = useCallback((container) => {
if (!container) return true;
// 5px 오차 허용 (스크롤 정밀도 문제 대응)
return container.scrollHeight - container.scrollTop - container.clientHeight < 5;
}, []);
// 스크롤 이벤트 핸들러 - isAtBottom 상태 업데이트
const handleLogScroll = useCallback((e) => {
const container = e.target;
setIsAtBottom(checkIsAtBottom(container));
}, [checkIsAtBottom]);
// 맨 아래로 스크롤하는 함수
const scrollToBottom = useCallback(() => {
const container = logContainerRef.current;
if (container) {
container.scrollTop = container.scrollHeight;
setIsAtBottom(true);
}
}, []);
// 로그 스크롤 - 새 로그 추가 시 부드럽게 스크롤 (맨 아래에 있을 때만)
// CSS scroll-behavior: smooth가 컨테이너에 적용되어 있으므로 scrollTop 설정만으로 부드럽게 동작
useEffect(() => {
if (activeTab !== 'console' || logs.length === 0) return;
const container = logContainerRef.current;
if (!container) return;
if (isInitialLoad.current) {
// 초기 로드 시 즉시 스크롤 (애니메이션 없이)
container.style.scrollBehavior = 'auto';
container.scrollTop = container.scrollHeight;
setIsAtBottom(true);
requestAnimationFrame(() => {
container.style.scrollBehavior = 'smooth';
});
isInitialLoad.current = false;
} else if (isAtBottom) {
// 맨 아래에 있을 때만 새 로그에 따라 자동 스크롤
container.scrollTop = container.scrollHeight;
}
}, [logs, activeTab, isAtBottom]);
// 탭 전환 시 맨 아래로 스크롤 (ref 콜백 방식)
// AnimatePresence로 인해 컴포넌트가 리마운트될 때 useEffect가 다시 트리거되지 않으므로
// ref 콜백을 사용하여 DOM이 마운트될 때마다 스크롤 처리
// 주의: logs.length를 의존성에서 제거하여 새 로그 추가 시에는 위의 useEffect에서 부드럽게 스크롤
const setLogContainerRef = useCallback((node) => {
logContainerRef.current = node;
if (node) {
// DOM 마운트 시 (탭 전환 시) 즉시 맨 아래로 스크롤
// CSS smooth가 설정되어 있으므로 일시적으로 비활성화
requestAnimationFrame(() => {
node.style.scrollBehavior = 'auto'; // 즉시 스크롤
node.scrollTop = node.scrollHeight;
setIsAtBottom(true);
// 다음 프레임에서 smooth 복원 (새 로그 추가 시 부드럽게)
requestAnimationFrame(() => {
node.style.scrollBehavior = 'smooth';
});
});
}
}, []); // 의존성 없음 - 마운트 시에만 실행
// 로그 파일 목록 fetch 함수
const fetchLogFiles = async () => {
try {
const token = localStorage.getItem('token');
const params = new URLSearchParams();
if (selectedLogServer !== 'all') params.append('serverId', selectedLogServer);
if (selectedLogType !== 'all') params.append('fileType', selectedLogType);
const response = await fetch(`/api/admin/logfiles?${params}`, {
headers: { 'Authorization': `Bearer ${token}` }
});
const data = await response.json();
if (data.files) {
setLogFiles(data.files);
}
if (data.servers) {
setLogServers(data.servers);
}
} catch (error) {
console.error('로그 파일 목록 조회 실패:', error);
}
};
// 로그 파일 목록 자동 fetch
useEffect(() => {
fetchLogFiles();
}, [selectedLogServer, selectedLogType]);
// 로그 파일 내용 보기
const viewLogContent = async (file) => {
setViewingLog(file);
setLogViewerOpen(true);
setLogLoading(true);
setLogContent('');
try {
const token = localStorage.getItem('token');
const response = await fetch(`/api/admin/logfile?id=${file.id}`, {
headers: { 'Authorization': `Bearer ${token}` }
});
if (response.ok) {
const blob = await response.blob();
// .gz 파일만 압축 해제
if (file.fileName.endsWith('.gz')) {
const ds = new DecompressionStream('gzip');
const decompressedStream = blob.stream().pipeThrough(ds);
const decompressedBlob = await new Response(decompressedStream).blob();
const text = await decompressedBlob.text();
setLogContent(text);
} else {
// .log 파일은 직접 텍스트로 읽기
const text = await blob.text();
setLogContent(text);
}
} else {
setLogContent('로그 파일을 불러올 수 없습니다.');
}
} catch (error) {
console.error('로그 파일 로드 실패:', error);
setLogContent('로그 파일을 불러오는 중 오류가 발생했습니다.');
} finally {
setLogLoading(false);
}
};
// 로그 파일 삭제
const deleteLogFile = async (file, e) => {
if (e) e.stopPropagation();
if (!confirm(`${file.fileName} 파일을 삭제하시겠습니까?`)) return;
try {
const token = localStorage.getItem('token');
const response = await fetch(`/api/admin/logfile?id=${file.id}`, {
method: 'DELETE',
headers: { 'Authorization': `Bearer ${token}` }
});
if (response.ok) {
fetchLogFiles(); // 목록 새로고침
}
} catch (error) {
console.error('로그 파일 삭제 실패:', error);
}
};
// 마인크래프트 색상 코드 매핑
const MC_COLORS = {
'0': '#000000', // Black
'1': '#0000AA', // Dark Blue
'2': '#00AA00', // Dark Green
'3': '#00AAAA', // Dark Aqua
'4': '#AA0000', // Dark Red
'5': '#AA00AA', // Dark Purple
'6': '#FFAA00', // Gold
'7': '#AAAAAA', // Gray
'8': '#555555', // Dark Gray
'9': '#5555FF', // Blue
'a': '#55FF55', // Green
'b': '#55FFFF', // Aqua
'c': '#FF5555', // Red
'd': '#FF55FF', // Light Purple
'e': '#FFFF55', // Yellow
'f': '#FFFFFF', // White
};
// 마인크래프트 색상 코드를 HTML span으로 변환
const parseMinecraftColors = (text) => {
if (!text) return [];
const parts = [];
let currentColor = null;
let buffer = '';
let i = 0;
while (i < text.length) {
if (text[i] === '§' && i + 1 < text.length) {
// 현재 버퍼 저장
if (buffer) {
parts.push({ text: buffer, color: currentColor });
buffer = '';
}
const code = text[i + 1].toLowerCase();
if (MC_COLORS[code]) {
currentColor = MC_COLORS[code];
} else if (code === 'r') {
currentColor = '#FFFFFF'; // Reset to white
}
// k, l, m, n, o는 스타일 코드 (무시)
i += 2;
} else {
buffer += text[i];
i++;
}
}
// 남은 버퍼 저장
if (buffer) {
parts.push({ text: buffer, color: currentColor });
}
return parts;
};
// 실제 서버 로그 Socket.io로 수신
useEffect(() => {
if (!isAdmin) return;
// 초기 로그 fetch (Socket.io 연결 전)
const fetchInitialLogs = async () => {
try {
const token = localStorage.getItem('token');
const response = await fetch('/api/admin/logs', {
headers: { 'Authorization': `Bearer ${token}` }
});
const data = await response.json();
if (data.logs && data.logs.length > 0) {
setLogs(data.logs.map(log => ({
time: log.time,
type: log.level === 'ERROR' ? 'error' : log.level === 'WARN' ? 'warning' : 'info',
message: log.message
})));
}
} catch (error) {
console.error('초기 로그 조회 오류:', error);
}
};
fetchInitialLogs();
const socket = io('/', {
path: '/socket.io',
transports: ['websocket', 'polling']
});
socket.on('logs', (serverLogs) => {
if (serverLogs && Array.isArray(serverLogs)) {
setLogs(serverLogs.map(log => ({
time: log.time,
type: log.level === 'ERROR' ? 'error' : log.level === 'WARN' ? 'warning' : 'info',
message: log.message
})));
}
});
// 플레이어 목록 실시간 업데이트
socket.on('players', (playersList) => {
if (playersList && Array.isArray(playersList)) {
setPlayers(playersList);
}
});
// 서버 상태에서 게임 규칙 가져오기
socket.on('status', (status) => {
if (status?.gameRules) {
setGameRules(status.gameRules);
}
if (status?.difficulty) {
// 난이도를 영문 ID로 변환
const difficultyMap = {
'평화로움': 'peaceful',
'쉬움': 'easy',
'보통': 'normal',
'어려움': 'hard'
};
setDifficulty(difficultyMap[status.difficulty] || 'normal');
}
// 성능 모니터링 데이터 업데이트
if (status?.tps !== undefined || status?.memoryUsedMb !== undefined) {
setServerPerformance(prev => ({
tps: status.tps ?? prev.tps,
mspt: status.mspt ?? prev.mspt,
memory: {
used: status.memoryUsedMb ?? prev.memory.used,
max: status.memoryMaxMb ?? prev.memory.max
}
}));
}
});
// 월드 정보에서 시간/날씨 가져오기
socket.on('worlds', (data) => {
const worlds = data?.worlds || data;
// 오버월드 찾기
const overworld = worlds?.find(w => w.dimension === 'minecraft:overworld');
if (overworld) {
// 날씨 설정 (thunderstorm -> thunder로 변환)
if (overworld.weather?.type) {
const weatherMap = {
'clear': 'clear',
'rain': 'rain',
'thunderstorm': 'thunder'
};
setWeather(weatherMap[overworld.weather.type] || 'clear');
}
// 시간 설정 (틱 기반으로 대략적인 시간대 결정)
if (overworld.time?.dayTime !== undefined) {
const dayTime = overworld.time.dayTime;
// 0-6000: 아침/낮, 6000-12000: 낮/오후, 12000-24000: 밤
if (dayTime >= 0 && dayTime < 6000) {
setTimeOfDay('day');
} else if (dayTime >= 6000 && dayTime < 12000) {
setTimeOfDay('noon');
} else {
setTimeOfDay('night');
}
}
}
});
// 월드 정보 요청
socket.emit('get_worlds');
return () => {
socket.disconnect();
};
}, [isAdmin]);
// 게임 규칙 설명 데이터 로드
useEffect(() => {
fetch('/api/gamerules')
.then(res => res.json())
.then(data => setGameRuleDescriptions(data))
.catch(err => console.error('게임 규칙 설명 로드 실패:', err));
}, []);
// 명령어 실행 (실제 API 호출)
const handleCommand = async () => {
if (!command.trim()) return;
try {
const token = localStorage.getItem('token');
await fetch('/api/admin/command', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${token}`
},
body: JSON.stringify({ command: command.trim() })
});
} catch (error) {
// 오류 무시 (로그에서 확인 가능)
}
setCommand('');
};
// 플레이어 액션 핸들러
const handlePlayerAction = async () => {
if (!selectedPlayer || !dialogAction) return;
const token = localStorage.getItem('token');
let command = '';
let message = '';
switch (dialogAction) {
case 'kick':
command = actionReason ? `kick ${selectedPlayer.name} ${actionReason}` : `kick ${selectedPlayer.name}`;
message = `${selectedPlayer.displayName || selectedPlayer.name}님을 추방했습니다.`;
break;
case 'ban':
command = actionReason ? `ban ${selectedPlayer.name} ${actionReason}` : `ban ${selectedPlayer.name}`;
message = `${selectedPlayer.displayName || selectedPlayer.name}님을 차단했습니다.`;
break;
case 'unban':
command = `pardon ${selectedPlayer.name}`;
message = `${selectedPlayer.displayName || selectedPlayer.name}님의 차단을 해제했습니다.`;
break;
case 'op':
const isOp = selectedPlayer.isOp;
command = isOp ? `deop ${selectedPlayer.name}` : `op ${selectedPlayer.name}`;
message = isOp ? `${selectedPlayer.displayName || selectedPlayer.name}님의 OP를 해제했습니다.` : `${selectedPlayer.displayName || selectedPlayer.name}님에게 OP를 부여했습니다.`;
break;
}
try {
const response = await fetch('/api/admin/command', {
method: 'POST',
headers: {
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json'
},
body: JSON.stringify({ command })
});
if (response.ok) {
setToast(message);
// 데이터 새로고침
setTimeout(() => {
fetchPlayers();
fetchBanList();
}, 500);
} else {
setToast('명령어 실행에 실패했습니다.');
}
} catch (error) {
console.error('플레이어 액션 오류:', error);
setToast('서버 연결에 실패했습니다.');
}
setShowPlayerDialog(false);
setSelectedPlayer(null);
setDialogAction(null);
setActionReason('');
};
// 게임규칙 토글 (서버에 gamerule 명령어 전송)
const toggleGamerule = async (name) => {
const currentValue = gameRules[name];
const newValue = !currentValue;
// 낙관적 UI 업데이트
setGameRules(prev => ({ ...prev, [name]: newValue }));
try {
const token = localStorage.getItem('token');
await fetch('/api/admin/command', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${token}`
},
body: JSON.stringify({ command: `gamerule ${name} ${newValue}` })
});
setToast(`${name}: ${newValue ? 'true' : 'false'}`);
} catch (error) {
// 실패 시 롤백
setGameRules(prev => ({ ...prev, [name]: currentValue }));
setToast('게임규칙 변경 실패');
}
};
// 로그 색상 (hex 값 반환)
const getLogColorHex = (type) => {
switch (type) {
case 'error': return '#f87171'; // red-400
case 'warning': return '#facc15'; // yellow-400
case 'command': return '#22c55e'; // mc-green
default: return '#d4d4d8'; // zinc-300
}
};
// 로그 색상 (클래스명 반환 - 시간용)
const getLogColor = (type) => {
switch (type) {
case 'error': return 'text-red-400';
case 'warning': return 'text-yellow-400';
case 'command': return 'text-mc-green';
default: return 'text-zinc-300';
}
};
// 필터된 플레이어 (banned 필터는 banList 사용)
const filteredPlayers = playerFilter === 'banned'
? banList.map(ban => ({
name: ban.name,
uuid: ban.uuid,
displayName: ban.name,
isOnline: false,
isOp: false,
isBanned: true,
banReason: ban.reason,
banSource: ban.source
}))
: [...players].sort((a, b) => {
// 온라인 우선
if (a.isOnline !== b.isOnline) return b.isOnline ? 1 : -1;
// 같은 온라인 상태면 OP 우선
if (a.isOp !== b.isOp) return b.isOp ? 1 : -1;
// 그 외에는 닉네임 순
return (a.displayName || a.name).localeCompare(b.displayName || b.name);
});
if (loading) {
return (
);
}
if (!isLoggedIn || !isAdmin) {
return (
<>
{toast && (
{toast.message}
)}
>
);
}
// 탭 설정
const tabs = [
{ id: 'console', label: '콘솔', icon: Terminal },
{ id: 'players', label: '플레이어', icon: Users },
{ id: 'modpack', label: '모드팩', icon: Package },
{ id: 'settings', label: '설정', icon: Settings },
];
return (
{/* 토스트 */}
{toast && (
{toast.message}
)}
{/* 모바일용 헤더 */}
{isMobile && (
)}
{/* 데스크탑용 타이틀 */}
{!isMobile && (
)}
{/* 탭 네비게이션 - 데스크톱 */}
{!isMobile && (
{tabs.map(tab => (
))}
)}
{/* 탭 콘텐츠 */}
{/* 콘솔 탭 */}
{activeTab === 'console' && (
{/* 서버 성능 모니터링 */}
📊 서버 성능
{/* TPS */}
TPS
= 18 ? 'text-mc-green' :
serverPerformance.tps >= 15 ? 'text-yellow-400' : 'text-red-400'
}`}>
{serverPerformance.tps.toFixed(1)}
= 18 ? 'bg-mc-green' :
serverPerformance.tps >= 15 ? 'bg-yellow-400' : 'bg-red-400'
}`}
style={{ width: `${Math.min(100, (serverPerformance.tps / 20) * 100)}%` }}
/>
{/* MSPT */}
MSPT
{serverPerformance.mspt.toFixed(1)}ms
{/* 메모리 */}
메모리
{(serverPerformance.memory.used / 1024).toFixed(1)}GB / {(serverPerformance.memory.max / 1024).toFixed(1)}GB
0.9 ? 'bg-red-400' :
(serverPerformance.memory.used / serverPerformance.memory.max) > 0.7 ? 'bg-yellow-400' : 'bg-mc-diamond'
}`}
style={{ width: `${serverPerformance.memory.max === 0 ? 0 : (serverPerformance.memory.used / serverPerformance.memory.max) * 100}%` }}
/>
{/* 로그 영역 */}
📝 콘솔 로그
{logs.map((log, index) => (
[{log.time}]{' '}
{parseMinecraftColors(log.message).map((part, i) => (
{part.text}
))}
))}
{/* 맨 아래로 스크롤 버튼 */}
{!isAtBottom && logs.length > 0 && (
)}
{/* 명령어 입력 */}
{/* 로그 파일 목록 */}
로그 파일
{logFiles.length > 0 && ({logFiles.length})}
{/* 필터 드롭다운 */}
{/* 서버 선택 드롭다운 */}
{serverDropdownOpen && (
{logServers.map(server => (
))}
)}
{/* 종류 선택 드롭다운 */}
{typeDropdownOpen && (
{[
{ value: 'all', label: '모든 종류' },
{ value: 'dated', label: '📅 날짜별' },
{ value: 'debug', label: '🐛 디버그' },
{ value: 'latest', label: '⚡ 최신' }
].map(item => (
))}
)}
{logFiles.length === 0 ? (
로그 파일이 없습니다
) : (
logFiles.map((file) => (
viewLogContent(file)}
>
{file.fileName}
{file.fileSize} • {file.serverId} •
{file.fileType}
))
)}
)}
{/* 플레이어 탭 */}
{activeTab === 'players' && (
{/* 필터 */}
{[
{ id: 'all', label: '전체' },
{ id: 'banned', label: '밴' },
{ id: 'whitelist', label: '화이트리스트' },
].map(filter => (
))}
{/* 화이트리스트 필터 선택 시 */}
{playerFilter === 'whitelist' ? (
{/* 화이트리스트 On/Off 토글 */}
{/* 플레이어 추가 */}
setNewWhitelistPlayer(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') {
addWhitelistPlayer(newWhitelistPlayer);
}
}}
placeholder="플레이어 이름 입력..."
className="flex-1 bg-zinc-800 border border-zinc-700 rounded-lg px-3 py-2 text-white text-sm placeholder-zinc-500 focus:outline-none focus:border-mc-green/50"
/>
{/* 화이트리스트 플레이어 그리드 */}
{whitelistPlayers.length > 0 ? (
{whitelistPlayers.map(player => (
{player.name}
))}
) : (
화이트리스트가 비어있습니다
)}
) : (
/* 플레이어 그리드 */
{filteredPlayers.map((player) => (
{/* OP 뱃지 */}
{player.isOp && (
)}
{/* 온/오프라인 표시 */}
{/* 전신 아바타 */}
{player.displayName || player.name}
{player.name}
{player.isBanned && (
차단됨
)}
{/* 액션 버튼 - mt-2 추가 */}
{player.isOnline && (
)}
{player.isBanned ? (
) : (
)}
))}
)}
)}
{/* 설정 탭 */}
{activeTab === 'settings' && (
{/* 게임규칙 */}
🎮 게임규칙
({Object.keys(gameRules).length})
{Object.keys(gameRules).length > 0 ? (
{Object.entries(gameRules).map(([rule, value]) => (
))}
) : (
게임 규칙을 불러오는 중...
)}
{/* 난이도 */}
⚔️ 난이도
{[
{ id: 'peaceful', label: '평화로움' },
{ id: 'easy', label: '쉬움' },
{ id: 'normal', label: '보통' },
{ id: 'hard', label: '어려움' },
].map(d => (
))}
{/* 시간 */}
🕐 시간
{[
{ id: 'day', label: '아침', icon: Sun, time: '1000' },
{ id: 'noon', label: '낮', icon: Sun, time: '6000' },
{ id: 'night', label: '밤', icon: Moon, time: '13000' },
].map(t => (
))}
{/* 날씨 */}
🌤️ 날씨
{[
{ id: 'clear', label: '맑음', icon: Sun },
{ id: 'rain', label: '비', icon: CloudRain },
{ id: 'thunder', label: '천둥', icon: CloudLightning },
].map(w => (
))}
)}
{/* 모드팩 탭 */}
{activeTab === 'modpack' && (
{/* 모드 번역 */}
🌐 모드 번역
{modTranslations.length > 0 && (
({modTranslations.length}개 모드)
)}
{/* 업로드 영역 */}
{/* 대기열 목록 */}
{pendingFiles.length > 0 && (
파일 목록 ({pendingFiles.length}개)
{pendingFiles.map((fileObj) => {
// 상태별 스타일
const statusStyles = {
pending: 'bg-zinc-800/50',
processing: 'bg-purple-600/30 border border-purple-500',
success: 'bg-green-600/20 border border-green-500/50',
error: 'bg-red-600/20 border border-red-500/50'
};
const textStyles = {
pending: 'text-white',
processing: 'text-purple-300',
success: 'text-green-400',
error: 'text-red-400'
};
const statusIcons = {
pending: null,
processing:
,
success:
,
error:
};
return (
{/* 처리 중 애니메이션 배경 */}
{fileObj.status === 'processing' && (
)}
{statusIcons[fileObj.status]}
{fileObj.name}
{fileObj.status === 'pending' && (
)}
{fileObj.status === 'error' && fileObj.error && (
{fileObj.error}
)}
);
})}
{/* 업로드 버튼 */}
{pendingFiles.some(f => f.status === 'pending') && (
)}
)}
{/* 업로드된 모드 목록 */}
{modTranslations.length > 0 && (
{isModListExpanded && (
{modTranslations.map((mod) => (
{mod.mod_id}
블록 {mod.block_count} · 아이템 {mod.item_count}
))}
)}
)}
{/* 모드팩 관리 */}
📦 모드팩 관리
{/* 모드팩 목록 */}
{modpacks.map((pack, i) => (
isMobile ? (
/* 모바일 레이아웃 - 세로 카드 */
{i === 0 && 최신}
v{pack.version}
{pack.name}
{pack.date} · {pack.size}
) : (
/* 데스크톱 레이아웃 - 가로 */
{pack.name}
v{pack.version}
{i === 0 && 최신}
{pack.date} · {pack.size}
)
))}
{/* 빈 상태 */}
{modpacks.length === 0 && (
)}
)}
{/* 모바일 바텀 네비게이션 */}
{isMobile && (
{tabs.map(tab => (
))}
)}
{/* 플레이어 액션 다이얼로그 */}
{showPlayerDialog && selectedPlayer && (
setShowPlayerDialog(false)}
>
e.stopPropagation()}
>
{dialogAction === 'kick' && <> 플레이어 킥>}
{dialogAction === 'ban' && <> 플레이어 밴>}
{dialogAction === 'unban' && <> 차단 해제>}
{dialogAction === 'op' && <> OP {selectedPlayer?.isOp ? '해제' : '부여'}>}
{selectedPlayer.displayName || selectedPlayer.name}
{selectedPlayer.uuid}
{(dialogAction === 'kick' || dialogAction === 'ban') && (
setActionReason(e.target.value)}
placeholder="사유 (선택)"
className="w-full bg-zinc-800 rounded-xl p-3 text-white placeholder-zinc-500 focus:outline-none focus:ring-2 focus:ring-mc-green/50 mb-4"
/>
)}
)}
{/* 로그 뷰어 다이얼로그 */}
{logViewerOpen && (
setLogViewerOpen(false)}
>
e.stopPropagation()}
>
{/* 헤더 */}
{viewingLog?.fileName}
{viewingLog?.fileSize} • {viewingLog?.serverId} • {viewingLog?.fileType}
{/* 로그 내용 */}
{logLoading ? (
) : (
{logContent.split('\n').map((line, idx) => {
// 로그 레벨 추출 (INFO, WARN, ERROR)
const logType = line.includes('/ERROR]') || line.includes('[ERROR]') ? 'error' :
line.includes('/WARN]') || line.includes('[WARN]') ? 'warning' : 'info';
const colorHex = logType === 'error' ? '#f87171' :
logType === 'warning' ? '#facc15' : '#d4d4d8';
return (
{parseMinecraftColors(line).map((part, pIdx) => (
{part.text}
))}
);
})}
)}
{/* 하단 버튼 */}
)}
{/* 화이트리스트 삭제 확인 다이얼로그 */}
{whitelistRemoveTarget && (
setWhitelistRemoveTarget(null)}
>
e.stopPropagation()}
>
화이트리스트 제거
{whitelistRemoveTarget.name}님을
화이트리스트에서 제거하시겠습니까?
)}
{/* 모드 번역 삭제 확인 다이얼로그 */}
{deleteModDialog.show && (
setDeleteModDialog({ show: false, modId: null })}
>
e.stopPropagation()}
>
모드 번역 삭제
{deleteModDialog.modId} 모드의 번역 데이터를 삭제하시겠습니까?
)}
{/* 모드팩 업로드/수정 다이얼로그 */}
{showModpackDialog && (
{
// 배경 클릭 시 바운스 효과
const dialog = document.getElementById('modpack-dialog');
if (dialog) {
dialog.classList.add('animate-shake');
setTimeout(() => dialog.classList.remove('animate-shake'), 150);
}
}}
>
e.stopPropagation()}
>
{modpackDialogMode === 'upload' ? '📦 모드팩 업로드' : '✏️ 모드팩 수정'}
{/* 파일 선택 (업로드 모드에서만) */}
{modpackDialogMode === 'upload' && (
)}
{/* 수정 모드에서 파일명 표시 */}
{modpackDialogMode === 'edit' && editingModpack && (
{editingModpack.name} v{editingModpack.version}
{editingModpack.size}
)}
{/* 변경 로그 */}
{/* 업로드 중 로딩 표시 */}
{modpackLoading && (
{modpackDialogMode === 'upload' ? '업로드 중...' : '저장 중...'}
파일을 처리하고 있습니다. 잠시만 기다려주세요.
)}
{/* 버튼 */}
)}
{/* 모드팩 삭제 확인 다이얼로그 */}
{modpackDeleteTarget && (
setModpackDeleteTarget(null)}
>
e.stopPropagation()}
>
모드팩 삭제
{modpackDeleteTarget.name} v{modpackDeleteTarget.version}을(를) 삭제하시겠습니까?
)}
);
}