feat: 관리자 설정 탭 기능 완성

- 게임규칙: 서버에서 실시간 목록 가져오기, 툴팁 설명, 토글 시 gamerule 명령어 실행
- 난이도: 서버에서 현재 난이도 가져오기, difficulty 명령어 실행
- 시간: 실시간 동기화 (틱 기반), time set 명령어 실행
- 날씨: 실시간 동기화, weather 명령어 실행
- 백엔드: worlds 정보 주기적 브로드캐스트 추가
This commit is contained in:
caadiq 2025-12-23 10:36:53 +09:00
parent 6fe6d0dda0
commit 6fb441dc80
2 changed files with 180 additions and 39 deletions

View file

@ -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) {
// 연결 오류 무시
}
} }
// 로그 캐시 (중복 브로드캐스트 방지) // 로그 캐시 (중복 브로드캐스트 방지)

View file

@ -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');
@ -403,11 +397,70 @@ export default function Admin({ isMobile = false }) {
} }
}); });
//
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,24 +1090,44 @@ 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">
🎮 게임규칙
<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"> <div className="grid grid-cols-1 sm:grid-cols-2 gap-2">
{gamerules.map(rule => ( {Object.entries(gameRules).map(([rule, value]) => (
<button <button
key={rule.name} key={rule}
onClick={() => toggleGamerule(rule.name)} onClick={() => toggleGamerule(rule)}
className={`flex items-center justify-between p-3 rounded-xl transition-colors border ${ 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' value ? 'bg-mc-green/20 border-mc-green/30' : 'bg-zinc-800/50 border-transparent hover:border-zinc-700'
}`} }`}
> >
<span className="text-white text-sm">{rule.label}</span> <Tooltip
<div className={`w-10 h-6 rounded-full relative transition-colors ${rule.value ? 'bg-mc-green' : 'bg-zinc-600'}`}> content={gameRuleDescriptions[rule]?.description || gameRuleDescriptions[rule]?.name || "설명이 없습니다."}
<div className={`absolute top-1 w-4 h-4 rounded-full bg-white transition-transform ${rule.value ? 'left-5' : 'left-1'}`} /> 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> </div>
</button> </button>
))} ))}
</div> </div>
</div> </div>
) : (
<div className="text-zinc-500 text-sm py-4 text-center">
게임 규칙을 불러오는 ...
</div>
)}
</div>
{/* 난이도 */} {/* 난이도 */}
<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">
@ -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'