diff --git a/backend/routes/admin.js b/backend/routes/admin.js index 5fec1df..69d263e 100644 --- a/backend/routes/admin.js +++ b/backend/routes/admin.js @@ -192,6 +192,26 @@ router.get("/banlist", async (req, res) => { } }); +/** + * GET /api/admin/whitelist - 화이트리스트 조회 (모드 API 프록시) + */ +router.get("/whitelist", async (req, res) => { + try { + const response = await fetch(`${MOD_API_URL}/whitelist`); + const data = await response.json(); + res.json(data); + } catch (error) { + console.error("[Admin] 화이트리스트 조회 오류:", error); + res + .status(500) + .json({ + enabled: false, + players: [], + error: "서버에 연결할 수 없습니다", + }); + } +}); + /** * GET /api/admin/logfiles - DB에서 로그 파일 목록 조회 */ diff --git a/frontend/src/pages/Admin.jsx b/frontend/src/pages/Admin.jsx index 2e6f799..d1ab11e 100644 --- a/frontend/src/pages/Admin.jsx +++ b/frontend/src/pages/Admin.jsx @@ -31,14 +31,60 @@ const DUMMY_LOGS = [ // 더미 게임규칙 데이터 제거됨 - 소켓에서 실시간으로 가져옴 +// 스티브 기본 스킨 (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 ( + {name} { e.target.src = fallbackUrl; }} + /> + ); +}; + export default function Admin({ isMobile = false }) { const { isLoggedIn, isAdmin, user, loading } = useAuth(); const navigate = useNavigate(); const location = useLocation(); const [toast, setToast] = useState(null); - // 탭 상태 - const [activeTab, setActiveTab] = useState('console'); + // 탭 상태 (URL 해시에서 초기값 로드) + const getInitialTab = () => { + const hash = window.location.hash.replace('#', ''); + return ['console', 'players', 'settings'].includes(hash) ? hash : 'console'; + }; + const [activeTab, setActiveTab] = useState(getInitialTab); + + // 탭 변경 시 URL 해시 업데이트 + const handleTabChange = (tab) => { + setActiveTab(tab); + window.location.hash = tab; + }; // 콘솔 관련 상태 const [logs, setLogs] = useState([]); @@ -77,6 +123,20 @@ export default function Admin({ isMobile = false }) { const [difficulty, setDifficulty] = useState('normal'); const [timeOfDay, setTimeOfDay] = useState('day'); const [weather, setWeather] = useState('clear'); + + // 화이트리스트 상태 (API 연동) + const [whitelistEnabled, setWhitelistEnabled] = useState(false); + const [whitelistPlayers, setWhitelistPlayers] = useState([]); + const [newWhitelistPlayer, setNewWhitelistPlayer] = useState(''); + const [whitelistRemoveTarget, setWhitelistRemoveTarget] = useState(null); // 삭제 확인 다이얼로그용 + const [whitelistLoading, setWhitelistLoading] = useState(false); + + // 성능 모니터링 상태 (더미) + const [serverPerformance, setServerPerformance] = useState({ + tps: 19.8, + cpu: 35.2, + memory: { used: 2150, max: 4096 }, + }); // 권한 확인 useEffect(() => { @@ -130,11 +190,107 @@ export default function Admin({ isMobile = false }) { } }, []); + // 화이트리스트 API 함수들 + const fetchWhitelist = useCallback(async () => { + try { + setWhitelistLoading(true); + const token = localStorage.getItem('token'); + const response = await fetch('/api/admin/whitelist', { + headers: { 'Authorization': `Bearer ${token}` } + }); + const data = await response.json(); + setWhitelistEnabled(data.enabled || false); + setWhitelistPlayers(data.players || []); + } catch (error) { + console.error('화이트리스트 조회 실패:', error); + } finally { + setWhitelistLoading(false); + } + }, []); + + // 화이트리스트 토글 (on/off) + const toggleWhitelist = useCallback(async () => { + const command = whitelistEnabled ? 'whitelist off' : 'whitelist on'; + try { + const token = localStorage.getItem('token'); + const response = await fetch('/api/admin/command', { + method: 'POST', + headers: { + 'Authorization': `Bearer ${token}`, + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ command }) + }); + if (response.ok) { + setWhitelistEnabled(!whitelistEnabled); + setToast(`화이트리스트: ${!whitelistEnabled ? '활성화' : '비활성화'}`); + } + } catch (error) { + console.error('화이트리스트 토글 실패:', error); + setToast('화이트리스트 변경 실패'); + } + }, [whitelistEnabled]); + + // 화이트리스트 플레이어 추가 + const addWhitelistPlayer = useCallback(async (playerName) => { + if (!playerName.trim()) return; + try { + const token = localStorage.getItem('token'); + const response = await fetch('/api/admin/command', { + method: 'POST', + headers: { + 'Authorization': `Bearer ${token}`, + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ command: `whitelist add ${playerName.trim()}` }) + }); + if (response.ok) { + setToast(`${playerName.trim()} 추가됨`); + setNewWhitelistPlayer(''); + // 즉시 프론트엔드 상태 업데이트 (임시 uuid) + setWhitelistPlayers(prev => [...prev, { + uuid: crypto.randomUUID(), + name: playerName.trim() + }]); + // 백그라운드에서 서버 동기화 (정확한 uuid 가져오기) + fetchWhitelist(); + } + } catch (error) { + console.error('화이트리스트 추가 실패:', error); + setToast('플레이어 추가 실패'); + } + }, [fetchWhitelist]); + + // 화이트리스트 플레이어 제거 + const removeWhitelistPlayer = useCallback(async (playerName) => { + try { + const token = localStorage.getItem('token'); + const response = await fetch('/api/admin/command', { + method: 'POST', + headers: { + 'Authorization': `Bearer ${token}`, + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ command: `whitelist remove ${playerName}` }) + }); + if (response.ok) { + setToast(`${playerName} 제거됨`); + setWhitelistRemoveTarget(null); + // 약간의 딜레이 후 목록 새로고침 (서버 반영 시간) + setTimeout(fetchWhitelist, 500); + } + } catch (error) { + console.error('화이트리스트 제거 실패:', error); + setToast('플레이어 제거 실패'); + } + }, [fetchWhitelist]); + // 플레이어 탭 활성화 시 데이터 로드 useEffect(() => { if (activeTab === 'players' && isAdmin) { fetchPlayers(); fetchBanList(); + fetchWhitelist(); } }, [activeTab, isAdmin]); @@ -599,10 +755,13 @@ export default function Admin({ isMobile = false }) { banReason: ban.reason, banSource: ban.source })) - : players.filter(p => { - if (playerFilter === 'online') return p.isOnline; - if (playerFilter === 'offline') return !p.isOnline; - return true; + : [...players].sort((a, b) => { + // 온라인 우선 + if (a.isOnline !== b.isOnline) return b.isOnline ? 1 : -1; + // 같은 온라인 상태면 OP 우선 + if (a.isOp !== b.isOp) return b.isOp ? 1 : -1; + // 그 외에는 닉네임 순 + return (a.displayName || a.name).localeCompare(b.displayName || b.name); }); if (loading) { @@ -622,7 +781,7 @@ export default function Admin({ isMobile = false }) { initial={{ opacity: 0, y: 50 }} animate={{ opacity: 1, y: 0 }} exit={{ opacity: 0, y: 50 }} - className="fixed bottom-8 left-1/2 -translate-x-1/2 z-[200] bg-red-500/90 backdrop-blur-sm text-white px-6 py-3 rounded-xl text-center font-medium shadow-lg" + className="fixed bottom-8 inset-x-0 mx-auto w-fit z-[200] bg-red-500/90 backdrop-blur-sm text-white px-6 py-3 rounded-xl text-center font-medium shadow-lg" > {toast} @@ -651,7 +810,7 @@ export default function Admin({ isMobile = false }) { initial={{ opacity: 0, y: 50 }} animate={{ opacity: 1, y: 0 }} exit={{ opacity: 0, y: 50 }} - className="fixed bottom-8 left-1/2 -translate-x-1/2 z-[200] bg-mc-green/90 backdrop-blur-sm text-white px-6 py-3 rounded-xl text-center font-medium shadow-lg" + className="fixed bottom-8 inset-x-0 mx-auto w-fit z-[200] bg-mc-green/90 backdrop-blur-sm text-white px-6 py-3 rounded-xl text-center font-medium shadow-lg" > {toast} @@ -686,7 +845,7 @@ export default function Admin({ isMobile = false }) { {tabs.map(tab => ( ))} - {/* 플레이어 그리드 */} -
- {filteredPlayers.map((player) => ( + {/* 화이트리스트 필터 선택 시 */} + {playerFilter === 'whitelist' ? ( +
+ {/* 화이트리스트 On/Off 토글 */} +
+ 화이트리스트 활성화 + +
+ + {/* 플레이어 추가 */} +
+ setNewWhitelistPlayer(e.target.value)} + onKeyDown={(e) => { + if (e.key === 'Enter') { + addWhitelistPlayer(newWhitelistPlayer); + } + }} + placeholder="플레이어 이름 입력..." + className="flex-1 bg-zinc-800 border border-zinc-700 rounded-lg px-3 py-2 text-white text-sm placeholder-zinc-500 focus:outline-none focus:border-mc-green/50" + /> + +
+ + {/* 화이트리스트 플레이어 그리드 */} + {whitelistPlayers.length > 0 ? ( +
+ {whitelistPlayers.map(player => ( +
+ + + {player.name} +
+ ))} +
+ ) : ( +
+ 화이트리스트가 비어있습니다 +
+ )} +
+ ) : ( + /* 플레이어 그리드 */ +
+ {filteredPlayers.map((player) => (
{/* 전신 아바타 */} - {player.name} @@ -1075,7 +1383,8 @@ export default function Admin({ isMobile = false }) {
))} -
+ + )} )} @@ -1272,9 +1581,11 @@ export default function Admin({ isMobile = false }) {
- {selectedPlayer.name}
@@ -1417,6 +1728,56 @@ export default function Admin({ isMobile = false }) { )} + + {/* 화이트리스트 삭제 확인 다이얼로그 */} + + {whitelistRemoveTarget && ( + setWhitelistRemoveTarget(null)} + > + e.stopPropagation()} + > +
+ +

화이트리스트 제거

+

+ {whitelistRemoveTarget.name}님을 + 화이트리스트에서 제거하시겠습니까? +

+
+
+ + +
+
+
+ )} +
); }