minecraft-web/frontend/src/pages/ProfilePage.jsx

428 lines
15 KiB
React
Raw Normal View History

/**
* 프로필 페이지
* 프로필 정보 마인크래프트 계정 연동
*/
import React, { useState, useEffect } from 'react';
2025-12-22 11:42:37 +09:00
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';
2025-12-22 11:42:37 +09:00
import { io } from 'socket.io-client';
export default function ProfilePage({ isMobile = false }) {
const navigate = useNavigate();
2025-12-22 11:42:37 +09:00
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);
2025-12-22 11:42:37 +09:00
const [showDeleteDialog, setShowDeleteDialog] = useState(false);
// 로그인 체크 (loading 완료 후에만)
useEffect(() => {
if (!loading && !isLoggedIn) {
2025-12-22 11:42:37 +09:00
navigate('/login', { state: { from: location.pathname } });
}
2025-12-22 11:42:37 +09:00
}, [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]);
2025-12-22 11:42:37 +09:00
// 소켓으로 닉네임 변경 감지 시 동기화
useEffect(() => {
if (!linkStatus?.minecraftUuid) return;
const socket = io(window.location.origin, { path: '/socket.io' });
let isSyncing = false;
2025-12-22 11:42:37 +09:00
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 {
2025-12-22 11:42:37 +09:00
await fetchLinkStatus();
await checkAuth();
} catch (error) {
// 무시
} finally {
isSyncing = false;
2025-12-22 11:42:37 +09:00
}
}
});
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);
}
};
2025-12-22 11:42:37 +09:00
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>
2025-12-22 11:42:37 +09:00
{/* 계정 관리 섹션 */}
<section className="bg-zinc-900 rounded-2xl p-6">
2025-12-22 11:42:37 +09:00
<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">
2025-12-22 11:42:37 +09:00
<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>
2025-12-22 11:42:37 +09:00
{/* 탈퇴 확인 다이얼로그 */}
<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>
);
}
2025-12-22 11:42:37 +09:00