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