refactor: 프로필/관리자 페이지 UI 개선

- 모바일 툴바 h-14 통일

- 데스크탑 사이드바 레이아웃 통합

- 관리자 페이지 프로필과 동일한 스타일 적용

- 불필요한 hooks/api.js 파일 제거

- body 배경 gradient에서 단색으로 변경
This commit is contained in:
caadiq 2025-12-22 14:57:34 +09:00
parent 9adc0fe19b
commit ba907ec8eb
5 changed files with 186 additions and 140 deletions

View file

@ -34,8 +34,8 @@ function App() {
const isAuthPage = ['/login', '/register'].includes(location.pathname) || const isAuthPage = ['/login', '/register'].includes(location.pathname) ||
location.pathname.startsWith('/verify/'); location.pathname.startsWith('/verify/');
// (, ) // ( standalone)
const isStandalonePage = ['/admin', '/profile'].includes(location.pathname); const isStandalonePage = (location.pathname === '/admin' || location.pathname === '/profile') && isMobile;
// //
useEffect(() => { useEffect(() => {
@ -68,8 +68,8 @@ function App() {
<div className="absolute inset-0 bg-[url('https://www.transparenttextures.com/patterns/cubes.png')] opacity-[0.03] pointer-events-none"></div> <div className="absolute inset-0 bg-[url('https://www.transparenttextures.com/patterns/cubes.png')] opacity-[0.03] pointer-events-none"></div>
<AnimatePresence mode="wait"> <AnimatePresence mode="wait">
<Routes location={location} key={location.pathname}> <Routes location={location} key={location.pathname}>
<Route path="/admin" element={<PageWrapper><Admin /></PageWrapper>} /> <Route path="/admin" element={<PageWrapper><Admin isMobile={isMobile} /></PageWrapper>} />
<Route path="/profile" element={<PageWrapper><ProfilePage /></PageWrapper>} /> <Route path="/profile" element={<PageWrapper><ProfilePage isMobile={isMobile} /></PageWrapper>} />
</Routes> </Routes>
</AnimatePresence> </AnimatePresence>
</div> </div>
@ -95,6 +95,8 @@ function App() {
<Route path="/players" element={<PageWrapper><PlayersPage isMobile={isMobile} /></PageWrapper>} /> <Route path="/players" element={<PageWrapper><PlayersPage isMobile={isMobile} /></PageWrapper>} />
<Route path="/player/:uuid/stats" element={<PageWrapper><PlayerStatsPage isMobile={isMobile} /></PageWrapper>} /> <Route path="/player/:uuid/stats" element={<PageWrapper><PlayerStatsPage isMobile={isMobile} /></PageWrapper>} />
<Route path="/worldmap" element={<PageWrapper><WorldMapPage isMobile={isMobile} /></PageWrapper>} /> <Route path="/worldmap" element={<PageWrapper><WorldMapPage isMobile={isMobile} /></PageWrapper>} />
<Route path="/profile" element={<PageWrapper><ProfilePage isMobile={isMobile} /></PageWrapper>} />
<Route path="/admin" element={<PageWrapper><Admin isMobile={isMobile} /></PageWrapper>} />
</Routes> </Routes>
</AnimatePresence> </AnimatePresence>
</main> </main>

View file

@ -62,7 +62,7 @@ const Sidebar = ({ isMobile = false }) => {
setMinecraftLink(data); setMinecraftLink(data);
} }
} catch (error) { } catch (error) {
console.error('연동 상태 확인 실패:', error); //
} }
}; };
@ -72,41 +72,41 @@ const Sidebar = ({ isMobile = false }) => {
// (socket.io) + // (socket.io) +
useEffect(() => { useEffect(() => {
const socket = io(window.location.origin, { path: '/socket.io' }); const socket = io(window.location.origin, { path: '/socket.io' });
let isSyncing = false;
// ( ) socket.on('status', async (status) => {
const syncNickname = async () => {
if (!isLoggedIn) return;
try {
const token = localStorage.getItem('token');
const res = await fetch('/link/status', {
headers: { 'Authorization': `Bearer ${token}` }
});
const data = await res.json();
if (data.linked) {
setMinecraftLink(data);
// fetch checkAuth user.name
await checkAuth();
}
} catch (error) {
//
}
};
socket.on('status', (status) => {
setServerOnline(status?.online || false); setServerOnline(status?.online || false);
//
if (isSyncing) return;
// , // ,
if (status?.online && minecraftLink?.minecraftUuid && status?.players?.list) { if (status?.online && minecraftLink?.minecraftUuid && status?.players?.list) {
const playerInGame = status.players.list.find(p => p.uuid === minecraftLink.minecraftUuid); const playerInGame = status.players.list.find(p => p.uuid === minecraftLink.minecraftUuid);
// //
if (playerInGame && playerInGame.name !== user?.name) { if (playerInGame && playerInGame.name !== user?.name) {
syncNickname(); isSyncing = true;
try {
const token = localStorage.getItem('token');
const res = await fetch('/link/status', {
headers: { 'Authorization': `Bearer ${token}` }
});
const data = await res.json();
if (data.linked) {
setMinecraftLink(data);
await checkAuth();
}
} catch (error) {
//
} finally {
isSyncing = false;
}
} }
} }
}); });
return () => socket.disconnect(); return () => socket.disconnect();
}, [isLoggedIn, minecraftLink?.minecraftUuid, user?.name]); }, [isLoggedIn, minecraftLink?.minecraftUuid, user?.name, checkAuth]);
// //
useEffect(() => { useEffect(() => {

View file

@ -2,10 +2,9 @@
@tailwind components; @tailwind components;
@tailwind utilities; @tailwind utilities;
/* 기본 body 스타일 - 다크 배경 (약간 밝게) */ /* 기본 body 스타일 - 다크 배경 */
body { body {
background: linear-gradient(135deg, #141414 0%, #181a18 50%, #141414 100%); background: #141414;
background-attachment: fixed;
min-height: 100vh; min-height: 100vh;
} }

View file

@ -3,13 +3,13 @@
*/ */
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { useNavigate, useLocation } from 'react-router-dom'; import { useNavigate, useLocation, Link } from 'react-router-dom';
import { useAuth } from '../contexts/AuthContext'; import { useAuth } from '../contexts/AuthContext';
import { Shield, LogOut, Settings, Server, Users, Loader2 } from 'lucide-react'; import { Shield, ArrowLeft, Settings, Server, Users, Loader2, User, Terminal, MessageSquare } from 'lucide-react';
import { motion, AnimatePresence } from 'framer-motion'; import { motion, AnimatePresence } from 'framer-motion';
export default function Admin() { export default function Admin({ isMobile = false }) {
const { isLoggedIn, isAdmin, user, loading, logout } = useAuth(); const { isLoggedIn, isAdmin, user, loading } = useAuth();
const navigate = useNavigate(); const navigate = useNavigate();
const location = useLocation(); const location = useLocation();
const [toast, setToast] = useState(null); const [toast, setToast] = useState(null);
@ -18,7 +18,6 @@ export default function Admin() {
useEffect(() => { useEffect(() => {
if (!loading) { if (!loading) {
if (!isLoggedIn) { if (!isLoggedIn) {
// admin
navigate('/login', { state: { from: location.pathname } }); navigate('/login', { state: { from: location.pathname } });
} else if (!isAdmin) { } else if (!isAdmin) {
setToast('관리자 권한이 필요합니다.'); setToast('관리자 권한이 필요합니다.');
@ -29,11 +28,6 @@ export default function Admin() {
} }
}, [isLoggedIn, isAdmin, loading, navigate, location.pathname]); }, [isLoggedIn, isAdmin, loading, navigate, location.pathname]);
const handleLogout = () => {
logout();
navigate('/');
};
// //
useEffect(() => { useEffect(() => {
if (toast) { if (toast) {
@ -74,87 +68,128 @@ export default function Admin() {
} }
return ( return (
<div className="p-6 max-w-4xl mx-auto"> <div className="pb-8">
{/* 헤더 */} {/* 모바일용 헤더 */}
<div className="flex items-center justify-between mb-8"> {isMobile && (
<div className="flex items-center gap-3"> <header className="bg-mc-dark border-b border-white/10 sticky top-0 z-50 backdrop-blur-xl">
<div className="p-3 rounded-xl bg-yellow-500/20 border border-yellow-500/30"> <div className="flex items-center h-14 px-4">
<Shield className="w-6 h-6 text-yellow-500" /> <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>
<p className="text-xs text-zinc-500">서버 관리 설정</p>
</div>
</div> </div>
<div> </header>
)}
<main className={`max-w-4xl mx-auto px-4 sm:px-6 ${isMobile ? 'py-4' : 'py-8'} space-y-4 sm:space-y-6`}>
{/* 데스크탑용 타이틀 */}
{!isMobile && (
<div className="mb-6">
<h1 className="text-2xl font-bold text-white">관리자 페이지</h1> <h1 className="text-2xl font-bold text-white">관리자 페이지</h1>
<p className="text-sm text-zinc-400">서버 관리 설정</p> <p className="text-sm text-zinc-500 mt-1">서버 관리 설정</p>
</div> </div>
</div> )}
<button {/* 관리자 정보 카드 */}
onClick={handleLogout} <section className="bg-zinc-900 border border-zinc-800 rounded-2xl p-6">
className="flex items-center gap-2 px-4 py-2 bg-zinc-800 hover:bg-zinc-700 text-zinc-300 rounded-lg transition-colors" <h2 className="text-lg font-semibold text-white mb-6 flex items-center gap-2">
> <Shield size={20} className="text-yellow-500" />
<LogOut className="w-4 h-4" /> 관리자 정보
로그아웃 </h2>
</button>
</div>
{/* 사용자 정보 */} <div className="flex items-center gap-6">
<div className="bg-zinc-900 border border-zinc-800 rounded-2xl p-6 mb-6"> <div className="w-16 h-16 rounded-2xl bg-yellow-500/20 border border-yellow-500/30 flex items-center justify-center">
<h2 className="text-lg font-semibold text-white mb-4">로그인 정보</h2> <User size={32} className="text-yellow-500" />
<div className="grid grid-cols-2 gap-4 text-sm"> </div>
<div>
<span className="text-zinc-500">이름</span>
<p className="text-white mt-1">{user?.name || '-'}</p>
</div>
<div>
<span className="text-zinc-500">이메일</span>
<p className="text-white mt-1">{user?.email}</p>
</div>
</div>
</div>
{/* 관리 기능 카드 */} <div className="flex-1 space-y-3">
<div className="grid grid-cols-1 md:grid-cols-2 gap-4"> <div>
{/* 서버 상태 */} <p className="text-sm text-zinc-500">닉네임</p>
<div className="bg-zinc-900 border border-zinc-800 rounded-2xl p-6"> <p className="text-lg font-medium text-white">{user?.name || '-'}</p>
<div className="flex items-center gap-3 mb-4"> </div>
<Server className="w-5 h-5 text-mc-green" /> <div>
<h2 className="text-lg font-semibold text-white">서버 상태</h2> <p className="text-sm text-zinc-500">이메일</p>
<p className="text-white">{user?.email}</p>
</div>
</div>
</div> </div>
<p className="text-zinc-400 text-sm"> </section>
마인크래프트 서버 상태 모니터링 관리
</p>
<div className="mt-4 py-2 px-3 bg-mc-green/10 border border-mc-green/20 rounded-lg text-mc-green text-sm inline-block">
정상 작동
</div>
</div>
{/* 플레이어 관리 */} {/* 서버 관리 섹션 */}
<div className="bg-zinc-900 border border-zinc-800 rounded-2xl p-6"> <section className="bg-zinc-900 border border-zinc-800 rounded-2xl p-6">
<div className="flex items-center gap-3 mb-4"> <h2 className="text-lg font-semibold text-white mb-6 flex items-center gap-2">
<Users className="w-5 h-5 text-blue-400" /> <Server size={20} className="text-mc-green" />
<h2 className="text-lg font-semibold text-white">플레이어 관리</h2> 서버 관리
</h2>
<div className="space-y-4">
{/* 서버 명령어 */}
<div className="p-4 bg-zinc-800/50 rounded-xl">
<div className="flex items-center gap-3 mb-2">
<Terminal size={18} className="text-zinc-400" />
<p className="font-medium text-white">서버 명령어</p>
</div>
<p className="text-sm text-zinc-400 mb-3">
마인크래프트 서버에 명령어를 전송합니다.
</p>
<div className="text-zinc-500 text-sm">
추후 업데이트 예정
</div>
</div>
{/* 공지 전송 */}
<div className="p-4 bg-zinc-800/50 rounded-xl">
<div className="flex items-center gap-3 mb-2">
<MessageSquare size={18} className="text-zinc-400" />
<p className="font-medium text-white">공지 전송</p>
</div>
<p className="text-sm text-zinc-400 mb-3">
게임 모든 플레이어에게 공지를 전송합니다.
</p>
<div className="text-zinc-500 text-sm">
추후 업데이트 예정
</div>
</div>
</div> </div>
<p className="text-zinc-400 text-sm"> </section>
{/* 플레이어 관리 섹션 */}
<section className="bg-zinc-900 border border-zinc-800 rounded-2xl p-6">
<h2 className="text-lg font-semibold text-white mb-6 flex items-center gap-2">
<Users size={20} className="text-blue-400" />
플레이어 관리
</h2>
<p className="text-sm text-zinc-400">
접속 중인 플레이어 목록 관리 기능 접속 중인 플레이어 목록 관리 기능
</p> </p>
<div className="mt-4 text-zinc-500 text-sm"> <div className="mt-4 text-zinc-500 text-sm">
추후 업데이트 예정 추후 업데이트 예정
</div> </div>
</div> </section>
{/* 설정 */} {/* 설정 섹션 */}
<div className="bg-zinc-900 border border-zinc-800 rounded-2xl p-6 md:col-span-2"> <section className="bg-zinc-900 border border-zinc-800 rounded-2xl p-6">
<div className="flex items-center gap-3 mb-4"> <h2 className="text-lg font-semibold text-white mb-6 flex items-center gap-2">
<Settings className="w-5 h-5 text-zinc-400" /> <Settings size={20} className="text-zinc-400" />
<h2 className="text-lg font-semibold text-white">설정</h2> 설정
</div> </h2>
<p className="text-zinc-400 text-sm">
<p className="text-sm text-zinc-400">
대시보드 설정 구성 관리 대시보드 설정 구성 관리
</p> </p>
<div className="mt-4 text-zinc-500 text-sm"> <div className="mt-4 text-zinc-500 text-sm">
추후 업데이트 예정 추후 업데이트 예정
</div> </div>
</div> </section>
</div> </main>
</div> </div>
); );
} }

View file

@ -10,7 +10,7 @@ import { ArrowLeft, Copy, Check, Gamepad2, Link as LinkIcon, Unlink, RefreshCw,
import { motion, AnimatePresence } from 'framer-motion'; import { motion, AnimatePresence } from 'framer-motion';
import { io } from 'socket.io-client'; import { io } from 'socket.io-client';
export default function ProfilePage() { export default function ProfilePage({ isMobile = false }) {
const navigate = useNavigate(); const navigate = useNavigate();
const location = useLocation(); const location = useLocation();
const { user, isLoggedIn, loading, checkAuth, logout } = useAuth(); const { user, isLoggedIn, loading, checkAuth, logout } = useAuth();
@ -32,6 +32,20 @@ export default function ProfilePage() {
}, [loading, isLoggedIn, navigate, location.pathname]); }, [loading, isLoggedIn, navigate, location.pathname]);
// //
const fetchLinkStatus = async () => {
try {
const token = localStorage.getItem('token');
const res = await fetch('/link/status', {
headers: { 'Authorization': `Bearer ${token}` }
});
const data = await res.json();
setLinkStatus(data);
return data;
} catch (error) {
return null;
}
};
useEffect(() => { useEffect(() => {
fetchLinkStatus(); fetchLinkStatus();
}, []); }, []);
@ -46,7 +60,6 @@ export default function ProfilePage() {
setPolling(false); setPolling(false);
setLinkToken(null); setLinkToken(null);
setCommand(''); setCommand('');
//
await checkAuth(); await checkAuth();
} }
}, 3000); }, 3000);
@ -59,10 +72,8 @@ export default function ProfilePage() {
if (!linkToken) return; if (!linkToken) return;
const handleBeforeUnload = (e) => { const handleBeforeUnload = (e) => {
// API (sendBeacon )
const token = localStorage.getItem('token'); const token = localStorage.getItem('token');
navigator.sendBeacon('/link/cancel', JSON.stringify({ authToken: token })); navigator.sendBeacon('/link/cancel', JSON.stringify({ authToken: token }));
e.preventDefault(); e.preventDefault();
e.returnValue = '연동이 진행 중입니다. 페이지를 떠나시겠습니까?'; e.returnValue = '연동이 진행 중입니다. 페이지를 떠나시겠습니까?';
return e.returnValue; return e.returnValue;
@ -77,15 +88,22 @@ export default function ProfilePage() {
if (!linkStatus?.minecraftUuid) return; if (!linkStatus?.minecraftUuid) return;
const socket = io(window.location.origin, { path: '/socket.io' }); const socket = io(window.location.origin, { path: '/socket.io' });
let isSyncing = false;
socket.on('status', async (status) => { socket.on('status', async (status) => {
if (status?.online && status?.players?.list) { if (!status?.online || !status?.players?.list) return;
const playerInGame = status.players.list.find(p => p.uuid === linkStatus.minecraftUuid); if (isSyncing) return;
//
if (playerInGame && playerInGame.name !== user?.name) { const playerInGame = status.players.list.find(p => p.uuid === linkStatus.minecraftUuid);
// fetchLinkStatus DB checkAuth user.name if (playerInGame && playerInGame.name !== user?.name) {
isSyncing = true;
try {
await fetchLinkStatus(); await fetchLinkStatus();
await checkAuth(); await checkAuth();
} catch (error) {
//
} finally {
isSyncing = false;
} }
} }
}); });
@ -93,21 +111,6 @@ export default function ProfilePage() {
return () => socket.disconnect(); return () => socket.disconnect();
}, [linkStatus?.minecraftUuid, user?.name]); }, [linkStatus?.minecraftUuid, user?.name]);
const fetchLinkStatus = async () => {
try {
const token = localStorage.getItem('token');
const res = await fetch('/link/status', {
headers: { 'Authorization': `Bearer ${token}` }
});
const data = await res.json();
setLinkStatus(data);
return data;
} catch (error) {
console.error('연동 상태 확인 실패:', error);
return null;
}
};
const handleRequestLink = async () => { const handleRequestLink = async () => {
setIsLoading(true); setIsLoading(true);
try { try {
@ -175,26 +178,33 @@ export default function ProfilePage() {
if (!user) return null; if (!user) return null;
return ( return (
<div className="bg-mc-bg pb-8"> <div className="pb-8">
{/* 헤더 */} {/* 모바일용 헤더 */}
<header className="bg-mc-dark border-b border-white/10 sticky top-0 z-50 backdrop-blur-xl"> {isMobile && (
<div className="max-w-4xl mx-auto px-4 sm:px-6 py-4 flex items-center justify-between"> <header className="bg-mc-dark border-b border-white/10 sticky top-0 z-50 backdrop-blur-xl">
<div className="flex items-center gap-4"> <div className="flex items-center h-14 px-4">
<Link <Link
to="/" to="/"
className="p-2 rounded-lg text-zinc-400 hover:text-white hover:bg-white/5 transition-colors" className="p-2 -ml-2 rounded-lg text-zinc-400 hover:text-white hover:bg-white/5 transition-colors"
> >
<ArrowLeft size={20} /> <ArrowLeft size={20} />
</Link> </Link>
<div> <div className="ml-2">
<h1 className="text-xl font-bold text-white">프로필</h1> <h1 className="text-lg font-bold text-white">프로필</h1>
<p className="text-sm text-zinc-500">계정 정보 마인크래프트 연동</p> <p className="text-xs text-zinc-500">계정 정보 마인크래프트 연동</p>
</div> </div>
</div> </div>
</div> </header>
</header> )}
<main className="max-w-4xl mx-auto px-4 sm:px-6 py-6 sm:py-8 space-y-4 sm:space-y-6"> <main className={`max-w-4xl mx-auto px-4 sm:px-6 ${isMobile ? 'py-4' : 'py-8'} space-y-4 sm:space-y-6`}>
{/* 데스크탑용 타이틀 */}
{!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>
)}
{/* 프로필 정보 카드 */} {/* 프로필 정보 카드 */}
<section className="bg-zinc-900 border border-zinc-800 rounded-2xl p-6"> <section className="bg-zinc-900 border border-zinc-800 rounded-2xl p-6">
<h2 className="text-lg font-semibold text-white mb-6 flex items-center gap-2"> <h2 className="text-lg font-semibold text-white mb-6 flex items-center gap-2">
@ -310,14 +320,14 @@ export default function ProfilePage() {
</section> </section>
{/* 계정 관리 섹션 */} {/* 계정 관리 섹션 */}
<section className="bg-zinc-900 border border-zinc-800 rounded-2xl p-6"> <section className="bg-zinc-900 rounded-2xl p-6">
<h2 className="text-lg font-semibold text-white mb-6 flex items-center gap-2"> <h2 className="text-lg font-semibold text-white mb-6 flex items-center gap-2">
<User size={20} className="text-zinc-400" /> <User size={20} className="text-zinc-400" />
계정 관리 계정 관리
</h2> </h2>
<div className="space-y-4"> <div className="space-y-4">
<div className="p-4 bg-zinc-800/50 border border-zinc-700 rounded-xl"> <div className="p-4 bg-zinc-800/50 rounded-xl">
<p className="text-sm text-zinc-400 mb-3"> <p className="text-sm text-zinc-400 mb-3">
회원 탈퇴 모든 데이터가 삭제되며 복구할 없습니다. 회원 탈퇴 모든 데이터가 삭제되며 복구할 없습니다.
</p> </p>