diff --git a/frontend/src/pages/Admin.jsx b/frontend/src/pages/Admin.jsx
index 92cdd20..de42160 100644
--- a/frontend/src/pages/Admin.jsx
+++ b/frontend/src/pages/Admin.jsx
@@ -3,32 +3,59 @@
* - 탭 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, Image
-} from 'lucide-react';
-import { motion, AnimatePresence } from 'framer-motion';
-import { io } from 'socket.io-client';
-import Tooltip from '../components/Tooltip';
+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,
+ Image,
+} 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==';
+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 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 => {
+ .then((res) => res.json())
+ .then((data) => {
if (data.url) {
const img = new Image();
img.onload = () => setSrc(data.url);
@@ -40,13 +67,15 @@ const CachedSkin = ({ uuid, name, type = 'body', size = 100, className }) => {
})
.catch(() => setSrc(fallbackUrl));
}, [uuid, type, size, fallbackUrl]);
-
+
return (
- { e.target.src = fallbackUrl; }}
+ onError={(e) => {
+ e.target.src = fallbackUrl;
+ }}
/>
);
};
@@ -57,72 +86,79 @@ export default function Admin({ isMobile = false }) {
const location = useLocation();
const [toast, setToastState] = useState(null);
// 토스트 헬퍼 함수 (type: 'success' | 'warning' | 'error' | boolean)
- const setToast = (message, type = 'success') => {
+ const setToast = (message, type = "success") => {
// 이전 호환성: true면 error, false면 success
- const colorType = type === true ? 'error' : (type === false ? 'success' : type);
+ 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 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 [command, setCommand] = useState("");
const [logFiles, setLogFiles] = useState([]);
const [logServers, setLogServers] = useState([]); // 서버 ID 목록
- const [selectedLogServer, setSelectedLogServer] = useState('all'); // 선택된 서버
- const [selectedLogType, setSelectedLogType] = useState('all'); // 로그 종류 필터
+ 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 [logContent, setLogContent] = useState(""); // 로그 내용
const [logLoading, setLogLoading] = useState(false); // 로그 로딩
const [serverDropdownOpen, setServerDropdownOpen] = useState(false); // 서버 드롭다운
const [typeDropdownOpen, setTypeDropdownOpen] = useState(false); // 타입 드롭다운
- const [deleteLogDialog, setDeleteLogDialog] = useState({ show: false, files: [], loading: false }); // 로그 삭제 다이얼로그
+ const [deleteLogDialog, setDeleteLogDialog] = useState({
+ show: false,
+ files: [],
+ loading: false,
+ }); // 로그 삭제 다이얼로그
const [selectedLogFiles, setSelectedLogFiles] = useState(new Set()); // 선택된 로그 파일 ID
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 [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 [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');
-
+ 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 [newWhitelistPlayer, setNewWhitelistPlayer] = useState("");
const [whitelistRemoveTarget, setWhitelistRemoveTarget] = useState(null); // 삭제 확인 다이얼로그용
const [whitelistLoading, setWhitelistLoading] = useState(false);
-
+
// 성능 모니터링 상태 (소켓에서 업데이트)
const [serverPerformance, setServerPerformance] = useState({
tps: 0,
@@ -132,9 +168,9 @@ export default function Admin({ isMobile = false }) {
// 모드팩 관리 상태
const [showModpackDialog, setShowModpackDialog] = useState(false);
- const [modpackDialogMode, setModpackDialogMode] = useState('upload'); // 'upload' | 'edit'
+ const [modpackDialogMode, setModpackDialogMode] = useState("upload"); // 'upload' | 'edit'
const [editingModpack, setEditingModpack] = useState(null);
- const [modpackForm, setModpackForm] = useState({ changelog: '' });
+ const [modpackForm, setModpackForm] = useState({ changelog: "" });
const [modpackFile, setModpackFile] = useState(null); // 업로드할 파일
const [modpacks, setModpacks] = useState([]);
const [modpackDeleteTarget, setModpackDeleteTarget] = useState(null); // 삭제 확인 다이얼로그용
@@ -148,14 +184,22 @@ export default function Admin({ isMobile = 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, loading: false }); // 모드 삭제 확인 다이얼로그
+ const [deleteModDialog, setDeleteModDialog] = useState({
+ show: false,
+ modId: null,
+ loading: false,
+ }); // 모드 삭제 확인 다이얼로그
const [clearingFiles, setClearingFiles] = useState(false); // 완료 항목 삭제 중 (애니메이션용)
// 아이콘 관리 상태
const [iconMods, setIconMods] = useState([]); // 등록된 아이콘 모드 목록
const [iconUploading, setIconUploading] = useState(false);
const [isIconDragging, setIsIconDragging] = useState(false);
- const [deleteIconDialog, setDeleteIconDialog] = useState({ show: false, modId: null, loading: false });
+ const [deleteIconDialog, setDeleteIconDialog] = useState({
+ show: false,
+ modId: null,
+ loading: false,
+ });
const [isIconListExpanded, setIsIconListExpanded] = useState(false);
const [pendingIconFiles, setPendingIconFiles] = useState([]); // 아이콘 파일 대기열
const [clearingIconFiles, setClearingIconFiles] = useState(false);
@@ -164,10 +208,10 @@ export default function Admin({ isMobile = false }) {
useEffect(() => {
if (!loading) {
if (!isLoggedIn) {
- navigate('/login', { state: { from: location.pathname } });
+ navigate("/login", { state: { from: location.pathname } });
} else if (!isAdmin) {
- setToast('관리자 권한이 필요합니다.');
- setTimeout(() => navigate('/'), 1500);
+ setToast("관리자 권한이 필요합니다.");
+ setTimeout(() => navigate("/"), 1500);
}
}
}, [isLoggedIn, isAdmin, loading, navigate, location.pathname]);
@@ -183,26 +227,26 @@ export default function Admin({ isMobile = false }) {
// 모드팩 목록 fetch
const fetchModpacks = useCallback(async () => {
try {
- const res = await fetch('/api/modpacks');
+ const res = await fetch("/api/modpacks");
const data = await res.json();
// API 응답을 UI 형식에 맞게 변환
- const formatted = data.map(mp => ({
+ 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',
+ 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);
+ console.error("모드팩 목록 로드 실패:", error);
}
}, []);
// 모드팩 탭 활성화 시 목록 로드
useEffect(() => {
- if (activeTab === 'modpack') {
+ if (activeTab === "modpack") {
fetchModpacks();
}
}, [activeTab, fetchModpacks]);
@@ -210,19 +254,19 @@ export default function Admin({ isMobile = false }) {
// 모드팩 업로드
const handleModpackUpload = async () => {
if (!modpackFile) {
- setToast('.mrpack 파일을 선택해주세요.');
+ setToast(".mrpack 파일을 선택해주세요.");
return;
}
setModpackLoading(true);
try {
- const token = localStorage.getItem('token');
+ const token = localStorage.getItem("token");
const formData = new FormData();
- formData.append('file', modpackFile);
- formData.append('changelog', modpackForm.changelog);
+ formData.append("file", modpackFile);
+ formData.append("changelog", modpackForm.changelog);
- const res = await fetch('/api/admin/modpacks', {
- method: 'POST',
- headers: { 'Authorization': `Bearer ${token}` },
+ const res = await fetch("/api/admin/modpacks", {
+ method: "POST",
+ headers: { Authorization: `Bearer ${token}` },
body: formData,
});
const result = await res.json();
@@ -230,13 +274,13 @@ export default function Admin({ isMobile = false }) {
setToast(`${result.name} v${result.version} 업로드 완료!`);
setShowModpackDialog(false);
setModpackFile(null);
- setModpackForm({ changelog: '' });
+ setModpackForm({ changelog: "" });
fetchModpacks();
} else {
- setToast(result.error || '업로드 실패', true);
+ setToast(result.error || "업로드 실패", true);
}
} catch (error) {
- setToast('업로드 실패: ' + error.message, true);
+ setToast("업로드 실패: " + error.message, true);
} finally {
setModpackLoading(false);
}
@@ -247,25 +291,25 @@ export default function Admin({ isMobile = false }) {
if (!editingModpack) return;
setModpackLoading(true);
try {
- const token = localStorage.getItem('token');
+ const token = localStorage.getItem("token");
const res = await fetch(`/api/admin/modpacks/${editingModpack.id}`, {
- method: 'PUT',
- headers: {
- 'Authorization': `Bearer ${token}`,
- 'Content-Type': 'application/json',
+ 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('변경 로그가 수정되었습니다.');
+ setToast("변경 로그가 수정되었습니다.");
setShowModpackDialog(false);
fetchModpacks();
} else {
- setToast(result.error || '수정 실패', true);
+ setToast(result.error || "수정 실패", true);
}
} catch (error) {
- setToast('수정 실패: ' + error.message, true);
+ setToast("수정 실패: " + error.message, true);
} finally {
setModpackLoading(false);
}
@@ -276,21 +320,21 @@ export default function Admin({ isMobile = false }) {
if (!modpackDeleteTarget) return;
setModpackLoading(true);
try {
- const token = localStorage.getItem('token');
+ const token = localStorage.getItem("token");
const res = await fetch(`/api/admin/modpacks/${modpackDeleteTarget.id}`, {
- method: 'DELETE',
- headers: { 'Authorization': `Bearer ${token}` },
+ method: "DELETE",
+ headers: { Authorization: `Bearer ${token}` },
});
const result = await res.json();
if (result.success) {
- setToast('모드팩이 삭제되었습니다.');
+ setToast("모드팩이 삭제되었습니다.");
setModpackDeleteTarget(null);
fetchModpacks();
} else {
- setToast(result.error || '삭제 실패', true);
+ setToast(result.error || "삭제 실패", true);
}
} catch (error) {
- setToast('삭제 실패: ' + error.message, true);
+ setToast("삭제 실패: " + error.message, true);
} finally {
setModpackLoading(false);
}
@@ -299,72 +343,74 @@ export default function Admin({ isMobile = false }) {
// 모드 번역 목록 조회
const fetchModTranslations = useCallback(async () => {
try {
- const token = localStorage.getItem('token');
- const res = await fetch('/api/admin/modtranslations', {
- headers: { 'Authorization': `Bearer ${token}` }
+ 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);
+ console.error("모드 번역 목록 로드 실패:", error);
}
}, []);
// 아이콘 모드 목록 조회
const fetchIconMods = useCallback(async () => {
try {
- const token = localStorage.getItem('token');
- const res = await fetch('/api/admin/icons', {
- headers: { 'Authorization': `Bearer ${token}` }
+ const token = localStorage.getItem("token");
+ const res = await fetch("/api/admin/icons", {
+ headers: { Authorization: `Bearer ${token}` },
});
const data = await res.json();
if (data.mods) {
setIconMods(data.mods);
}
} catch (error) {
- console.error('아이콘 목록 로드 실패:', error);
+ console.error("아이콘 목록 로드 실패:", error);
}
}, []);
// 아이콘 ZIP 파일 추가 (대기열에만 추가)
const addIconFiles = (files) => {
const fileArray = Array.isArray(files) ? files : [files];
- const zipFiles = fileArray.filter(f => f && f.name.endsWith('.zip'));
-
+ const zipFiles = fileArray.filter((f) => f && f.name.endsWith(".zip"));
+
if (zipFiles.length === 0) {
- setToast('ZIP 파일만 추가 가능합니다', true);
+ setToast("ZIP 파일만 추가 가능합니다", true);
return;
}
// 중복 체크 후 대기열에 추가
const newFiles = zipFiles
- .filter(f => !pendingIconFiles.some(pf => pf.name === f.name))
- .map(f => ({ name: f.name, file: f, status: 'pending' }));
-
+ .filter((f) => !pendingIconFiles.some((pf) => pf.name === f.name))
+ .map((f) => ({ name: f.name, file: f, status: "pending" }));
+
if (newFiles.length > 0) {
- setPendingIconFiles(prev => [...prev, ...newFiles]);
+ setPendingIconFiles((prev) => [...prev, ...newFiles]);
}
};
// 아이콘 파일 제거
const removeIconFile = (fileName) => {
- setPendingIconFiles(prev => prev.filter(f => f.name !== fileName));
+ setPendingIconFiles((prev) => prev.filter((f) => f.name !== fileName));
};
// 완료된 아이콘 파일 지우기
const clearCompletedIconFiles = () => {
setClearingIconFiles(true);
setTimeout(() => {
- setPendingIconFiles(prev => prev.filter(f => f.status === 'pending'));
+ setPendingIconFiles((prev) => prev.filter((f) => f.status === "pending"));
setClearingIconFiles(false);
}, 300);
};
// 아이콘 업로드 시작
const startIconUpload = async () => {
- const filesToUpload = pendingIconFiles.filter(f => f.status === 'pending');
+ const filesToUpload = pendingIconFiles.filter(
+ (f) => f.status === "pending"
+ );
if (filesToUpload.length === 0) return;
setIconUploading(true);
@@ -373,42 +419,57 @@ export default function Admin({ isMobile = false }) {
for (const fileObj of filesToUpload) {
// 상태를 'processing'으로 변경
- setPendingIconFiles(prev =>
- prev.map(f => f.name === fileObj.name ? { ...f, status: 'processing' } : f)
+ setPendingIconFiles((prev) =>
+ prev.map((f) =>
+ f.name === fileObj.name ? { ...f, status: "processing" } : f
+ )
);
try {
- const token = localStorage.getItem('token');
+ const token = localStorage.getItem("token");
const formData = new FormData();
- formData.append('files', fileObj.file);
+ formData.append("files", fileObj.file);
- const res = await fetch('/api/admin/icons/upload', {
- method: 'POST',
- headers: { 'Authorization': `Bearer ${token}` },
- body: formData
+ const res = await fetch("/api/admin/icons/upload", {
+ method: "POST",
+ headers: { Authorization: `Bearer ${token}` },
+ body: formData,
});
const data = await res.json();
if (data.success && data.results?.length > 0) {
const result = data.results[0];
- setPendingIconFiles(prev =>
- prev.map(f => f.name === fileObj.name ? {
- ...f,
- status: 'success',
- result: `${result.uploaded}개 업로드, ${result.updated}개 DB 업데이트`
- } : f)
+ setPendingIconFiles((prev) =>
+ prev.map((f) =>
+ f.name === fileObj.name
+ ? {
+ ...f,
+ status: "success",
+ result: `${result.uploaded}개 업로드, ${result.updated}개 DB 업데이트`,
+ }
+ : f
+ )
);
successCount++;
} else {
- const errorMsg = data.errors?.[0]?.error || data.error || '업로드 실패';
- setPendingIconFiles(prev =>
- prev.map(f => f.name === fileObj.name ? { ...f, status: 'error', error: errorMsg } : f)
+ const errorMsg =
+ data.errors?.[0]?.error || data.error || "업로드 실패";
+ setPendingIconFiles((prev) =>
+ prev.map((f) =>
+ f.name === fileObj.name
+ ? { ...f, status: "error", error: errorMsg }
+ : f
+ )
);
failCount++;
}
} catch (error) {
- setPendingIconFiles(prev =>
- prev.map(f => f.name === fileObj.name ? { ...f, status: 'error', error: error.message } : f)
+ setPendingIconFiles((prev) =>
+ prev.map((f) =>
+ f.name === fileObj.name
+ ? { ...f, status: "error", error: error.message }
+ : f
+ )
);
failCount++;
}
@@ -430,28 +491,30 @@ export default function Admin({ isMobile = false }) {
// 아이콘 모드 삭제
const deleteIconMod = async (modId) => {
try {
- const token = localStorage.getItem('token');
+ const token = localStorage.getItem("token");
const res = await fetch(`/api/admin/icons/${modId}`, {
- method: 'DELETE',
- headers: { 'Authorization': `Bearer ${token}` }
+ method: "DELETE",
+ headers: { Authorization: `Bearer ${token}` },
});
const data = await res.json();
if (data.success) {
- setToast(`${modId} 모드 아이콘 삭제 완료 (DB ${data.deleted}개, S3 ${data.s3Deleted}개)`);
+ setToast(
+ `${modId} 모드 아이콘 삭제 완료 (DB ${data.deleted}개, S3 ${data.s3Deleted}개)`
+ );
fetchIconMods();
} else {
- setToast(data.error || '삭제 실패', true);
+ setToast(data.error || "삭제 실패", true);
}
} catch (error) {
- console.error('아이콘 삭제 실패:', error);
- setToast('삭제 중 오류 발생', true);
+ console.error("아이콘 삭제 실패:", error);
+ setToast("삭제 중 오류 발생", true);
}
};
// 모드팩 탭 활성화 시 번역 목록 + 아이콘 목록 로드
useEffect(() => {
- if (activeTab === 'modpack') {
+ if (activeTab === "modpack") {
fetchModTranslations();
fetchIconMods();
}
@@ -459,68 +522,73 @@ export default function Admin({ isMobile = false }) {
// 모드 번역 - 파일 추가 (대기열에 추가)
const addPendingFiles = (files) => {
- const jarFiles = Array.from(files).filter(f => f.name.endsWith('.jar'));
+ const jarFiles = Array.from(files).filter((f) => f.name.endsWith(".jar"));
if (jarFiles.length === 0) {
- setToast('JAR 파일만 업로드 가능합니다', true);
+ setToast("JAR 파일만 업로드 가능합니다", true);
return;
}
// 중복 제거 후 상태 객체로 추가
- setPendingFiles(prev => {
- const existingNames = new Set(prev.map(f => f.name));
+ 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 }));
+ .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));
+ 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
- ));
+ setPendingFiles((prev) =>
+ prev.map((f) => (f.name === fileName ? { ...f, status, error } : f))
+ );
};
// 모드 번역 - 일괄 업로드 시작
const startTranslationUpload = async () => {
- const filesToUpload = pendingFiles.filter(f => f.status === 'pending');
+ const filesToUpload = pendingFiles.filter((f) => f.status === "pending");
if (filesToUpload.length === 0) return;
-
+
setTranslationLoading(true);
- const token = localStorage.getItem('token');
-
+ const token = localStorage.getItem("token");
+
// 로컬 카운터 사용 (상태는 비동기로 업데이트되므로)
let successCount = 0;
let failCount = 0;
for (const fileObj of filesToUpload) {
- updateFileStatus(fileObj.name, 'processing');
+ updateFileStatus(fileObj.name, "processing");
try {
const formData = new FormData();
- formData.append('file', fileObj.file);
+ formData.append("file", fileObj.file);
- const res = await fetch('/api/admin/modtranslations', {
- method: 'POST',
- headers: { 'Authorization': `Bearer ${token}` },
- body: formData
+ 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');
+ updateFileStatus(fileObj.name, "success");
successCount++;
} else {
- updateFileStatus(fileObj.name, 'error', result.error || '추출 실패');
+ updateFileStatus(fileObj.name, "error", result.error || "추출 실패");
failCount++;
}
} catch (error) {
- updateFileStatus(fileObj.name, 'error', error.message);
+ updateFileStatus(fileObj.name, "error", error.message);
failCount++;
}
}
@@ -530,70 +598,72 @@ export default function Admin({ isMobile = false }) {
// 결과 토스트 (색상: 성공만=초록, 혼합=노랑, 실패만=빨강)
if (successCount > 0 && failCount === 0) {
- setToast(`${successCount}개 모드 번역 추가 완료!`, 'success');
+ setToast(`${successCount}개 모드 번역 추가 완료!`, "success");
} else if (successCount > 0 && failCount > 0) {
- setToast(`완료: ${successCount}개 성공, ${failCount}개 실패`, 'warning');
+ setToast(`완료: ${successCount}개 성공, ${failCount}개 실패`, "warning");
} else {
- setToast(`${failCount}개 모드 번역 추출 실패`, 'error');
+ setToast(`${failCount}개 모드 번역 추출 실패`, "error");
}
};
// 모드 번역 - 완료된 항목 지우기
const clearCompletedFiles = () => {
- setPendingFiles(prev => prev.filter(f => f.status === 'pending' || f.status === 'processing'));
+ setPendingFiles((prev) =>
+ prev.filter((f) => f.status === "pending" || f.status === "processing")
+ );
};
// 모드 번역 삭제
const handleDeleteTranslation = async (modId) => {
try {
- const token = localStorage.getItem('token');
+ const token = localStorage.getItem("token");
const res = await fetch(`/api/admin/modtranslations/${modId}`, {
- method: 'DELETE',
- headers: { 'Authorization': `Bearer ${token}` }
+ method: "DELETE",
+ headers: { Authorization: `Bearer ${token}` },
});
-
+
const result = await res.json();
if (result.success) {
setToast(`${modId} 번역 삭제됨`);
fetchModTranslations();
fetchIconMods(); // 아이콘 목록도 새로고침
} else {
- setToast(result.error || '삭제 실패', true);
+ setToast(result.error || "삭제 실패", true);
}
} catch (error) {
- setToast('삭제 실패: ' + error.message, true);
+ 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 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);
+ 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 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);
+ console.error("밴 목록 조회 실패:", error);
}
}, []);
@@ -601,15 +671,15 @@ export default function Admin({ isMobile = false }) {
const fetchWhitelist = useCallback(async () => {
try {
setWhitelistLoading(true);
- const token = localStorage.getItem('token');
- const response = await fetch('/api/admin/whitelist', {
- headers: { 'Authorization': `Bearer ${token}` }
+ 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);
+ console.error("화이트리스트 조회 실패:", error);
} finally {
setWhitelistLoading(false);
}
@@ -617,84 +687,95 @@ export default function Admin({ isMobile = false }) {
// 화이트리스트 토글 (on/off)
const toggleWhitelist = useCallback(async () => {
- const command = whitelistEnabled ? 'whitelist off' : 'whitelist on';
+ 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'
+ 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 })
+ body: JSON.stringify({ command }),
});
if (response.ok) {
setWhitelistEnabled(!whitelistEnabled);
- setToast(`화이트리스트: ${!whitelistEnabled ? '활성화' : '비활성화'}`);
+ setToast(`화이트리스트: ${!whitelistEnabled ? "활성화" : "비활성화"}`);
}
} catch (error) {
- console.error('화이트리스트 토글 실패:', error);
- setToast('화이트리스트 변경 실패');
+ 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();
+ 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("플레이어 추가 실패");
}
- } catch (error) {
- console.error('화이트리스트 추가 실패:', error);
- setToast('플레이어 추가 실패');
- }
- }, [fetchWhitelist]);
+ },
+ [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);
+ 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("플레이어 제거 실패");
}
- } catch (error) {
- console.error('화이트리스트 제거 실패:', error);
- setToast('플레이어 제거 실패');
- }
- }, [fetchWhitelist]);
+ },
+ [fetchWhitelist]
+ );
// 플레이어 탭 활성화 시 데이터 로드
useEffect(() => {
- if (activeTab === 'players' && isAdmin) {
+ if (activeTab === "players" && isAdmin) {
fetchPlayers();
fetchBanList();
fetchWhitelist();
@@ -705,14 +786,19 @@ export default function Admin({ isMobile = false }) {
const checkIsAtBottom = useCallback((container) => {
if (!container) return true;
// 5px 오차 허용 (스크롤 정밀도 문제 대응)
- return container.scrollHeight - container.scrollTop - container.clientHeight < 5;
+ return (
+ container.scrollHeight - container.scrollTop - container.clientHeight < 5
+ );
}, []);
// 스크롤 이벤트 핸들러 - isAtBottom 상태 업데이트
- const handleLogScroll = useCallback((e) => {
- const container = e.target;
- setIsAtBottom(checkIsAtBottom(container));
- }, [checkIsAtBottom]);
+ const handleLogScroll = useCallback(
+ (e) => {
+ const container = e.target;
+ setIsAtBottom(checkIsAtBottom(container));
+ },
+ [checkIsAtBottom]
+ );
// 맨 아래로 스크롤하는 함수
const scrollToBottom = useCallback(() => {
@@ -726,18 +812,18 @@ export default function Admin({ isMobile = false }) {
// 로그 스크롤 - 새 로그 추가 시 부드럽게 스크롤 (맨 아래에 있을 때만)
// CSS scroll-behavior: smooth가 컨테이너에 적용되어 있으므로 scrollTop 설정만으로 부드럽게 동작
useEffect(() => {
- if (activeTab !== 'console' || logs.length === 0) return;
-
+ if (activeTab !== "console" || logs.length === 0) return;
+
const container = logContainerRef.current;
if (!container) return;
-
+
if (isInitialLoad.current) {
// 초기 로드 시 즉시 스크롤 (애니메이션 없이)
- container.style.scrollBehavior = 'auto';
+ container.style.scrollBehavior = "auto";
container.scrollTop = container.scrollHeight;
setIsAtBottom(true);
requestAnimationFrame(() => {
- container.style.scrollBehavior = 'smooth';
+ container.style.scrollBehavior = "smooth";
});
isInitialLoad.current = false;
} else if (isAtBottom) {
@@ -756,12 +842,12 @@ export default function Admin({ isMobile = false }) {
// DOM 마운트 시 (탭 전환 시) 즉시 맨 아래로 스크롤
// CSS smooth가 설정되어 있으므로 일시적으로 비활성화
requestAnimationFrame(() => {
- node.style.scrollBehavior = 'auto'; // 즉시 스크롤
+ node.style.scrollBehavior = "auto"; // 즉시 스크롤
node.scrollTop = node.scrollHeight;
setIsAtBottom(true);
// 다음 프레임에서 smooth 복원 (새 로그 추가 시 부드럽게)
requestAnimationFrame(() => {
- node.style.scrollBehavior = 'smooth';
+ node.style.scrollBehavior = "smooth";
});
});
}
@@ -770,13 +856,14 @@ export default function Admin({ isMobile = false }) {
// 로그 파일 목록 fetch 함수
const fetchLogFiles = async () => {
try {
- const token = localStorage.getItem('token');
+ const token = localStorage.getItem("token");
const params = new URLSearchParams();
- if (selectedLogServer !== 'all') params.append('serverId', selectedLogServer);
- if (selectedLogType !== 'all') params.append('fileType', selectedLogType);
-
+ 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}` }
+ headers: { Authorization: `Bearer ${token}` },
});
const data = await response.json();
if (data.files) {
@@ -786,7 +873,7 @@ export default function Admin({ isMobile = false }) {
setLogServers(data.servers);
}
} catch (error) {
- console.error('로그 파일 목록 조회 실패:', error);
+ console.error("로그 파일 목록 조회 실패:", error);
}
};
@@ -800,22 +887,24 @@ export default function Admin({ isMobile = false }) {
setViewingLog(file);
setLogViewerOpen(true);
setLogLoading(true);
- setLogContent('');
-
+ setLogContent("");
+
try {
- const token = localStorage.getItem('token');
+ const token = localStorage.getItem("token");
const response = await fetch(`/api/admin/logfile?id=${file.id}`, {
- headers: { 'Authorization': `Bearer ${token}` }
+ headers: { Authorization: `Bearer ${token}` },
});
-
+
if (response.ok) {
const blob = await response.blob();
-
+
// .gz 파일만 압축 해제
- if (file.fileName.endsWith('.gz')) {
- const ds = new DecompressionStream('gzip');
+ if (file.fileName.endsWith(".gz")) {
+ const ds = new DecompressionStream("gzip");
const decompressedStream = blob.stream().pipeThrough(ds);
- const decompressedBlob = await new Response(decompressedStream).blob();
+ const decompressedBlob = await new Response(
+ decompressedStream
+ ).blob();
const text = await decompressedBlob.text();
setLogContent(text);
} else {
@@ -824,11 +913,11 @@ export default function Admin({ isMobile = false }) {
setLogContent(text);
}
} else {
- setLogContent('로그 파일을 불러올 수 없습니다.');
+ setLogContent("로그 파일을 불러올 수 없습니다.");
}
} catch (error) {
- console.error('로그 파일 로드 실패:', error);
- setLogContent('로그 파일을 불러오는 중 오류가 발생했습니다.');
+ console.error("로그 파일 로드 실패:", error);
+ setLogContent("로그 파일을 불러오는 중 오류가 발생했습니다.");
} finally {
setLogLoading(false);
}
@@ -843,24 +932,24 @@ export default function Admin({ isMobile = false }) {
// 로그 파일 삭제 실행
const executeDeleteLog = async () => {
- setDeleteLogDialog(prev => ({ ...prev, loading: true }));
-
+ setDeleteLogDialog((prev) => ({ ...prev, loading: true }));
+
try {
- const token = localStorage.getItem('token');
-
+ const token = localStorage.getItem("token");
+
for (const file of deleteLogDialog.files) {
await fetch(`/api/admin/logfile?id=${file.id}`, {
- method: 'DELETE',
- headers: { 'Authorization': `Bearer ${token}` }
+ method: "DELETE",
+ headers: { Authorization: `Bearer ${token}` },
});
}
-
+
setToast(`${deleteLogDialog.files.length}개 로그 파일 삭제 완료`);
fetchLogFiles();
setSelectedLogFiles(new Set());
} catch (error) {
- console.error('로그 파일 삭제 실패:', error);
- setToast('삭제 실패', true);
+ console.error("로그 파일 삭제 실패:", error);
+ setToast("삭제 실패", true);
} finally {
setDeleteLogDialog({ show: false, files: [], loading: false });
}
@@ -869,7 +958,7 @@ export default function Admin({ isMobile = false }) {
// 로그 파일 선택 토글
const toggleLogFileSelect = (fileId, e) => {
e.stopPropagation();
- setSelectedLogFiles(prev => {
+ setSelectedLogFiles((prev) => {
const next = new Set(prev);
if (next.has(fileId)) {
next.delete(fileId);
@@ -885,13 +974,13 @@ export default function Admin({ isMobile = false }) {
if (selectedLogFiles.size === logFiles.length) {
setSelectedLogFiles(new Set());
} else {
- setSelectedLogFiles(new Set(logFiles.map(f => f.id)));
+ setSelectedLogFiles(new Set(logFiles.map((f) => f.id)));
}
};
// 선택된 파일 일괄 삭제
const deleteSelectedLogs = () => {
- const selectedFiles = logFiles.filter(f => selectedLogFiles.has(f.id));
+ const selectedFiles = logFiles.filter((f) => selectedLogFiles.has(f.id));
if (selectedFiles.length > 0) {
openDeleteLogDialog(selectedFiles);
}
@@ -899,46 +988,46 @@ export default function Admin({ isMobile = false }) {
// 마인크래프트 색상 코드 매핑
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
+ 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 buffer = "";
let i = 0;
-
+
while (i < text.length) {
- if (text[i] === '§' && i + 1 < text.length) {
+ if (text[i] === "§" && i + 1 < text.length) {
// 현재 버퍼 저장
if (buffer) {
parts.push({ text: buffer, color: currentColor });
- buffer = '';
+ buffer = "";
}
-
+
const code = text[i + 1].toLowerCase();
if (MC_COLORS[code]) {
currentColor = MC_COLORS[code];
- } else if (code === 'r') {
- currentColor = '#FFFFFF'; // Reset to white
+ } else if (code === "r") {
+ currentColor = "#FFFFFF"; // Reset to white
}
// k, l, m, n, o는 스타일 코드 (무시)
i += 2;
@@ -947,12 +1036,12 @@ export default function Admin({ isMobile = false }) {
i++;
}
}
-
+
// 남은 버퍼 저장
if (buffer) {
parts.push({ text: buffer, color: currentColor });
}
-
+
return parts;
};
@@ -963,107 +1052,123 @@ export default function Admin({ isMobile = false }) {
// 초기 로그 fetch (Socket.io 연결 전)
const fetchInitialLogs = async () => {
try {
- const token = localStorage.getItem('token');
- const response = await fetch('/api/admin/logs', {
- headers: { 'Authorization': `Bearer ${token}` }
+ 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
- })));
+ 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);
+ console.error("초기 로그 조회 오류:", error);
}
};
fetchInitialLogs();
- const socket = io('/', {
- path: '/socket.io',
- transports: ['websocket', 'polling']
+ const socket = io("/", {
+ path: "/socket.io",
+ transports: ["websocket", "polling"],
});
- socket.on('logs', (serverLogs) => {
+ 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
- })));
+ setLogs(
+ serverLogs.map((log) => ({
+ time: log.time,
+ type:
+ log.level === "ERROR"
+ ? "error"
+ : log.level === "WARN"
+ ? "warning"
+ : "info",
+ message: log.message,
+ }))
+ );
}
});
-
+
// 플레이어 목록 실시간 업데이트
- socket.on('players', (playersList) => {
+ socket.on("players", (playersList) => {
if (playersList && Array.isArray(playersList)) {
setPlayers(playersList);
}
});
-
+
// 서버 상태에서 게임 규칙 가져오기
- socket.on('status', (status) => {
+ socket.on("status", (status) => {
if (status?.gameRules) {
setGameRules(status.gameRules);
}
if (status?.difficulty) {
// 난이도를 영문 ID로 변환
const difficultyMap = {
- '평화로움': 'peaceful',
- '쉬움': 'easy',
- '보통': 'normal',
- '어려움': 'hard'
+ 평화로움: "peaceful",
+ 쉬움: "easy",
+ 보통: "normal",
+ 어려움: "hard",
};
- setDifficulty(difficultyMap[status.difficulty] || 'normal');
+ setDifficulty(difficultyMap[status.difficulty] || "normal");
}
// 성능 모니터링 데이터 업데이트
if (status?.tps !== undefined || status?.memoryUsedMb !== undefined) {
- setServerPerformance(prev => ({
+ 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
- }
+ max: status.memoryMaxMb ?? prev.memory.max,
+ },
}));
}
});
-
+
// 월드 정보에서 시간/날씨 가져오기
- socket.on('worlds', (data) => {
+ socket.on("worlds", (data) => {
const worlds = data?.worlds || data;
// 오버월드 찾기
- const overworld = worlds?.find(w => w.dimension === 'minecraft:overworld');
+ 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'
+ clear: "clear",
+ rain: "rain",
+ thunderstorm: "thunder",
};
- setWeather(weatherMap[overworld.weather.type] || 'clear');
+ 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');
+ setTimeOfDay("day");
} else if (dayTime >= 6000 && dayTime < 12000) {
- setTimeOfDay('noon');
+ setTimeOfDay("noon");
} else {
- setTimeOfDay('night');
+ setTimeOfDay("night");
}
}
}
});
-
+
// 월드 정보 요청
- socket.emit('get_worlds');
+ socket.emit("get_worlds");
return () => {
socket.disconnect();
@@ -1072,71 +1177,89 @@ export default function Admin({ isMobile = false }) {
// 게임 규칙 설명 데이터 로드
useEffect(() => {
- fetch('/api/gamerules')
- .then(res => res.json())
- .then(data => setGameRuleDescriptions(data))
- .catch(err => console.error('게임 규칙 설명 로드 실패:', err));
+ 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',
+ const token = localStorage.getItem("token");
+ await fetch("/api/admin/command", {
+ method: "POST",
headers: {
- 'Content-Type': 'application/json',
- 'Authorization': `Bearer ${token}`
+ "Content-Type": "application/json",
+ Authorization: `Bearer ${token}`,
},
- body: JSON.stringify({ command: command.trim() })
+ body: JSON.stringify({ command: command.trim() }),
});
} catch (error) {
// 오류 무시 (로그에서 확인 가능)
}
-
- setCommand('');
+
+ setCommand("");
};
// 플레이어 액션 핸들러
const handlePlayerAction = async () => {
if (!selectedPlayer || !dialogAction) return;
-
- const token = localStorage.getItem('token');
- let command = '';
- let message = '';
-
+
+ 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}님을 추방했습니다.`;
+ 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}님을 차단했습니다.`;
+ case "ban":
+ command = actionReason
+ ? `ban ${selectedPlayer.name} ${actionReason}`
+ : `ban ${selectedPlayer.name}`;
+ message = `${
+ selectedPlayer.displayName || selectedPlayer.name
+ }님을 차단했습니다.`;
break;
- case 'unban':
+ case "unban":
command = `pardon ${selectedPlayer.name}`;
- message = `${selectedPlayer.displayName || selectedPlayer.name}님의 차단을 해제했습니다.`;
+ message = `${
+ selectedPlayer.displayName || selectedPlayer.name
+ }님의 차단을 해제했습니다.`;
break;
- case 'op':
+ 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를 부여했습니다.`;
+ 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'
+ const response = await fetch("/api/admin/command", {
+ method: "POST",
+ headers: {
+ Authorization: `Bearer ${token}`,
+ "Content-Type": "application/json",
},
- body: JSON.stringify({ command })
+ body: JSON.stringify({ command }),
});
-
+
if (response.ok) {
setToast(message);
// 데이터 새로고침
@@ -1145,85 +1268,96 @@ export default function Admin({ isMobile = false }) {
fetchBanList();
}, 500);
} else {
- setToast('명령어 실행에 실패했습니다.');
+ setToast("명령어 실행에 실패했습니다.");
}
} catch (error) {
- console.error('플레이어 액션 오류:', error);
- setToast('서버 연결에 실패했습니다.');
+ console.error("플레이어 액션 오류:", error);
+ setToast("서버 연결에 실패했습니다.");
}
-
+
setShowPlayerDialog(false);
setSelectedPlayer(null);
setDialogAction(null);
- setActionReason('');
+ setActionReason("");
};
// 게임규칙 토글 (서버에 gamerule 명령어 전송)
const toggleGamerule = async (name) => {
const currentValue = gameRules[name];
const newValue = !currentValue;
-
+
// 낙관적 UI 업데이트
- setGameRules(prev => ({ ...prev, [name]: newValue }));
-
+ setGameRules((prev) => ({ ...prev, [name]: newValue }));
+
try {
- const token = localStorage.getItem('token');
- await fetch('/api/admin/command', {
- method: 'POST',
+ const token = localStorage.getItem("token");
+ await fetch("/api/admin/command", {
+ method: "POST",
headers: {
- 'Content-Type': 'application/json',
- 'Authorization': `Bearer ${token}`
+ "Content-Type": "application/json",
+ Authorization: `Bearer ${token}`,
},
- body: JSON.stringify({ command: `gamerule ${name} ${newValue}` })
+ body: JSON.stringify({ command: `gamerule ${name} ${newValue}` }),
});
- setToast(`${name}: ${newValue ? 'true' : 'false'}`);
+ setToast(`${name}: ${newValue ? "true" : "false"}`);
} catch (error) {
// 실패 시 롤백
- setGameRules(prev => ({ ...prev, [name]: currentValue }));
- setToast('게임규칙 변경 실패');
+ 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
+ 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';
+ 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);
- });
+ 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 (
@@ -1242,7 +1376,13 @@ export default function Admin({ isMobile = false }) {
initial={{ opacity: 0, y: 50 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: 50 }}
- className={`fixed bottom-8 inset-x-0 mx-auto w-fit z-[200] backdrop-blur-sm text-white px-6 py-3 rounded-xl text-center font-medium shadow-lg ${toast.type === 'error' ? 'bg-red-500/90' : toast.type === 'warning' ? 'bg-amber-500/90' : 'bg-mc-green/90'}`}
+ className={`fixed bottom-8 inset-x-0 mx-auto w-fit z-[200] backdrop-blur-sm text-white px-6 py-3 rounded-xl text-center font-medium shadow-lg ${
+ toast.type === "error"
+ ? "bg-red-500/90"
+ : toast.type === "warning"
+ ? "bg-amber-500/90"
+ : "bg-mc-green/90"
+ }`}
>
{toast.message}
@@ -1257,14 +1397,14 @@ export default function Admin({ isMobile = false }) {
// 탭 설정
const tabs = [
- { id: 'console', label: '콘솔', icon: Terminal },
- { id: 'players', label: '플레이어', icon: Users },
- { id: 'modpack', label: '모드팩', icon: Package },
- { id: 'settings', label: '설정', icon: Settings },
+ { id: "console", label: "콘솔", icon: Terminal },
+ { id: "players", label: "플레이어", icon: Users },
+ { id: "modpack", label: "모드팩", icon: Package },
+ { id: "settings", label: "설정", icon: Settings },
];
return (
-