From a1956a8ed5fb51ea39249109ed5b3103144436c2 Mon Sep 17 00:00:00 2001 From: caadiq Date: Mon, 29 Dec 2025 13:28:22 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20=EC=84=9C=EB=B2=84=20=EA=B4=80=EB=A6=AC?= =?UTF-8?q?=20UI=20=EC=B6=94=EA=B0=80=20(=EC=BD=98=EC=86=94=20=ED=83=AD)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 서버 목록 표시 (NeoForge/Fabric 구분) - 실행 상태에 따른 스타일 (녹색 펄스/회색) - 시작/종료 버튼 (기능 미구현, TODO) --- frontend/src/pages/Admin.jsx | 2511 ++++++++++++++++++++++------------ 1 file changed, 1625 insertions(+), 886 deletions(-) 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 ( - {name} { 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 ( -
+
{/* 토스트 */} {toast && ( @@ -1272,7 +1412,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} @@ -1283,7 +1429,10 @@ export default function Admin({ isMobile = false }) { {isMobile && (
- +
@@ -1293,7 +1442,11 @@ export default function Admin({ isMobile = false }) {
)} -
+
{/* 데스크탑용 타이틀 */} {!isMobile && (
@@ -1305,14 +1458,14 @@ export default function Admin({ isMobile = false }) { {/* 탭 네비게이션 - 데스크톱 */} {!isMobile && (
- {tabs.map(tab => ( + {tabs.map((tab) => ( +
+ ))} +
+
+ {/* 서버 성능 모니터링 */}

📊 서버 성능

@@ -1341,62 +1589,105 @@ export default function Admin({ isMobile = false }) {
TPS - = 18 ? 'text-mc-green' : - serverPerformance.tps >= 15 ? 'text-yellow-400' : 'text-red-400' - }`}> + = 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' + serverPerformance.tps >= 18 + ? "bg-mc-green" + : serverPerformance.tps >= 15 + ? "bg-yellow-400" + : "bg-red-400" }`} - style={{ width: `${Math.min(100, (serverPerformance.tps / 20) * 100)}%` }} + 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 + {(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' + serverPerformance.memory.max === 0 + ? "bg-mc-diamond" + : serverPerformance.memory.used / + serverPerformance.memory.max > + 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}%` }} + style={{ + width: `${ + serverPerformance.memory.max === 0 + ? 0 + : (serverPerformance.memory.used / + serverPerformance.memory.max) * + 100 + }%`, + }} />
@@ -1405,22 +1696,36 @@ export default function Admin({ isMobile = false }) { {/* 로그 영역 */}
-

📝 콘솔 로그

-
+ 📝 콘솔 로그 + +
{logs.map((log, index) => ( -
- [{log.time}]{' '} + + [{log.time}] + {" "} {parseMinecraftColors(log.message).map((part, i) => ( - + {part.text} ))} @@ -1428,7 +1733,7 @@ export default function Admin({ isMobile = false }) { ))}
- + {/* 맨 아래로 스크롤 버튼 */} {!isAtBottom && logs.length > 0 && ( @@ -1438,18 +1743,18 @@ export default function Admin({ isMobile = false }) { exit={{ opacity: 0, scale: 0.8 }} onClick={scrollToBottom} className="absolute bottom-[88px] right-4 p-3 bg-mc-green hover:bg-mc-green text-white rounded-full transition-colors z-10" - style={{ boxShadow: '0 0 16px rgba(76, 175, 80, 0.5)' }} + style={{ boxShadow: "0 0 16px rgba(76, 175, 80, 0.5)" }} > )} - + {/* 명령어 입력 */}
- {'>'} + {">"} { // 히스토리 네비게이션 - if (e.key === 'ArrowUp' && commandHistory.length > 0) { + if ( + e.key === "ArrowUp" && + commandHistory.length > 0 + ) { e.preventDefault(); - const newIndex = historyIndex < commandHistory.length - 1 ? historyIndex + 1 : historyIndex; + const newIndex = + historyIndex < commandHistory.length - 1 + ? historyIndex + 1 + : historyIndex; setHistoryIndex(newIndex); - setCommand(commandHistory[commandHistory.length - 1 - newIndex] || ''); + setCommand( + commandHistory[ + commandHistory.length - 1 - newIndex + ] || "" + ); return; } - if (e.key === 'ArrowDown') { + if (e.key === "ArrowDown") { e.preventDefault(); - const newIndex = historyIndex > 0 ? historyIndex - 1 : -1; + const newIndex = + historyIndex > 0 ? historyIndex - 1 : -1; setHistoryIndex(newIndex); - setCommand(newIndex >= 0 ? commandHistory[commandHistory.length - 1 - newIndex] : ''); + setCommand( + newIndex >= 0 + ? commandHistory[ + commandHistory.length - 1 - newIndex + ] + : "" + ); return; } - + // Enter 키로 명령어 실행 - if (e.key === 'Enter' && command.trim()) { + if (e.key === "Enter" && command.trim()) { // 히스토리에 추가 - setCommandHistory(prev => [...prev.slice(-49), command]); + setCommandHistory((prev) => [ + ...prev.slice(-49), + command, + ]); setHistoryIndex(-1); handleCommand(); } @@ -1489,7 +1814,10 @@ export default function Admin({ isMobile = false }) {
- + {/* 필터 드롭다운 */}
{/* 서버 선택 드롭다운 */}
{serverDropdownOpen && (
- {logServers.map(server => ( + {logServers.map((server) => ( @@ -1552,32 +1910,51 @@ export default function Admin({ isMobile = false }) {
)}
- + {/* 종류 선택 드롭다운 */}
{typeDropdownOpen && (
{[ - { value: 'all', label: '모든 종류' }, - { value: 'dated', label: '📅 날짜별' }, - { value: 'debug', label: '🐛 디버그' }, - { value: 'latest', label: '⚡ 최신' } - ].map(item => ( + { value: "all", label: "모든 종류" }, + { value: "dated", label: "📅 날짜별" }, + { value: "debug", label: "🐛 디버그" }, + { value: "latest", label: "⚡ 최신" }, + ].map((item) => ( @@ -1586,7 +1963,7 @@ export default function Admin({ isMobile = false }) { )}
- + {/* 일괄 삭제 컨트롤 - 항상 표시하여 레이아웃 밀림 방지 */} {logFiles.length > 0 && (
@@ -1595,19 +1972,29 @@ export default function Admin({ isMobile = false }) { onClick={toggleSelectAllLogs} className="text-xs text-zinc-400 hover:text-white transition-colors" > - {selectedLogFiles.size === logFiles.length ? '전체 해제' : '전체 선택'} + {selectedLogFiles.size === logFiles.length + ? "전체 해제" + : "전체 선택"} - 0 ? 'text-zinc-500' : 'text-transparent'}`}> - {selectedLogFiles.size > 0 ? `${selectedLogFiles.size}개 선택됨` : '-'} + 0 + ? "text-zinc-500" + : "text-transparent" + }`} + > + {selectedLogFiles.size > 0 + ? `${selectedLogFiles.size}개 선택됨` + : "-"}
)} - +
{logFiles.length === 0 ? ( -

로그 파일이 없습니다

+

+ 로그 파일이 없습니다 +

) : ( logFiles.map((file) => ( -
viewLogContent(file)} > @@ -1632,21 +2023,30 @@ export default function Admin({ isMobile = false }) {
-

{file.fileName}

+

+ {file.fileName} +

- {file.fileSize} • {file.serverId} • - + {file.fileSize} • {file.serverId} • + {file.fileType}

@@ -1654,7 +2054,7 @@ export default function Admin({ isMobile = false }) {
- - ))}
{/* 화이트리스트 필터 선택 시 */} - {playerFilter === 'whitelist' ? ( + {playerFilter === "whitelist" ? (
{/* 화이트리스트 On/Off 토글 */}
- 화이트리스트 활성화 + + 화이트리스트 활성화 +
- + {/* 플레이어 추가 */}
setNewWhitelistPlayer(e.target.value)} onKeyDown={(e) => { - if (e.key === 'Enter') { + if (e.key === "Enter") { addWhitelistPlayer(newWhitelistPlayer); } }} @@ -1770,12 +2184,12 @@ export default function Admin({ isMobile = false }) { 추가
- + {/* 화이트리스트 플레이어 그리드 */} {whitelistPlayers.length > 0 ? (
- {whitelistPlayers.map(player => ( -
( +
@@ -1794,7 +2208,9 @@ export default function Admin({ isMobile = false }) { size={100} className="h-32 mx-auto mt-2 mb-2 drop-shadow-lg" /> - {player.name} + + {player.name} +
))}
@@ -1808,104 +2224,112 @@ export default function Admin({ isMobile = false }) { /* 플레이어 그리드 */
{filteredPlayers.map((player) => ( -
- {/* OP 뱃지 */} - {player.isOp && ( -
- +
+ {/* OP 뱃지 */} + {player.isOp && ( +
+ +
+ )} + + {/* 온/오프라인 표시 */} +
+ + {/* 전신 아바타 */} + + +

+ {player.displayName || player.name} +

+

{player.name}

+ {player.isBanned && ( + + 차단됨 + + )} + + {/* 액션 버튼 - mt-2 추가 */} +
+ + + + {player.isOnline && ( + + + + )} + {player.isBanned ? ( + + + + ) : ( + + + + )}
- )} - - {/* 온/오프라인 표시 */} -
- - {/* 전신 아바타 */} - - -

{player.displayName || player.name}

-

{player.name}

- {player.isBanned && ( - 차단됨 - )} - - {/* 액션 버튼 - mt-2 추가 */} -
- - - - {player.isOnline && ( - - - - )} - {player.isBanned ? ( - - - - ) : ( - - - - )}
-
- ))} + ))}
)} )} {/* 설정 탭 */} - {activeTab === 'settings' && ( + {activeTab === "settings" && ( toggleGamerule(rule)} className={`flex items-center justify-between p-3 rounded-xl transition-colors border ${ - value ? 'bg-mc-green/20 border-mc-green/30' : 'bg-zinc-800/50 border-transparent hover:border-zinc-700' + value + ? "bg-mc-green/20 border-mc-green/30" + : "bg-zinc-800/50 border-transparent hover:border-zinc-700" }`} > - {rule} -
-
+
+
))} @@ -1959,31 +2397,36 @@ export default function Admin({ isMobile = false }) {

⚔️ 난이도

{[ - { id: 'peaceful', label: '평화로움' }, - { id: 'easy', label: '쉬움' }, - { id: 'normal', label: '보통' }, - { id: 'hard', label: '어려움' }, - ].map(d => ( + { id: "peaceful", label: "평화로움" }, + { id: "easy", label: "쉬움" }, + { id: "normal", label: "보통" }, + { id: "hard", label: "어려움" }, + ].map((d) => ( )}
- {fileObj.status === 'error' && fileObj.error && ( + {fileObj.status === "error" && fileObj.error && (
{fileObj.error}
@@ -2189,9 +2670,10 @@ export default function Admin({ isMobile = false }) { ); })}
- + {/* 업로드 버튼 */} - {(pendingFiles.some(f => f.status === 'pending') || translationLoading) && ( + {(pendingFiles.some((f) => f.status === "pending") || + translationLoading) && ( @@ -2216,21 +2703,25 @@ export default function Admin({ isMobile = false }) { {/* 업로드된 모드 목록 */} {modTranslations.length > 0 && (
- {isModListExpanded && (
- +
- {mod.mod_id} + + {mod.mod_id} + - 블록 {mod.block_count} · 아이템 {mod.item_count} + 블록 {mod.block_count} · 아이템{" "} + {mod.item_count}
)} - {fileObj.status === 'error' && ( - + {fileObj.status === "error" && ( + {fileObj.error} )} @@ -2380,7 +2907,8 @@ export default function Admin({ isMobile = false }) {
{/* 업로드 시작 버튼 */} - {(pendingIconFiles.some(f => f.status === 'pending') || iconUploading) && ( + {(pendingIconFiles.some((f) => f.status === "pending") || + iconUploading) && ( - 아이콘 업로드 시작 ({pendingIconFiles.filter(f => f.status === 'pending').length}개) + 아이콘 업로드 시작 ( + { + pendingIconFiles.filter( + (f) => f.status === "pending" + ).length + } + 개) )} @@ -2407,21 +2941,25 @@ export default function Admin({ isMobile = false }) { {/* 업로드된 모드 목록 */} {iconMods.length > 0 && (
- {isIconListExpanded && (
- +
- {mod.mod_id} + + {mod.mod_id} + - 블록 {mod.block_count} · 아이템 {mod.item_count} + 블록 {mod.block_count} · 아이템{" "} + {mod.item_count}
- + {/* 모드팩 목록 */}
- {modpacks.map((pack, i) => ( + {modpacks.map((pack, i) => isMobile ? ( /* 모바일 레이아웃 - 세로 카드 */ -
+
- {i === 0 && 최신} - v{pack.version} + {i === 0 && ( + + 최신 + + )} + + v{pack.version} +
- -
-

{pack.name}

-

{pack.date} · {pack.size}

+

+ {pack.name} +

+

+ {pack.date} · {pack.size} +

) : ( /* 데스크톱 레이아웃 - 가로 */ -
+
-
- +
+
- {pack.name} - v{pack.version} - {i === 0 && 최신} + + {pack.name} + + + v{pack.version} + + {i === 0 && ( + + 최신 + + )}
- {pack.date} · {pack.size} + + {pack.date} · {pack.size} +
- -
) - ))} + )}
{/* 빈 상태 */} {modpacks.length === 0 && (
-

등록된 모드팩이 없습니다

+

+ 등록된 모드팩이 없습니다 +

)}
@@ -2574,12 +3166,12 @@ export default function Admin({ isMobile = false }) { {isMobile && (
- {tabs.map(tab => ( + {tabs.map((tab) => (
@@ -2742,9 +3377,12 @@ export default function Admin({ isMobile = false }) {
-

{viewingLog?.fileName}

+

+ {viewingLog?.fileName} +

- {viewingLog?.fileSize} • {viewingLog?.serverId} • {viewingLog?.fileType} + {viewingLog?.fileSize} • {viewingLog?.serverId} •{" "} + {viewingLog?.fileType}

@@ -2755,30 +3393,46 @@ export default function Admin({ isMobile = false }) {
- + {/* 로그 내용 */} -
+
{logLoading ? (
) : (
- {logContent.split('\n').map((line, idx) => { + {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'; - + 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} + + {part.text} + ))}
); @@ -2786,20 +3440,23 @@ export default function Admin({ isMobile = false }) {
)}
- + {/* 하단 버튼 */}
@@ -2939,15 +3619,26 @@ export default function Admin({ isMobile = false }) { animate={{ scale: 1, opacity: 1 }} exit={{ scale: 0.9, opacity: 0 }} className="bg-zinc-900 border border-zinc-800 rounded-2xl p-6 w-full max-w-sm mx-4" - onClick={e => e.stopPropagation()} + onClick={(e) => e.stopPropagation()} > -

아이콘 삭제

+

+ 아이콘 삭제 +

- {deleteIconDialog.modId} 모드의 아이콘을 삭제하시겠습니까? + + {deleteIconDialog.modId} + {" "} + 모드의 아이콘을 삭제하시겠습니까?

@@ -2985,10 +3682,10 @@ export default function Admin({ isMobile = false }) { className="fixed inset-0 bg-black/50 backdrop-blur-sm flex items-center justify-center z-[100] p-4" onClick={() => { // 배경 클릭 시 바운스 효과 - const dialog = document.getElementById('modpack-dialog'); + const dialog = document.getElementById("modpack-dialog"); if (dialog) { - dialog.classList.add('animate-shake'); - setTimeout(() => dialog.classList.remove('animate-shake'), 150); + dialog.classList.add("animate-shake"); + setTimeout(() => dialog.classList.remove("animate-shake"), 150); } }} > @@ -2998,19 +3695,25 @@ export default function Admin({ isMobile = false }) { animate={{ scale: 1, opacity: 1 }} exit={{ scale: 0.9, opacity: 0 }} className="bg-zinc-900 border border-zinc-800 rounded-2xl p-6 max-w-md w-full max-h-[80vh] overflow-y-auto [&.animate-shake]:animate-[shake_0.15s_ease-in-out]" - onClick={e => e.stopPropagation()} + onClick={(e) => e.stopPropagation()} >

- {modpackDialogMode === 'upload' ? '📦 모드팩 업로드' : '✏️ 모드팩 수정'} + {modpackDialogMode === "upload" + ? "📦 모드팩 업로드" + : "✏️ 모드팩 수정"}

- + {/* 파일 선택 (업로드 모드에서만) */} - {modpackDialogMode === 'upload' && ( + {modpackDialogMode === "upload" && (
- - +
)} - + {/* 수정 모드에서 파일명 표시 */} - {modpackDialogMode === 'edit' && editingModpack && ( + {modpackDialogMode === "edit" && editingModpack && (
-

{editingModpack.name} v{editingModpack.version}

-

{editingModpack.size}

+

+ {editingModpack.name} v{editingModpack.version} +

+

+ {editingModpack.size} +

@@ -3063,10 +3778,17 @@ export default function Admin({ isMobile = false }) { {/* 변경 로그 */}
- +