feat(admin): 화이트리스트 API 연동 및 UI 개선

- 화이트리스트 조회/추가/삭제/토글 API 연동 (Mod WhitelistHandler)
- 화이트리스트 아바타 S3 캐싱 (CachedSkin 컴포넌트)
- 플레이어 아바타 S3 캐싱 연동
- 플레이어 추가 시 즉시 목록 반영
- 토스트 중앙 정렬 (모바일 대응)
- URL 해시로 탭 상태 유지
- 화이트리스트 활성화 상태 정확히 조회 (white-list 값만 체크)
This commit is contained in:
caadiq 2025-12-23 12:17:58 +09:00
parent 6fb441dc80
commit dd17cb5c5e
2 changed files with 404 additions and 23 deletions

View file

@ -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에서 로그 파일 목록 조회
*/

View file

@ -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 (
<img
src={src}
alt={name}
className={className}
onError={(e) => { 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}
</motion.div>
@ -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}
</motion.div>
@ -686,7 +845,7 @@ export default function Admin({ isMobile = false }) {
{tabs.map(tab => (
<button
key={tab.id}
onClick={() => setActiveTab(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'
@ -710,8 +869,78 @@ export default function Admin({ isMobile = false }) {
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-3">📊 서버 성능</h3>
<div className="grid grid-cols-3 gap-3">
{/* 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>
{/* CPU */}
<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">CPU</span>
<span className={`font-bold text-sm ${
serverPerformance.cpu <= 50 ? 'text-mc-green' :
serverPerformance.cpu <= 80 ? 'text-yellow-400' : 'text-red-400'
}`}>
{serverPerformance.cpu.toFixed(0)}%
</span>
</div>
<div className="h-1.5 bg-zinc-700 rounded-full overflow-hidden">
<div
className={`h-full transition-all ${
serverPerformance.cpu <= 50 ? 'bg-mc-green' :
serverPerformance.cpu <= 80 ? 'bg-yellow-400' : 'bg-red-400'
}`}
style={{ width: `${serverPerformance.cpu}%` }}
/>
</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">
{(serverPerformance.memory.used / 1024).toFixed(1)}GB
</span>
</div>
<div className="h-1.5 bg-zinc-700 rounded-full overflow-hidden">
<div
className={`h-full transition-all ${
(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'
}`}
style={{ width: `${(serverPerformance.memory.used / serverPerformance.memory.max) * 100}%` }}
/>
</div>
</div>
</div>
</div>
{/* 로그 영역 */}
<div className="bg-zinc-900 border border-zinc-800 rounded-2xl overflow-hidden relative">
<h3 className="text-white font-medium p-4 pb-3">📝 콘솔 로그</h3>
<div
ref={setLogContainerRef}
onScroll={handleLogScroll}
@ -966,16 +1195,17 @@ export default function Admin({ isMobile = false }) {
<div className="flex gap-2">
{[
{ id: 'all', label: '전체' },
{ id: 'online', label: '온라인' },
{ id: 'offline', label: '오프라인' },
{ id: 'banned', label: '차단됨' },
{ id: 'banned', label: '밴' },
{ id: 'whitelist', 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
? filter.id === 'banned' ? 'bg-red-500 text-white' : 'bg-mc-green text-white'
? filter.id === 'banned' ? 'bg-red-500 text-white'
: filter.id === 'whitelist' ? 'bg-blue-500 text-white'
: 'bg-mc-green text-white'
: 'bg-zinc-800 text-zinc-400 hover:text-white'
}`}
>
@ -983,13 +1213,89 @@ export default function Admin({ isMobile = false }) {
{filter.id === 'banned' && banList.length > 0 && (
<span className="ml-1 text-xs">({banList.length})</span>
)}
{filter.id === 'whitelist' && whitelistPlayers.length > 0 && (
<span className="ml-1 text-xs">({whitelistPlayers.length})</span>
)}
</button>
))}
</div>
{/* 플레이어 그리드 */}
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 gap-4">
{filteredPlayers.map((player) => (
{/* 화이트리스트 필터 선택 시 */}
{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) => (
<div
key={player.uuid}
className="bg-zinc-900 border border-zinc-800 rounded-2xl p-4 text-center relative group"
@ -1005,9 +1311,11 @@ export default function Admin({ isMobile = false }) {
<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}
<CachedSkin
uuid={player.uuid}
name={player.name}
type="body"
size={100}
className="w-16 h-32 mx-auto mb-2 drop-shadow-lg"
/>
@ -1075,7 +1383,8 @@ export default function Admin({ isMobile = false }) {
</div>
</div>
))}
</div>
</div>
)}
</motion.div>
)}
@ -1272,9 +1581,11 @@ export default function Admin({ isMobile = false }) {
</h3>
<div className="flex items-center gap-4 mb-4">
<img
src={`https://mc-heads.net/body/${selectedPlayer.uuid}/60`}
alt={selectedPlayer.name}
<CachedSkin
uuid={selectedPlayer.uuid}
name={selectedPlayer.name}
type="body"
size={60}
className="w-10 h-20"
/>
<div>
@ -1417,6 +1728,56 @@ export default function Admin({ isMobile = false }) {
</motion.div>
)}
</AnimatePresence>
{/* 화이트리스트 삭제 확인 다이얼로그 */}
<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>
</div>
);
}