feat: 관리자 설정 탭 기능 완성
- 게임규칙: 서버에서 실시간 목록 가져오기, 툴팁 설명, 토글 시 gamerule 명령어 실행 - 난이도: 서버에서 현재 난이도 가져오기, difficulty 명령어 실행 - 시간: 실시간 동기화 (틱 기반), time set 명령어 실행 - 날씨: 실시간 동기화, weather 명령어 실행 - 백엔드: worlds 정보 주기적 브로드캐스트 추가
This commit is contained in:
parent
6fe6d0dda0
commit
6fb441dc80
2 changed files with 180 additions and 39 deletions
|
|
@ -123,6 +123,17 @@ async function refreshAndBroadcast() {
|
||||||
const { cachedStatus, cachedPlayers } = await refreshData();
|
const { cachedStatus, cachedPlayers } = await refreshData();
|
||||||
io.emit("status", cachedStatus);
|
io.emit("status", cachedStatus);
|
||||||
io.emit("players", cachedPlayers);
|
io.emit("players", cachedPlayers);
|
||||||
|
|
||||||
|
// 월드 정보도 브로드캐스트 (시간/날씨 포함)
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${MOD_API_URL}/worlds`);
|
||||||
|
if (response.ok) {
|
||||||
|
const data = await response.json();
|
||||||
|
io.emit("worlds", data);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
// 연결 오류 무시
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 로그 캐시 (중복 브로드캐스트 방지)
|
// 로그 캐시 (중복 브로드캐스트 방지)
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,7 @@ import {
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { motion, AnimatePresence } from 'framer-motion';
|
import { motion, AnimatePresence } from 'framer-motion';
|
||||||
import { io } from 'socket.io-client';
|
import { io } from 'socket.io-client';
|
||||||
|
import Tooltip from '../components/Tooltip';
|
||||||
|
|
||||||
// 더미 로그 데이터
|
// 더미 로그 데이터
|
||||||
const DUMMY_LOGS = [
|
const DUMMY_LOGS = [
|
||||||
|
|
@ -28,15 +29,7 @@ const DUMMY_LOGS = [
|
||||||
|
|
||||||
// 더미 로그 파일 데이터 (삭제됨 - API 사용)
|
// 더미 로그 파일 데이터 (삭제됨 - API 사용)
|
||||||
|
|
||||||
// 더미 게임규칙 데이터
|
// 더미 게임규칙 데이터 제거됨 - 소켓에서 실시간으로 가져옴
|
||||||
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 }) {
|
export default function Admin({ isMobile = false }) {
|
||||||
const { isLoggedIn, isAdmin, user, loading } = useAuth();
|
const { isLoggedIn, isAdmin, user, loading } = useAuth();
|
||||||
|
|
@ -79,7 +72,8 @@ export default function Admin({ isMobile = false }) {
|
||||||
const [actionReason, setActionReason] = useState('');
|
const [actionReason, setActionReason] = useState('');
|
||||||
|
|
||||||
// 설정 관련 상태
|
// 설정 관련 상태
|
||||||
const [gamerules, setGamerules] = useState(DUMMY_GAMERULES);
|
const [gameRules, setGameRules] = useState({}); // 소켓에서 가져온 게임 규칙
|
||||||
|
const [gameRuleDescriptions, setGameRuleDescriptions] = useState({}); // 게임 규칙 설명
|
||||||
const [difficulty, setDifficulty] = useState('normal');
|
const [difficulty, setDifficulty] = useState('normal');
|
||||||
const [timeOfDay, setTimeOfDay] = useState('day');
|
const [timeOfDay, setTimeOfDay] = useState('day');
|
||||||
const [weather, setWeather] = useState('clear');
|
const [weather, setWeather] = useState('clear');
|
||||||
|
|
@ -402,12 +396,71 @@ export default function Admin({ isMobile = false }) {
|
||||||
setPlayers(playersList);
|
setPlayers(playersList);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 서버 상태에서 게임 규칙 가져오기
|
||||||
|
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');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 월드 정보에서 시간/날씨 가져오기
|
||||||
|
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');
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
socket.disconnect();
|
socket.disconnect();
|
||||||
};
|
};
|
||||||
}, [isAdmin]);
|
}, [isAdmin]);
|
||||||
|
|
||||||
|
// 게임 규칙 설명 데이터 로드
|
||||||
|
useEffect(() => {
|
||||||
|
fetch('/api/gamerules')
|
||||||
|
.then(res => res.json())
|
||||||
|
.then(data => setGameRuleDescriptions(data))
|
||||||
|
.catch(err => console.error('게임 규칙 설명 로드 실패:', err));
|
||||||
|
}, []);
|
||||||
|
|
||||||
// 명령어 실행 (실제 API 호출)
|
// 명령어 실행 (실제 API 호출)
|
||||||
const handleCommand = async () => {
|
const handleCommand = async () => {
|
||||||
if (!command.trim()) return;
|
if (!command.trim()) return;
|
||||||
|
|
@ -488,12 +541,30 @@ export default function Admin({ isMobile = false }) {
|
||||||
setActionReason('');
|
setActionReason('');
|
||||||
};
|
};
|
||||||
|
|
||||||
// 게임규칙 토글
|
// 게임규칙 토글 (서버에 gamerule 명령어 전송)
|
||||||
const toggleGamerule = (name) => {
|
const toggleGamerule = async (name) => {
|
||||||
setGamerules(prev => prev.map(rule =>
|
const currentValue = gameRules[name];
|
||||||
rule.name === name ? { ...rule, value: !rule.value } : rule
|
const newValue = !currentValue;
|
||||||
));
|
|
||||||
setToast('게임규칙이 변경되었습니다.');
|
// 낙관적 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('게임규칙 변경 실패');
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// 로그 색상 (hex 값 반환)
|
// 로그 색상 (hex 값 반환)
|
||||||
|
|
@ -1019,23 +1090,43 @@ export default function Admin({ isMobile = false }) {
|
||||||
>
|
>
|
||||||
{/* 게임규칙 */}
|
{/* 게임규칙 */}
|
||||||
<div className="bg-zinc-900 border border-zinc-800 rounded-2xl p-4">
|
<div className="bg-zinc-900 border border-zinc-800 rounded-2xl p-4">
|
||||||
<h3 className="text-white font-medium mb-4">🎮 게임규칙</h3>
|
<h3 className="text-white font-medium mb-4 flex items-center gap-2">
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-2">
|
🎮 게임규칙
|
||||||
{gamerules.map(rule => (
|
<span className="text-sm font-normal text-zinc-500">
|
||||||
<button
|
({Object.keys(gameRules).length})
|
||||||
key={rule.name}
|
</span>
|
||||||
onClick={() => toggleGamerule(rule.name)}
|
</h3>
|
||||||
className={`flex items-center justify-between p-3 rounded-xl transition-colors border ${
|
{Object.keys(gameRules).length > 0 ? (
|
||||||
rule.value ? 'bg-mc-green/20 border-mc-green/30' : 'bg-zinc-800/50 border-transparent'
|
<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]) => (
|
||||||
<span className="text-white text-sm">{rule.label}</span>
|
<button
|
||||||
<div className={`w-10 h-6 rounded-full relative transition-colors ${rule.value ? 'bg-mc-green' : 'bg-zinc-600'}`}>
|
key={rule}
|
||||||
<div className={`absolute top-1 w-4 h-4 rounded-full bg-white transition-transform ${rule.value ? 'left-5' : 'left-1'}`} />
|
onClick={() => toggleGamerule(rule)}
|
||||||
</div>
|
className={`flex items-center justify-between p-3 rounded-xl transition-colors border ${
|
||||||
</button>
|
value ? 'bg-mc-green/20 border-mc-green/30' : 'bg-zinc-800/50 border-transparent hover:border-zinc-700'
|
||||||
))}
|
}`}
|
||||||
</div>
|
>
|
||||||
|
<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>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 난이도 */}
|
{/* 난이도 */}
|
||||||
|
|
@ -1050,7 +1141,20 @@ export default function Admin({ isMobile = false }) {
|
||||||
].map(d => (
|
].map(d => (
|
||||||
<button
|
<button
|
||||||
key={d.id}
|
key={d.id}
|
||||||
onClick={() => { setDifficulty(d.id); setToast('난이도가 변경되었습니다.'); }}
|
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('난이도 변경 실패');
|
||||||
|
}
|
||||||
|
}}
|
||||||
className={`flex-1 min-w-[80px] py-3 px-4 rounded-xl font-medium transition-colors ${
|
className={`flex-1 min-w-[80px] py-3 px-4 rounded-xl font-medium transition-colors ${
|
||||||
difficulty === d.id
|
difficulty === d.id
|
||||||
? 'bg-mc-green text-white'
|
? 'bg-mc-green text-white'
|
||||||
|
|
@ -1068,13 +1172,26 @@ export default function Admin({ isMobile = false }) {
|
||||||
<h3 className="text-white font-medium mb-4">🕐 시간</h3>
|
<h3 className="text-white font-medium mb-4">🕐 시간</h3>
|
||||||
<div className="flex gap-2 flex-wrap">
|
<div className="flex gap-2 flex-wrap">
|
||||||
{[
|
{[
|
||||||
{ id: 'day', label: '아침', icon: Sun },
|
{ id: 'day', label: '아침', icon: Sun, time: '1000' },
|
||||||
{ id: 'noon', label: '낮', icon: Sun },
|
{ id: 'noon', label: '낮', icon: Sun, time: '6000' },
|
||||||
{ id: 'night', label: '밤', icon: Moon },
|
{ id: 'night', label: '밤', icon: Moon, time: '13000' },
|
||||||
].map(t => (
|
].map(t => (
|
||||||
<button
|
<button
|
||||||
key={t.id}
|
key={t.id}
|
||||||
onClick={() => { setTimeOfDay(t.id); setToast('시간이 변경되었습니다.'); }}
|
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('시간 변경 실패');
|
||||||
|
}
|
||||||
|
}}
|
||||||
className={`flex-1 flex items-center justify-center gap-2 py-3 px-4 rounded-xl font-medium transition-colors ${
|
className={`flex-1 flex items-center justify-center gap-2 py-3 px-4 rounded-xl font-medium transition-colors ${
|
||||||
timeOfDay === t.id
|
timeOfDay === t.id
|
||||||
? 'bg-blue-500 text-white'
|
? 'bg-blue-500 text-white'
|
||||||
|
|
@ -1099,7 +1216,20 @@ export default function Admin({ isMobile = false }) {
|
||||||
].map(w => (
|
].map(w => (
|
||||||
<button
|
<button
|
||||||
key={w.id}
|
key={w.id}
|
||||||
onClick={() => { setWeather(w.id); setToast('날씨가 변경되었습니다.'); }}
|
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('날씨 변경 실패');
|
||||||
|
}
|
||||||
|
}}
|
||||||
className={`flex-1 flex items-center justify-center gap-2 py-3 px-4 rounded-xl font-medium transition-colors ${
|
className={`flex-1 flex items-center justify-center gap-2 py-3 px-4 rounded-xl font-medium transition-colors ${
|
||||||
weather === w.id
|
weather === w.id
|
||||||
? 'bg-cyan-500 text-white'
|
? 'bg-cyan-500 text-white'
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue