프로필 소켓 추가
This commit is contained in:
parent
fd6a583fcc
commit
9adc0fe19b
10 changed files with 414 additions and 45 deletions
|
|
@ -363,4 +363,35 @@ router.post("/logout", (req, res) => {
|
||||||
res.json({ success: true });
|
res.json({ success: true });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DELETE /auth/delete - 회원 탈퇴
|
||||||
|
*/
|
||||||
|
router.delete("/delete", async (req, res) => {
|
||||||
|
const authHeader = req.headers.authorization;
|
||||||
|
if (!authHeader || !authHeader.startsWith("Bearer ")) {
|
||||||
|
return res.status(401).json({ error: "로그인이 필요합니다." });
|
||||||
|
}
|
||||||
|
|
||||||
|
const token = authHeader.split(" ")[1];
|
||||||
|
|
||||||
|
try {
|
||||||
|
const decoded = jwt.verify(token, JWT_SECRET);
|
||||||
|
|
||||||
|
// 연동 정보 삭제
|
||||||
|
await pool.query("DELETE FROM minecraft_links WHERE user_id = ?", [
|
||||||
|
decoded.id,
|
||||||
|
]);
|
||||||
|
|
||||||
|
// 사용자 삭제
|
||||||
|
await pool.query("DELETE FROM users WHERE id = ?", [decoded.id]);
|
||||||
|
|
||||||
|
console.log(`[Auth] 회원 탈퇴: id=${decoded.id}, email=${decoded.email}`);
|
||||||
|
|
||||||
|
res.json({ success: true, message: "회원 탈퇴가 완료되었습니다." });
|
||||||
|
} catch (error) {
|
||||||
|
console.error("[Auth] 회원 탈퇴 오류:", error);
|
||||||
|
res.status(500).json({ error: "서버 오류가 발생했습니다." });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
export default router;
|
export default router;
|
||||||
|
|
|
||||||
|
|
@ -209,10 +209,41 @@ router.get("/status", async (req, res) => {
|
||||||
return res.json({ linked: false });
|
return res.json({ linked: false });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const uuid = links[0].minecraft_uuid;
|
||||||
|
let currentName = links[0].minecraft_name;
|
||||||
|
|
||||||
|
// 모드 API에서 최신 닉네임 조회 및 동기화
|
||||||
|
try {
|
||||||
|
const MOD_API_URL =
|
||||||
|
process.env.MOD_API_URL || "http://minecraft-server:8080";
|
||||||
|
const modRes = await fetch(`${MOD_API_URL}/player/${uuid}`);
|
||||||
|
if (modRes.ok) {
|
||||||
|
const playerData = await modRes.json();
|
||||||
|
if (playerData.name && playerData.name !== currentName) {
|
||||||
|
// 닉네임이 변경되었으면 minecraft_links 및 users 테이블 모두 업데이트
|
||||||
|
await pool.query(
|
||||||
|
"UPDATE minecraft_links SET minecraft_name = ? WHERE user_id = ?",
|
||||||
|
[playerData.name, user.id]
|
||||||
|
);
|
||||||
|
await pool.query("UPDATE users SET name = ? WHERE id = ?", [
|
||||||
|
playerData.name,
|
||||||
|
user.id,
|
||||||
|
]);
|
||||||
|
currentName = playerData.name;
|
||||||
|
console.log(
|
||||||
|
`[Link] 닉네임 동기화: ${links[0].minecraft_name} → ${currentName}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (modErr) {
|
||||||
|
// 모드 API 호출 실패해도 기존 데이터로 응답
|
||||||
|
console.log("[Link] 닉네임 동기화 실패 (모드 API 오류):", modErr.message);
|
||||||
|
}
|
||||||
|
|
||||||
res.json({
|
res.json({
|
||||||
linked: true,
|
linked: true,
|
||||||
minecraftName: links[0].minecraft_name,
|
minecraftName: currentName,
|
||||||
minecraftUuid: links[0].minecraft_uuid,
|
minecraftUuid: uuid,
|
||||||
linkedAt: links[0].linked_at,
|
linkedAt: links[0].linked_at,
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|
@ -384,4 +415,70 @@ router.post("/cancel", express.text({ type: "*/*" }), async (req, res) => {
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /link/skin/:type/:uuid/:size - 스킨 URL 조회 (캐싱)
|
||||||
|
* type: avatar 또는 body
|
||||||
|
* RustFS에 있으면 S3 URL 반환, 없으면 mc-heads에서 다운로드하여 저장 후 반환
|
||||||
|
*/
|
||||||
|
router.get("/skin/:type/:uuid/:size", async (req, res) => {
|
||||||
|
const { type, uuid, size } = req.params;
|
||||||
|
|
||||||
|
if (!uuid || uuid.length < 32) {
|
||||||
|
return res.status(400).json({ error: "유효하지 않은 UUID입니다." });
|
||||||
|
}
|
||||||
|
|
||||||
|
const validTypes = ["avatar", "body"];
|
||||||
|
const skinType = validTypes.includes(type) ? type : "avatar";
|
||||||
|
const skinSize = parseInt(size) || 128;
|
||||||
|
|
||||||
|
// S3 URL (타입별로 폴더 분리)
|
||||||
|
const s3Key = `skins/${skinType}/${uuid}_${skinSize}.png`;
|
||||||
|
const s3Url = `https://s3.caadiq.co.kr/minecraft/${s3Key}`;
|
||||||
|
|
||||||
|
try {
|
||||||
|
// S3에 파일 있는지 HEAD 요청으로 확인
|
||||||
|
const headRes = await fetch(s3Url, { method: "HEAD" });
|
||||||
|
|
||||||
|
if (headRes.ok) {
|
||||||
|
// 이미 캐시됨
|
||||||
|
return res.json({ url: s3Url, cached: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
// 없으면 mc-heads에서 다운로드
|
||||||
|
console.log(`[Link] 스킨 캐싱: ${skinType}/${uuid}/${skinSize}`);
|
||||||
|
const skinUrl = `https://mc-heads.net/${skinType}/${uuid}/${skinSize}`;
|
||||||
|
const skinRes = await fetch(skinUrl);
|
||||||
|
|
||||||
|
if (!skinRes.ok) {
|
||||||
|
return res
|
||||||
|
.status(404)
|
||||||
|
.json({ error: "스킨을 찾을 수 없습니다.", url: skinUrl });
|
||||||
|
}
|
||||||
|
|
||||||
|
const imageBuffer = await skinRes.arrayBuffer();
|
||||||
|
|
||||||
|
// S3에 업로드
|
||||||
|
await s3Client.send(
|
||||||
|
new PutObjectCommand({
|
||||||
|
Bucket: "minecraft",
|
||||||
|
Key: s3Key,
|
||||||
|
Body: Buffer.from(imageBuffer),
|
||||||
|
ContentType: "image/png",
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
console.log(`[Link] 스킨 캐시 완료: ${skinType}/${uuid}/${skinSize}`);
|
||||||
|
|
||||||
|
res.json({ url: s3Url, cached: false });
|
||||||
|
} catch (error) {
|
||||||
|
console.error("[Link] 스킨 캐싱 오류:", error);
|
||||||
|
// 폴백: mc-heads URL 직접 반환
|
||||||
|
res.json({
|
||||||
|
url: `https://mc-heads.net/${skinType}/${uuid}/${skinSize}`,
|
||||||
|
cached: false,
|
||||||
|
fallback: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
export default router;
|
export default router;
|
||||||
|
|
|
||||||
|
|
@ -15,7 +15,7 @@ const Sidebar = ({ isMobile = false }) => {
|
||||||
const [toast, setToast] = useState(null);
|
const [toast, setToast] = useState(null);
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const { isLoggedIn, isAdmin, user, logout } = useAuth();
|
const { isLoggedIn, isAdmin, user, logout, checkAuth } = useAuth();
|
||||||
const profileMenuRef = useRef(null);
|
const profileMenuRef = useRef(null);
|
||||||
|
|
||||||
const menuItems = [
|
const menuItems = [
|
||||||
|
|
@ -69,16 +69,44 @@ const Sidebar = ({ isMobile = false }) => {
|
||||||
fetchLinkStatus();
|
fetchLinkStatus();
|
||||||
}, [isLoggedIn, user]);
|
}, [isLoggedIn, user]);
|
||||||
|
|
||||||
// 서버 상태 확인 (socket.io)
|
// 서버 상태 확인 (socket.io) + 닉네임 변경 시에만 동기화
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const socket = io(window.location.origin, { path: '/socket.io' });
|
const socket = io(window.location.origin, { path: '/socket.io' });
|
||||||
|
|
||||||
|
// 닉네임 동기화 함수 (변경 시에만 호출됨)
|
||||||
|
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) => {
|
socket.on('status', (status) => {
|
||||||
setServerOnline(status?.online || false);
|
setServerOnline(status?.online || false);
|
||||||
|
|
||||||
|
// 연동된 유저인 경우, 소켓에서 받은 닉네임과 현재 닉네임 비교
|
||||||
|
if (status?.online && minecraftLink?.minecraftUuid && status?.players?.list) {
|
||||||
|
const playerInGame = status.players.list.find(p => p.uuid === minecraftLink.minecraftUuid);
|
||||||
|
// 게임 내 닉네임과 현재 저장된 닉네임이 다르면 동기화
|
||||||
|
if (playerInGame && playerInGame.name !== user?.name) {
|
||||||
|
syncNickname();
|
||||||
|
}
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
return () => socket.disconnect();
|
return () => socket.disconnect();
|
||||||
}, []);
|
}, [isLoggedIn, minecraftLink?.minecraftUuid, user?.name]);
|
||||||
|
|
||||||
// 토스트 자동 숨기기
|
// 토스트 자동 숨기기
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
@ -150,7 +178,7 @@ const Sidebar = ({ isMobile = false }) => {
|
||||||
className="w-full flex items-center gap-3 px-4 py-2.5 text-sm text-zinc-300 hover:bg-zinc-700 hover:text-white transition-colors"
|
className="w-full flex items-center gap-3 px-4 py-2.5 text-sm text-zinc-300 hover:bg-zinc-700 hover:text-white transition-colors"
|
||||||
>
|
>
|
||||||
<User size={16} />
|
<User size={16} />
|
||||||
프로필 수정
|
프로필 관리
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{minecraftLink && (
|
{minecraftLink && (
|
||||||
|
|
|
||||||
|
|
@ -2,31 +2,46 @@
|
||||||
* 관리자 페이지
|
* 관리자 페이지
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { useEffect } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate, useLocation } 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, LogOut, Settings, Server, Users, Loader2 } from 'lucide-react';
|
||||||
|
import { motion, AnimatePresence } from 'framer-motion';
|
||||||
|
|
||||||
export default function Admin() {
|
export default function Admin() {
|
||||||
const { isLoggedIn, isAdmin, user, loading, logout } = useAuth();
|
const { isLoggedIn, isAdmin, user, loading, logout } = useAuth();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
const location = useLocation();
|
||||||
|
const [toast, setToast] = useState(null);
|
||||||
|
|
||||||
// 권한 확인
|
// 권한 확인
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!loading) {
|
if (!loading) {
|
||||||
if (!isLoggedIn) {
|
if (!isLoggedIn) {
|
||||||
navigate('/login');
|
// 로그인 후 다시 admin으로 돌아올 수 있도록 현재 경로 전달
|
||||||
|
navigate('/login', { state: { from: location.pathname } });
|
||||||
} else if (!isAdmin) {
|
} else if (!isAdmin) {
|
||||||
navigate('/');
|
setToast('관리자 권한이 필요합니다.');
|
||||||
|
setTimeout(() => {
|
||||||
|
navigate('/');
|
||||||
|
}, 1500);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}, [isLoggedIn, isAdmin, loading, navigate]);
|
}, [isLoggedIn, isAdmin, loading, navigate, location.pathname]);
|
||||||
|
|
||||||
const handleLogout = () => {
|
const handleLogout = () => {
|
||||||
logout();
|
logout();
|
||||||
navigate('/');
|
navigate('/');
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// 토스트 자동 숨기기
|
||||||
|
useEffect(() => {
|
||||||
|
if (toast) {
|
||||||
|
const timer = setTimeout(() => setToast(null), 3000);
|
||||||
|
return () => clearTimeout(timer);
|
||||||
|
}
|
||||||
|
}, [toast]);
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center justify-center min-h-[60vh]">
|
<div className="flex items-center justify-center min-h-[60vh]">
|
||||||
|
|
@ -36,7 +51,26 @@ export default function Admin() {
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!isLoggedIn || !isAdmin) {
|
if (!isLoggedIn || !isAdmin) {
|
||||||
return null;
|
return (
|
||||||
|
<>
|
||||||
|
{/* 토스트 알림 */}
|
||||||
|
<AnimatePresence>
|
||||||
|
{toast && (
|
||||||
|
<motion.div
|
||||||
|
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"
|
||||||
|
>
|
||||||
|
{toast}
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
|
<div className="flex items-center justify-center min-h-[60vh]">
|
||||||
|
<Loader2 className="w-8 h-8 text-mc-green animate-spin" />
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
|
||||||
|
|
@ -3,18 +3,22 @@
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { useNavigate, Link } from 'react-router-dom';
|
import { useNavigate, Link, useLocation } from 'react-router-dom';
|
||||||
import { useAuth } from '../contexts/AuthContext';
|
import { useAuth } from '../contexts/AuthContext';
|
||||||
import { Lock, Mail, AlertCircle, Loader2, Shield, UserPlus } from 'lucide-react';
|
import { Lock, Mail, AlertCircle, Loader2, Shield, UserPlus } from 'lucide-react';
|
||||||
|
|
||||||
export default function LoginPage() {
|
export default function LoginPage() {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
const location = useLocation();
|
||||||
const { login } = useAuth();
|
const { login } = useAuth();
|
||||||
const [email, setEmail] = useState('');
|
const [email, setEmail] = useState('');
|
||||||
const [password, setPassword] = useState('');
|
const [password, setPassword] = useState('');
|
||||||
const [error, setError] = useState('');
|
const [error, setError] = useState('');
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
|
// 로그인 전 접근하려던 경로 (Admin 페이지 등에서 리다이렉트된 경우)
|
||||||
|
const from = location.state?.from || '/';
|
||||||
|
|
||||||
const handleSubmit = async (e) => {
|
const handleSubmit = async (e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
setError('');
|
setError('');
|
||||||
|
|
@ -25,7 +29,7 @@ export default function LoginPage() {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
|
|
||||||
if (result.success) {
|
if (result.success) {
|
||||||
navigate('/admin');
|
navigate(from, { replace: true });
|
||||||
} else {
|
} else {
|
||||||
setError(result.error);
|
setError(result.error);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
File diff suppressed because one or more lines are too long
|
|
@ -8,15 +8,26 @@ import { formatPlayTimeMs } from '../utils/formatters';
|
||||||
// 스티브 머리 기본 이미지 (로딩 전/실패 시 사용)
|
// 스티브 머리 기본 이미지 (로딩 전/실패 시 사용)
|
||||||
const STEVE_HEAD_BASE64 = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAADAAAAAwCAYAAABXAvmHAAAACXBIWXMAAA7EAAAOxAGVKw4bAAABWUlEQVRoge2ZPUsDQRCGk7DeGU1ysTDYpIuFjYUasMpHaZFKhJDWxj7gjwjYK/Y2wcJKsAppxEYkTcCU1oGcubt8gv6Bd4oBk3FhnvLZvb17GW7YvYsfF3Z+YgxSjuFMZxPMl9BPCZ9Y5cOsAw0gjQaQxvjBBA54qST0VJfgdqe/Wsf6CmgAaTSANMZ1NuDAVfkU+r3cLl5oKw39MhpDP5ktoPf9EfR33Tfora+ABpBGA0gTv788gycyz8vCC5Iu7loUVLfhQnUn6yugAaTRANIYqttc3DxAf15qQV8vfkBPdY/nzwr0j91r6NvNBvTWV0ADSKMBpGHvhSg6733W/MrRAWu+7oX+KxpAGusDmDCK4ECwCKF/ee1Bf5jfZ934tv0Efa16Av13iE921ldAA0ijAaQxxsU/6odj/NbPpnPoe18D1nx304Ge6jaZbfw9yvoKaABpNIA0v2NsVwyhlV0PAAAAAElFTkSuQmCC';
|
const STEVE_HEAD_BASE64 = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAADAAAAAwCAYAAABXAvmHAAAACXBIWXMAAA7EAAAOxAGVKw4bAAABWUlEQVRoge2ZPUsDQRCGk7DeGU1ysTDYpIuFjYUasMpHaZFKhJDWxj7gjwjYK/Y2wcJKsAppxEYkTcCU1oGcubt8gv6Bd4oBk3FhnvLZvb17GW7YvYsfF3Z+YgxSjuFMZxPMl9BPCZ9Y5cOsAw0gjQaQxvjBBA54qST0VJfgdqe/Wsf6CmgAaTSANMZ1NuDAVfkU+r3cLl5oKw39MhpDP5ktoPf9EfR33Tfora+ABpBGA0gTv788gycyz8vCC5Iu7loUVLfhQnUn6yugAaTRANIYqttc3DxAf15qQV8vfkBPdY/nzwr0j91r6NvNBvTWV0ADSKMBpGHvhSg6733W/MrRAWu+7oX+KxpAGusDmDCK4ECwCKF/ee1Bf5jfZ934tv0Efa16Av13iE921ldAA0ijAaQxxsU/6odj/NbPpnPoe18D1nx304Ge6jaZbfw9yvoKaABpNIA0v2NsVwyhlV0PAAAAAElFTkSuQmCC';
|
||||||
|
|
||||||
// 플레이어 아바타 컴포넌트 - 로딩 중에는 스티브, 로드 완료 시 실제 스킨 표시
|
// 플레이어 아바타 컴포넌트 - 스킨 캐싱 API 사용
|
||||||
const PlayerAvatar = ({ uuid, name }) => {
|
const PlayerAvatar = ({ uuid, name }) => {
|
||||||
const [src, setSrc] = useState(STEVE_HEAD_BASE64);
|
const [src, setSrc] = useState(STEVE_HEAD_BASE64);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const img = new Image();
|
// 스킨 캐싱 API 호출 (avatar/uuid/size)
|
||||||
const realUrl = `https://mc-heads.net/avatar/${uuid}/48`;
|
fetch(`/link/skin/avatar/${uuid}/48`)
|
||||||
img.onload = () => setSrc(realUrl);
|
.then(res => res.json())
|
||||||
img.src = realUrl;
|
.then(data => {
|
||||||
|
if (data.url) {
|
||||||
|
const img = new Image();
|
||||||
|
img.onload = () => setSrc(data.url);
|
||||||
|
img.onerror = () => setSrc(`https://mc-heads.net/avatar/${uuid}/48`);
|
||||||
|
img.src = data.url;
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
// 폴백: mc-heads 직접 사용
|
||||||
|
setSrc(`https://mc-heads.net/avatar/${uuid}/48`);
|
||||||
|
});
|
||||||
}, [uuid]);
|
}, [uuid]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
|
||||||
|
|
@ -4,14 +4,16 @@
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import React, { useState, useEffect } from 'react';
|
import React, { useState, useEffect } from 'react';
|
||||||
import { Link, useNavigate } from 'react-router-dom';
|
import { Link, useNavigate, useLocation } from 'react-router-dom';
|
||||||
import { useAuth } from '../contexts/AuthContext';
|
import { useAuth } from '../contexts/AuthContext';
|
||||||
import { ArrowLeft, Copy, Check, Gamepad2, Link as LinkIcon, Unlink, RefreshCw, User, Mail } from 'lucide-react';
|
import { ArrowLeft, Copy, Check, Gamepad2, Link as LinkIcon, Unlink, RefreshCw, User, Mail } from 'lucide-react';
|
||||||
import { motion, AnimatePresence } from 'framer-motion';
|
import { motion, AnimatePresence } from 'framer-motion';
|
||||||
|
import { io } from 'socket.io-client';
|
||||||
|
|
||||||
export default function ProfilePage() {
|
export default function ProfilePage() {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const { user, isLoggedIn, loading, checkAuth } = useAuth();
|
const location = useLocation();
|
||||||
|
const { user, isLoggedIn, loading, checkAuth, logout } = useAuth();
|
||||||
|
|
||||||
const [linkStatus, setLinkStatus] = useState(null);
|
const [linkStatus, setLinkStatus] = useState(null);
|
||||||
const [linkToken, setLinkToken] = useState(null);
|
const [linkToken, setLinkToken] = useState(null);
|
||||||
|
|
@ -20,13 +22,14 @@ export default function ProfilePage() {
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
const [polling, setPolling] = useState(false);
|
const [polling, setPolling] = useState(false);
|
||||||
const [showUnlinkDialog, setShowUnlinkDialog] = useState(false);
|
const [showUnlinkDialog, setShowUnlinkDialog] = useState(false);
|
||||||
|
const [showDeleteDialog, setShowDeleteDialog] = useState(false);
|
||||||
|
|
||||||
// 로그인 체크 (loading 완료 후에만)
|
// 로그인 체크 (loading 완료 후에만)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!loading && !isLoggedIn) {
|
if (!loading && !isLoggedIn) {
|
||||||
navigate('/login');
|
navigate('/login', { state: { from: location.pathname } });
|
||||||
}
|
}
|
||||||
}, [loading, isLoggedIn, navigate]);
|
}, [loading, isLoggedIn, navigate, location.pathname]);
|
||||||
|
|
||||||
// 연동 상태 확인
|
// 연동 상태 확인
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
@ -69,6 +72,27 @@ export default function ProfilePage() {
|
||||||
return () => window.removeEventListener('beforeunload', handleBeforeUnload);
|
return () => window.removeEventListener('beforeunload', handleBeforeUnload);
|
||||||
}, [linkToken]);
|
}, [linkToken]);
|
||||||
|
|
||||||
|
// 소켓으로 닉네임 변경 감지 시 동기화
|
||||||
|
useEffect(() => {
|
||||||
|
if (!linkStatus?.minecraftUuid) return;
|
||||||
|
|
||||||
|
const socket = io(window.location.origin, { path: '/socket.io' });
|
||||||
|
|
||||||
|
socket.on('status', async (status) => {
|
||||||
|
if (status?.online && status?.players?.list) {
|
||||||
|
const playerInGame = status.players.list.find(p => p.uuid === linkStatus.minecraftUuid);
|
||||||
|
// 게임 내 닉네임과 현재 저장된 닉네임이 다르면 동기화
|
||||||
|
if (playerInGame && playerInGame.name !== user?.name) {
|
||||||
|
// fetchLinkStatus가 DB 업데이트 → 완료 후 checkAuth로 user.name 갱신
|
||||||
|
await fetchLinkStatus();
|
||||||
|
await checkAuth();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => socket.disconnect();
|
||||||
|
}, [linkStatus?.minecraftUuid, user?.name]);
|
||||||
|
|
||||||
const fetchLinkStatus = async () => {
|
const fetchLinkStatus = async () => {
|
||||||
try {
|
try {
|
||||||
const token = localStorage.getItem('token');
|
const token = localStorage.getItem('token');
|
||||||
|
|
@ -129,6 +153,25 @@ export default function ProfilePage() {
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
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;
|
if (!user) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
@ -265,6 +308,28 @@ export default function ProfilePage() {
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</section>
|
</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">
|
||||||
|
<User size={20} className="text-zinc-400" />
|
||||||
|
계정 관리
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="p-4 bg-zinc-800/50 border border-zinc-700 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>
|
</main>
|
||||||
|
|
||||||
{/* 연동 해제 다이얼로그 */}
|
{/* 연동 해제 다이얼로그 */}
|
||||||
|
|
@ -306,6 +371,47 @@ export default function ProfilePage() {
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</AnimatePresence>
|
</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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,34 @@ import Tooltip from '../components/Tooltip';
|
||||||
|
|
||||||
import { io } from 'socket.io-client';
|
import { io } from 'socket.io-client';
|
||||||
|
|
||||||
|
// 캐시된 스킨 컴포넌트 (helm 타입 지원)
|
||||||
|
const CachedSkin = ({ uuid, name, size = 24 }) => {
|
||||||
|
const [src, setSrc] = useState(null);
|
||||||
|
const fallbackUrl = `https://mc-heads.net/avatar/${uuid}/${size}`;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetch(`/link/skin/avatar/${uuid}/${size}`)
|
||||||
|
.then(res => res.json())
|
||||||
|
.then(data => {
|
||||||
|
if (data.url) {
|
||||||
|
setSrc(data.url);
|
||||||
|
} else {
|
||||||
|
setSrc(fallbackUrl);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(() => setSrc(fallbackUrl));
|
||||||
|
}, [uuid, size, fallbackUrl]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<img
|
||||||
|
src={src || fallbackUrl}
|
||||||
|
alt={name}
|
||||||
|
className="w-6 h-6 rounded-sm"
|
||||||
|
onError={(e) => { e.target.src = fallbackUrl; }}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
const ServerDetail = ({ isMobile = false }) => {
|
const ServerDetail = ({ isMobile = false }) => {
|
||||||
const [server, setServer] = useState(null);
|
const [server, setServer] = useState(null);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
@ -45,7 +73,7 @@ const ServerDetail = ({ isMobile = false }) => {
|
||||||
});
|
});
|
||||||
|
|
||||||
socket.on('connect', () => {
|
socket.on('connect', () => {
|
||||||
console.log('서버 소켓 연결 성공');
|
// 소켓 연결 성공
|
||||||
});
|
});
|
||||||
|
|
||||||
socket.on('status', (data) => {
|
socket.on('status', (data) => {
|
||||||
|
|
@ -53,7 +81,7 @@ const ServerDetail = ({ isMobile = false }) => {
|
||||||
});
|
});
|
||||||
|
|
||||||
socket.on('disconnect', () => {
|
socket.on('disconnect', () => {
|
||||||
console.log('서버 소켓 연결 해제');
|
// 소켓 연결 해제
|
||||||
});
|
});
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
|
|
@ -246,15 +274,7 @@ const ServerDetail = ({ isMobile = false }) => {
|
||||||
className="flex items-center gap-2.5 px-4 py-2.5 rounded-lg bg-white/5 border border-white/10 hover:border-mc-green/50 hover:bg-mc-green/5 transition-all cursor-pointer group hover:scale-[1.03] active:scale-[0.98]"
|
className="flex items-center gap-2.5 px-4 py-2.5 rounded-lg bg-white/5 border border-white/10 hover:border-mc-green/50 hover:bg-mc-green/5 transition-all cursor-pointer group hover:scale-[1.03] active:scale-[0.98]"
|
||||||
>
|
>
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<img
|
<CachedSkin uuid={player.uuid} name={player.name} size={24} />
|
||||||
src={`https://minotar.net/helm/${player.uuid}/24.png`}
|
|
||||||
alt={player.name}
|
|
||||||
className="w-6 h-6 rounded-sm"
|
|
||||||
onError={(e) => {
|
|
||||||
e.target.onerror = null;
|
|
||||||
e.target.src = 'https://minotar.net/helm/Steve/24.png';
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
{/* 온라인 표시 */}
|
{/* 온라인 표시 */}
|
||||||
<span className="absolute -bottom-0.5 -right-0.5 w-2 h-2 bg-mc-green rounded-full border border-mc-bg" />
|
<span className="absolute -bottom-0.5 -right-0.5 w-2 h-2 bg-mc-green rounded-full border border-mc-bg" />
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,34 @@ import { Globe, Sun, CloudRain, CloudLightning, Clock, Users, MapPin, ServerOff
|
||||||
import { motion } from 'framer-motion';
|
import { motion } from 'framer-motion';
|
||||||
import { io } from 'socket.io-client';
|
import { io } from 'socket.io-client';
|
||||||
|
|
||||||
|
// 캐시된 스킨 컴포넌트
|
||||||
|
const CachedSkin = ({ uuid, name, type = 'avatar', size = 40, className }) => {
|
||||||
|
const [src, setSrc] = useState(null);
|
||||||
|
const fallbackUrl = `https://mc-heads.net/${type}/${uuid}/${size}`;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetch(`/link/skin/${type}/${uuid}/${size}`)
|
||||||
|
.then(res => res.json())
|
||||||
|
.then(data => {
|
||||||
|
if (data.url) {
|
||||||
|
setSrc(data.url);
|
||||||
|
} else {
|
||||||
|
setSrc(fallbackUrl);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(() => setSrc(fallbackUrl));
|
||||||
|
}, [uuid, type, size, fallbackUrl]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<img
|
||||||
|
src={src || fallbackUrl}
|
||||||
|
alt={name}
|
||||||
|
className={className}
|
||||||
|
onError={(e) => { e.target.src = fallbackUrl; }}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
// 월드 정보 페이지
|
// 월드 정보 페이지
|
||||||
const WorldsPage = ({ isMobile = false }) => {
|
const WorldsPage = ({ isMobile = false }) => {
|
||||||
const [worlds, setWorlds] = useState([]);
|
const [worlds, setWorlds] = useState([]);
|
||||||
|
|
@ -188,12 +216,11 @@ const WorldsPage = ({ isMobile = false }) => {
|
||||||
className={`flex items-center gap-3 bg-white/5 rounded-xl ${isMobile ? 'p-2' : 'p-3'}`}
|
className={`flex items-center gap-3 bg-white/5 rounded-xl ${isMobile ? 'p-2' : 'p-3'}`}
|
||||||
>
|
>
|
||||||
{/* 모바일: 머리만, PC: 전신 */}
|
{/* 모바일: 머리만, PC: 전신 */}
|
||||||
<img
|
<CachedSkin
|
||||||
src={isMobile
|
uuid={player.uuid}
|
||||||
? `https://mc-heads.net/avatar/${player.uuid}/40`
|
name={player.name}
|
||||||
: `https://mc-heads.net/body/${player.uuid}/60`
|
type={isMobile ? 'avatar' : 'body'}
|
||||||
}
|
size={isMobile ? 40 : 60}
|
||||||
alt={player.name}
|
|
||||||
className={isMobile ? 'w-10 h-10 rounded' : 'h-16 w-auto'}
|
className={isMobile ? 'w-10 h-10 rounded' : 'h-16 w-auto'}
|
||||||
/>
|
/>
|
||||||
<div>
|
<div>
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue