312 lines
11 KiB
React
312 lines
11 KiB
React
|
|
/**
|
||
|
|
* 프로필 페이지
|
||
|
|
* 프로필 정보 및 마인크래프트 계정 연동
|
||
|
|
*/
|
||
|
|
|
||
|
|
import React, { useState, useEffect } from 'react';
|
||
|
|
import { Link, useNavigate } 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';
|
||
|
|
|
||
|
|
export default function ProfilePage() {
|
||
|
|
const navigate = useNavigate();
|
||
|
|
const { user, isLoggedIn, loading, checkAuth } = 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);
|
||
|
|
|
||
|
|
// 로그인 체크 (loading 완료 후에만)
|
||
|
|
useEffect(() => {
|
||
|
|
if (!loading && !isLoggedIn) {
|
||
|
|
navigate('/login');
|
||
|
|
}
|
||
|
|
}, [loading, isLoggedIn, navigate]);
|
||
|
|
|
||
|
|
// 연동 상태 확인
|
||
|
|
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) => {
|
||
|
|
// 토큰 무효화 API 호출 (sendBeacon으로 페이지 이탈 전 전송)
|
||
|
|
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]);
|
||
|
|
|
||
|
|
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 () => {
|
||
|
|
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);
|
||
|
|
}
|
||
|
|
};
|
||
|
|
|
||
|
|
if (!user) return null;
|
||
|
|
|
||
|
|
return (
|
||
|
|
<div className="bg-mc-bg pb-8">
|
||
|
|
{/* 헤더 */}
|
||
|
|
<header className="bg-mc-dark border-b border-white/10 sticky top-0 z-50 backdrop-blur-xl">
|
||
|
|
<div className="max-w-4xl mx-auto px-4 sm:px-6 py-4 flex items-center justify-between">
|
||
|
|
<div className="flex items-center gap-4">
|
||
|
|
<Link
|
||
|
|
to="/"
|
||
|
|
className="p-2 rounded-lg text-zinc-400 hover:text-white hover:bg-white/5 transition-colors"
|
||
|
|
>
|
||
|
|
<ArrowLeft size={20} />
|
||
|
|
</Link>
|
||
|
|
<div>
|
||
|
|
<h1 className="text-xl font-bold text-white">프로필</h1>
|
||
|
|
<p className="text-sm text-zinc-500">계정 정보 및 마인크래프트 연동</p>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
</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">
|
||
|
|
{/* 프로필 정보 카드 */}
|
||
|
|
<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>
|
||
|
|
</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>
|
||
|
|
</div>
|
||
|
|
);
|
||
|
|
}
|