minecraft-web/frontend/src/pages/ProfilePage.jsx
caadiq ba907ec8eb refactor: 프로필/관리자 페이지 UI 개선
- 모바일 툴바 h-14 통일

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

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

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

- body 배경 gradient에서 단색으로 변경
2025-12-22 14:57:34 +09:00

427 lines
15 KiB
JavaScript
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* 프로필 페이지
* 프로필 정보 및 마인크래프트 계정 연동
*/
import React, { useState, useEffect } from 'react';
import { Link, useNavigate, useLocation } from 'react-router-dom';
import { useAuth } from '../contexts/AuthContext';
import { ArrowLeft, Copy, Check, Gamepad2, Link as LinkIcon, Unlink, RefreshCw, User, Mail } from 'lucide-react';
import { motion, AnimatePresence } from 'framer-motion';
import { io } from 'socket.io-client';
export default function ProfilePage({ isMobile = false }) {
const navigate = useNavigate();
const location = useLocation();
const { user, isLoggedIn, loading, checkAuth, logout } = useAuth();
const [linkStatus, setLinkStatus] = useState(null);
const [linkToken, setLinkToken] = useState(null);
const [command, setCommand] = useState('');
const [copied, setCopied] = useState(false);
const [isLoading, setIsLoading] = useState(false);
const [polling, setPolling] = useState(false);
const [showUnlinkDialog, setShowUnlinkDialog] = useState(false);
const [showDeleteDialog, setShowDeleteDialog] = useState(false);
// 로그인 체크 (loading 완료 후에만)
useEffect(() => {
if (!loading && !isLoggedIn) {
navigate('/login', { state: { from: 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(() => {
fetchLinkStatus();
}, []);
// 폴링 (연동 대기 중일 때)
useEffect(() => {
if (!polling) return;
const interval = setInterval(async () => {
const status = await fetchLinkStatus();
if (status?.linked) {
setPolling(false);
setLinkToken(null);
setCommand('');
await checkAuth();
}
}, 3000);
return () => clearInterval(interval);
}, [polling]);
// 연동 대기 중 새로고침 방지 + 토큰 무효화
useEffect(() => {
if (!linkToken) return;
const handleBeforeUnload = (e) => {
const token = localStorage.getItem('token');
navigator.sendBeacon('/link/cancel', JSON.stringify({ authToken: token }));
e.preventDefault();
e.returnValue = '연동이 진행 중입니다. 페이지를 떠나시겠습니까?';
return e.returnValue;
};
window.addEventListener('beforeunload', handleBeforeUnload);
return () => window.removeEventListener('beforeunload', handleBeforeUnload);
}, [linkToken]);
// 소켓으로 닉네임 변경 감지 시 동기화
useEffect(() => {
if (!linkStatus?.minecraftUuid) return;
const socket = io(window.location.origin, { path: '/socket.io' });
let isSyncing = false;
socket.on('status', async (status) => {
if (!status?.online || !status?.players?.list) return;
if (isSyncing) return;
const playerInGame = status.players.list.find(p => p.uuid === linkStatus.minecraftUuid);
if (playerInGame && playerInGame.name !== user?.name) {
isSyncing = true;
try {
await fetchLinkStatus();
await checkAuth();
} catch (error) {
// 무시
} finally {
isSyncing = false;
}
}
});
return () => socket.disconnect();
}, [linkStatus?.minecraftUuid, user?.name]);
const handleRequestLink = async () => {
setIsLoading(true);
try {
const token = localStorage.getItem('token');
const res = await fetch('/link/request', {
method: 'POST',
headers: { 'Authorization': `Bearer ${token}` }
});
const data = await res.json();
if (data.linked) {
setLinkStatus({ linked: true, minecraftName: data.minecraftName });
} else {
setLinkToken(data.token);
setCommand(data.command);
setPolling(true);
}
} catch (error) {
console.error('연동 요청 실패:', error);
} finally {
setIsLoading(false);
}
};
const handleCopyCommand = () => {
navigator.clipboard.writeText(command);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
};
const handleUnlink = async () => {
try {
const token = localStorage.getItem('token');
await fetch('/link/unlink', {
method: 'POST',
headers: { 'Authorization': `Bearer ${token}` }
});
setLinkStatus({ linked: false });
setShowUnlinkDialog(false);
await checkAuth();
} catch (error) {
console.error('연동 해제 실패:', error);
}
};
const handleDelete = async () => {
try {
const token = localStorage.getItem('token');
const res = await fetch('/auth/delete', {
method: 'DELETE',
headers: { 'Authorization': `Bearer ${token}` }
});
if (res.ok) {
logout();
navigate('/');
} else {
console.error('탈퇴 실패');
}
} catch (error) {
console.error('탈퇴 실패:', error);
}
};
if (!user) return null;
return (
<div className="pb-8">
{/* 모바일용 헤더 */}
{isMobile && (
<header className="bg-mc-dark border-b border-white/10 sticky top-0 z-50 backdrop-blur-xl">
<div className="flex items-center h-14 px-4">
<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>
</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>
<p className="text-sm text-zinc-500 mt-1">계정 정보 마인크래프트 연동</p>
</div>
)}
{/* 프로필 정보 카드 */}
<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">
<User size={20} className="text-mc-green" />
기본 정보
</h2>
<div className="flex items-center gap-6">
<img
src={user.profileUrl || '/default-profile.png'}
alt="프로필"
className="w-24 h-24 rounded-2xl object-cover border-2 border-zinc-700"
onError={(e) => { e.target.src = 'https://via.placeholder.com/96'; }}
/>
<div className="flex-1 space-y-3">
<div>
<p className="text-sm text-zinc-500">닉네임</p>
<p className="text-lg font-medium text-white">{user.name}</p>
</div>
<div>
<p className="text-sm text-zinc-500">이메일</p>
<p className="text-white flex items-center gap-2">
<Mail size={14} className="text-zinc-500" />
{user.email}
</p>
</div>
</div>
</div>
</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">
<Gamepad2 size={20} className="text-mc-green" />
마인크래프트 계정 연동
</h2>
{linkStatus?.linked ? (
// 연동 완료 상태
<div className="space-y-6">
<div className="flex items-center gap-4 p-4 bg-mc-green/10 border border-mc-green/20 rounded-xl">
<div className="w-12 h-12 rounded-lg bg-mc-green/20 flex items-center justify-center">
<Check className="w-6 h-6 text-mc-green" />
</div>
<div>
<p className="font-medium text-white">연동 완료</p>
<p className="text-sm text-zinc-400">
마인크래프트 계정: <span className="text-mc-green font-medium">{linkStatus.minecraftName}</span>
</p>
</div>
</div>
<button
onClick={() => setShowUnlinkDialog(true)}
className="flex items-center gap-2 px-4 py-2 text-red-400 hover:bg-red-500/10 rounded-lg transition-colors"
>
<Unlink size={16} />
연동 해제
</button>
</div>
) : linkToken ? (
// 연동 대기 상태
<div className="space-y-6">
<div className="p-4 bg-yellow-500/10 border border-yellow-500/20 rounded-xl">
<p className="text-sm text-yellow-400 mb-2">마인크래프트에서 아래 명령어를 입력하세요:</p>
<div className="flex items-center gap-2">
<code className="flex-1 px-4 py-3 bg-zinc-800 rounded-lg text-lg font-mono text-white">
{command}
</code>
<button
onClick={handleCopyCommand}
className="p-3 bg-mc-green hover:bg-mc-green/80 text-white rounded-lg transition-colors"
>
{copied ? <Check size={20} /> : <Copy size={20} />}
</button>
</div>
</div>
<div className="flex items-center gap-3 text-sm text-zinc-400">
<RefreshCw size={16} className="animate-spin" />
연동 대기 ... (10 만료)
</div>
<button
onClick={() => { setLinkToken(null); setCommand(''); setPolling(false); }}
className="text-sm text-zinc-500 hover:text-white transition-colors"
>
취소
</button>
</div>
) : (
// 연동 안됨 상태
<div className="space-y-6">
<p className="text-zinc-400">
마인크래프트 계정을 연동하면 게임 닉네임과 스킨이 프로필에 적용됩니다.
</p>
<button
onClick={handleRequestLink}
disabled={isLoading}
className="flex items-center gap-2 px-6 py-3 bg-mc-green hover:bg-mc-green/80 disabled:bg-zinc-700 text-white font-medium rounded-xl transition-colors"
>
{isLoading ? (
<RefreshCw size={18} className="animate-spin" />
) : (
<LinkIcon size={18} />
)}
마인크래프트 계정 연동하기
</button>
</div>
)}
</section>
{/* 계정 관리 섹션 */}
<section className="bg-zinc-900 rounded-2xl p-6">
<h2 className="text-lg font-semibold text-white mb-6 flex items-center gap-2">
<User size={20} className="text-zinc-400" />
계정 관리
</h2>
<div className="space-y-4">
<div className="p-4 bg-zinc-800/50 rounded-xl">
<p className="text-sm text-zinc-400 mb-3">
회원 탈퇴 모든 데이터가 삭제되며 복구할 없습니다.
</p>
<button
onClick={() => setShowDeleteDialog(true)}
className="px-4 py-2 text-sm text-red-400 border border-red-500/30 hover:bg-red-500/10 rounded-lg transition-colors"
>
회원 탈퇴
</button>
</div>
</div>
</section>
</main>
{/* 연동 해제 다이얼로그 */}
<AnimatePresence>
{showUnlinkDialog && (
<>
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
className="fixed inset-0 bg-black/60 backdrop-blur-sm z-[100]"
onClick={() => setShowUnlinkDialog(false)}
/>
<motion.div
initial={{ opacity: 0, scale: 0.9 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.9 }}
className="fixed top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 w-80 bg-zinc-900 border border-zinc-700 rounded-2xl p-6 z-[101]"
>
<h3 className="text-lg font-bold text-white mb-2">연동 해제</h3>
<p className="text-sm text-zinc-400 mb-6">
마인크래프트 계정 연동을 해제하시겠습니까?
</p>
<div className="flex gap-3">
<button
onClick={() => setShowUnlinkDialog(false)}
className="flex-1 py-2.5 bg-zinc-800 hover:bg-zinc-700 text-zinc-300 rounded-xl font-medium transition-colors"
>
취소
</button>
<button
onClick={handleUnlink}
className="flex-1 py-2.5 bg-red-500 hover:bg-red-600 text-white rounded-xl font-medium transition-colors"
>
연동 해제
</button>
</div>
</motion.div>
</>
)}
</AnimatePresence>
{/* 탈퇴 확인 다이얼로그 */}
<AnimatePresence>
{showDeleteDialog && (
<>
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
className="fixed inset-0 bg-black/60 backdrop-blur-sm z-[100]"
onClick={() => setShowDeleteDialog(false)}
/>
<motion.div
initial={{ opacity: 0, scale: 0.9 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.9 }}
className="fixed top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 w-80 bg-zinc-900 border border-zinc-700 rounded-2xl p-6 z-[101]"
>
<h3 className="text-lg font-bold text-red-400 mb-2"> 회원 탈퇴</h3>
<p className="text-sm text-zinc-400 mb-6">
정말 탈퇴하시겠습니까? 모든 데이터가 삭제되며 복구할 없습니다.
</p>
<div className="flex gap-3">
<button
onClick={() => setShowDeleteDialog(false)}
className="flex-1 py-2.5 bg-zinc-800 hover:bg-zinc-700 text-zinc-300 rounded-xl font-medium transition-colors"
>
취소
</button>
<button
onClick={handleDelete}
className="flex-1 py-2.5 bg-red-500 hover:bg-red-600 text-white rounded-xl font-medium transition-colors"
>
탈퇴하기
</button>
</div>
</motion.div>
</>
)}
</AnimatePresence>
</div>
);
}