feat(admin): 화이트리스트 API 연동 및 UI 개선
- 화이트리스트 조회/추가/삭제/토글 API 연동 (Mod WhitelistHandler) - 화이트리스트 아바타 S3 캐싱 (CachedSkin 컴포넌트) - 플레이어 아바타 S3 캐싱 연동 - 플레이어 추가 시 즉시 목록 반영 - 토스트 중앙 정렬 (모바일 대응) - URL 해시로 탭 상태 유지 - 화이트리스트 활성화 상태 정확히 조회 (white-list 값만 체크)
This commit is contained in:
parent
6fb441dc80
commit
dd17cb5c5e
2 changed files with 404 additions and 23 deletions
|
|
@ -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에서 로그 파일 목록 조회
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue