minecraft-web/frontend/src/pages/Admin.jsx
caadiq 1bb52f58d5 feat: 관리자 페이지 탭 UI 구현
- 탭 UI (콘솔/플레이어/설정)

- 콘솔: 로그 영역 + 명령어 입력 + 로그 파일 목록

- 플레이어: 전신 아바타 + 필터 + 킥/밴/OP

- 설정: 게임규칙 토글 + 난이도 + 시간 + 날씨

- 더미 데이터로 UI 미리보기
2025-12-22 15:30:09 +09:00

623 lines
25 KiB
JavaScript
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* 관리자 페이지
* - 탭 UI: 콘솔 / 플레이어 / 설정
*/
import { useEffect, useState, useRef } from 'react';
import { useNavigate, useLocation, Link } from 'react-router-dom';
import { useAuth } from '../contexts/AuthContext';
import {
Shield, ArrowLeft, Loader2, Terminal, Users, Settings,
Send, Ban, UserX, Crown, Sun, Moon, Cloud, CloudRain, CloudLightning,
ChevronDown, FileText, Download, Trash2, Check
} from 'lucide-react';
import { motion, AnimatePresence } from 'framer-motion';
// 더미 로그 데이터
const DUMMY_LOGS = [
{ time: '15:01:23', type: 'info', message: '[Server] Starting minecraft server version 1.21.1' },
{ time: '15:01:24', type: 'info', message: '[Server] Loading properties' },
{ time: '15:01:25', type: 'info', message: '[Server] Preparing level "world"' },
{ time: '15:01:28', type: 'info', message: '[Server] Done (3.245s)! For help, type "help"' },
{ time: '15:05:12', type: 'info', message: '[Server] 비머[/127.0.0.1:54321] logged in' },
{ time: '15:05:15', type: 'info', message: '[Server] 비머 joined the game' },
{ time: '15:10:30', type: 'warning', message: '[Server] Can\'t keep up! Is the server overloaded?' },
{ time: '15:15:00', type: 'info', message: '[Server] 비머부캐 joined the game' },
];
// 더미 로그 파일 데이터
const DUMMY_LOG_FILES = [
{ name: '2024-12-22.log', size: '2.4 MB', date: '2024-12-22' },
{ name: '2024-12-21.log', size: '1.8 MB', date: '2024-12-21' },
{ name: '2024-12-20.log', size: '3.1 MB', date: '2024-12-20' },
];
// 더미 플레이어 데이터
const DUMMY_PLAYERS = [
{ uuid: '1234-5678-9012-3456', name: '비머', isOnline: true, isOp: true },
{ uuid: '2345-6789-0123-4567', name: '비머부캐', isOnline: true, isOp: false },
{ uuid: '3456-7890-1234-5678', name: 'Steve', isOnline: false, isOp: false },
{ uuid: '4567-8901-2345-6789', name: 'Alex', isOnline: false, isOp: false },
];
// 더미 게임규칙 데이터
const DUMMY_GAMERULES = [
{ name: 'keepInventory', value: false, label: '인벤토리 유지' },
{ name: 'doDaylightCycle', value: true, label: '낮/밤 주기' },
{ name: 'doMobSpawning', value: true, label: '몹 스폰' },
{ name: 'doFireTick', value: true, label: '불 번짐' },
{ name: 'mobGriefing', value: true, label: '몹 그리핑' },
{ name: 'pvp', value: true, label: 'PvP' },
];
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');
// 콘솔 관련 상태
const [logs, setLogs] = useState(DUMMY_LOGS);
const [command, setCommand] = useState('');
const [logFiles] = useState(DUMMY_LOG_FILES);
const logEndRef = useRef(null);
// 플레이어 관련 상태
const [players, setPlayers] = useState(DUMMY_PLAYERS);
const [playerFilter, setPlayerFilter] = useState('all'); // all, online, offline, banned
const [selectedPlayer, setSelectedPlayer] = useState(null);
const [showPlayerDialog, setShowPlayerDialog] = useState(false);
const [dialogAction, setDialogAction] = useState(null); // kick, ban, op
const [actionReason, setActionReason] = useState('');
// 설정 관련 상태
const [gamerules, setGamerules] = useState(DUMMY_GAMERULES);
const [difficulty, setDifficulty] = useState('normal');
const [timeOfDay, setTimeOfDay] = useState('day');
const [weather, setWeather] = useState('clear');
// 권한 확인
useEffect(() => {
if (!loading) {
if (!isLoggedIn) {
navigate('/login', { state: { from: location.pathname } });
} else if (!isAdmin) {
setToast('관리자 권한이 필요합니다.');
setTimeout(() => navigate('/'), 1500);
}
}
}, [isLoggedIn, isAdmin, loading, navigate, location.pathname]);
// 토스트 자동 숨기기
useEffect(() => {
if (toast) {
const timer = setTimeout(() => setToast(null), 3000);
return () => clearTimeout(timer);
}
}, [toast]);
// 로그 스크롤
useEffect(() => {
logEndRef.current?.scrollIntoView({ behavior: 'smooth' });
}, [logs]);
// 명령어 실행 (더미)
const handleCommand = () => {
if (!command.trim()) return;
const newLogs = [
{ time: new Date().toLocaleTimeString('ko-KR', { hour12: false }), type: 'command', message: `> ${command}` },
{ time: new Date().toLocaleTimeString('ko-KR', { hour12: false }), type: 'info', message: `[Server] 명령어 실행됨: ${command}` }
];
setLogs(prev => [...prev, ...newLogs]);
setCommand('');
setToast('명령어가 실행되었습니다.');
};
// 플레이어 액션 핸들러
const handlePlayerAction = () => {
if (!selectedPlayer || !dialogAction) return;
let message = '';
switch (dialogAction) {
case 'kick':
message = `${selectedPlayer.name}님을 추방했습니다.`;
break;
case 'ban':
message = `${selectedPlayer.name}님을 차단했습니다.`;
break;
case 'op':
const isOp = players.find(p => p.uuid === selectedPlayer.uuid)?.isOp;
setPlayers(prev => prev.map(p =>
p.uuid === selectedPlayer.uuid ? { ...p, isOp: !isOp } : p
));
message = isOp ? `${selectedPlayer.name}님의 OP를 해제했습니다.` : `${selectedPlayer.name}님에게 OP를 부여했습니다.`;
break;
}
setShowPlayerDialog(false);
setSelectedPlayer(null);
setDialogAction(null);
setActionReason('');
setToast(message);
};
// 게임규칙 토글
const toggleGamerule = (name) => {
setGamerules(prev => prev.map(rule =>
rule.name === name ? { ...rule, value: !rule.value } : rule
));
setToast('게임규칙이 변경되었습니다.');
};
// 로그 색상
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';
}
};
// 필터된 플레이어
const filteredPlayers = players.filter(p => {
if (playerFilter === 'online') return p.isOnline;
if (playerFilter === 'offline') return !p.isOnline;
return true;
});
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) {
return (
<>
<AnimatePresence>
{toast && (
<motion.div
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"
>
{toast}
</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>
</>
);
}
// 탭 설정
const tabs = [
{ id: 'console', label: '콘솔', icon: Terminal },
{ id: 'players', label: '플레이어', icon: Users },
{ id: 'settings', label: '설정', icon: Settings },
];
return (
<div className="pb-8">
{/* 토스트 */}
<AnimatePresence>
{toast && (
<motion.div
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"
>
{toast}
</motion.div>
)}
</AnimatePresence>
{/* 모바일용 헤더 */}
{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">
<Link to="/" className="p-2 -ml-2 rounded-lg text-zinc-400 hover:text-white hover:bg-white/5 transition-colors">
<ArrowLeft size={20} />
</Link>
<div className="ml-2">
<h1 className="text-lg font-bold text-white">관리자</h1>
</div>
</div>
</header>
)}
<main className={`max-w-4xl mx-auto px-4 sm:px-6 ${isMobile ? 'py-4' : 'py-8'}`}>
{/* 데스크탑용 타이틀 */}
{!isMobile && (
<div className="mb-6">
<h1 className="text-2xl font-bold text-white">관리자 페이지</h1>
<p className="text-sm text-zinc-500 mt-1">서버 관리 설정</p>
</div>
)}
{/* 탭 네비게이션 */}
<div className="flex gap-1 p-1 bg-zinc-900 rounded-xl mb-6">
{tabs.map(tab => (
<button
key={tab.id}
onClick={() => setActiveTab(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 className={isMobile ? 'hidden sm:inline' : ''}>{tab.label}</span>
</button>
))}
</div>
{/* 탭 콘텐츠 */}
<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"
>
{/* 로그 영역 */}
<div className="bg-zinc-900 border border-zinc-800 rounded-2xl overflow-hidden">
<div className="h-80 overflow-y-auto bg-zinc-950 p-4 font-mono text-sm">
{logs.map((log, index) => (
<div key={index} className={`${getLogColor(log.type)} leading-relaxed`}>
<span className="text-zinc-600">[{log.time}]</span> {log.message}
</div>
))}
<div ref={logEndRef} />
</div>
{/* 명령어 입력 */}
<div className="p-4 border-t border-zinc-800 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)}
onKeyDown={(e) => e.key === 'Enter' && handleCommand()}
placeholder="명령어 입력..."
className="flex-1 bg-transparent py-3 text-white placeholder-zinc-500 focus:outline-none font-mono"
/>
</div>
<button
onClick={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>
</div>
</div>
{/* 로그 파일 목록 */}
<div className="bg-zinc-900 border border-zinc-800 rounded-2xl p-4">
<h3 className="text-white font-medium mb-3 flex items-center gap-2">
<FileText size={18} className="text-zinc-400" />
로그 파일
</h3>
<div className="space-y-2">
{logFiles.map((file, index) => (
<div key={index} className="flex items-center justify-between p-3 bg-zinc-800/50 rounded-xl">
<div>
<p className="text-white text-sm">{file.name}</p>
<p className="text-xs text-zinc-500">{file.size}</p>
</div>
<div className="flex gap-2">
<button className="p-2 text-zinc-400 hover:text-white hover:bg-zinc-700 rounded-lg transition-colors">
<Download size={16} />
</button>
<button className="p-2 text-red-400 hover:text-red-300 hover:bg-red-500/10 rounded-lg transition-colors">
<Trash2 size={16} />
</button>
</div>
</div>
))}
</div>
</div>
</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: '전체' },
{ id: 'online', label: '온라인' },
{ id: 'offline', label: '오프라인' },
].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
? 'bg-mc-green text-white'
: 'bg-zinc-800 text-zinc-400 hover:text-white'
}`}
>
{filter.label}
</button>
))}
</div>
{/* 플레이어 그리드 */}
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 gap-4">
{filteredPlayers.map((player) => (
<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'}`} />
{/* 전신 아바타 */}
<img
src={`https://mc-heads.net/body/${player.uuid}/100`}
alt={player.name}
className="w-16 h-32 mx-auto mb-3 drop-shadow-lg"
/>
<p className="text-white font-medium mb-3">{player.name}</p>
{/* 액션 버튼 */}
<div className="flex justify-center gap-1">
<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>
)}
<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>
</div>
</div>
))}
</div>
</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">
<h3 className="text-white font-medium mb-4">🎮 게임규칙</h3>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-2">
{gamerules.map(rule => (
<button
key={rule.name}
onClick={() => toggleGamerule(rule.name)}
className={`flex items-center justify-between p-3 rounded-xl transition-colors border ${
rule.value ? 'bg-mc-green/20 border-mc-green/30' : 'bg-zinc-800/50 border-transparent'
}`}
>
<span className="text-white text-sm">{rule.label}</span>
<div className={`w-10 h-6 rounded-full relative transition-colors ${rule.value ? 'bg-mc-green' : 'bg-zinc-600'}`}>
<div className={`absolute top-1 w-4 h-4 rounded-full bg-white transition-transform ${rule.value ? 'left-5' : 'left-1'}`} />
</div>
</button>
))}
</div>
</div>
{/* 난이도 */}
<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}
onClick={() => { setDifficulty(d.id); setToast('난이도가 변경되었습니다.'); }}
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>
</div>
{/* 시간 */}
<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: 'day', label: '아침', icon: Sun },
{ id: 'noon', label: '낮', icon: Sun },
{ id: 'night', label: '밤', icon: Moon },
].map(t => (
<button
key={t.id}
onClick={() => { setTimeOfDay(t.id); setToast('시간이 변경되었습니다.'); }}
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>
</div>
{/* 날씨 */}
<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}
onClick={() => { setWeather(w.id); setToast('날씨가 변경되었습니다.'); }}
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>
)}
</AnimatePresence>
</main>
{/* 플레이어 액션 다이얼로그 */}
<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" /> 플레이어 </>}
{dialogAction === 'op' && <><Crown size={20} className="text-yellow-500" /> OP {players.find(p => p.uuid === selectedPlayer.uuid)?.isOp ? '해제' : '부여'}</>}
</h3>
<div className="flex items-center gap-4 mb-4">
<img
src={`https://mc-heads.net/body/${selectedPlayer.uuid}/60`}
alt={selectedPlayer.name}
className="w-10 h-20"
/>
<div>
<p className="text-white font-medium">{selectedPlayer.name}</p>
<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' :
'bg-yellow-500 hover:bg-yellow-600 text-black'
}`}
>
확인
</button>
</div>
</motion.div>
</motion.div>
)}
</AnimatePresence>
</div>
);
}