Initial commit: Minecraft Dashboard
This commit is contained in:
commit
aa0f339f27
35 changed files with 3476 additions and 0 deletions
24
.gitignore
vendored
Normal file
24
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
# Dependencies
|
||||
node_modules/
|
||||
|
||||
# Environment
|
||||
.env
|
||||
.env.local
|
||||
|
||||
# Logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
|
||||
# OS
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
# IDE
|
||||
.idea/
|
||||
.vscode/
|
||||
*.swp
|
||||
*.swo
|
||||
|
||||
# Build
|
||||
dist/
|
||||
build/
|
||||
24
Dockerfile
Normal file
24
Dockerfile
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
# 빌드 스테이지 - 프론트엔드 빌드
|
||||
FROM node:20-alpine AS frontend-builder
|
||||
WORKDIR /frontend
|
||||
COPY frontend/package*.json ./
|
||||
RUN npm install
|
||||
COPY frontend/ ./
|
||||
RUN npm run build
|
||||
|
||||
# 프로덕션 스테이지 - 백엔드 + 프론트 빌드 결과물
|
||||
FROM node:20-alpine
|
||||
WORKDIR /app
|
||||
|
||||
# 백엔드 의존성 설치
|
||||
COPY backend/package*.json ./
|
||||
RUN npm install --production
|
||||
|
||||
# 백엔드 파일 복사
|
||||
COPY backend/ ./
|
||||
|
||||
# 프론트엔드 빌드 결과물 복사
|
||||
COPY --from=frontend-builder /frontend/dist ./dist
|
||||
|
||||
EXPOSE 80
|
||||
CMD ["node", "server.js"]
|
||||
99
README.md
Normal file
99
README.md
Normal file
|
|
@ -0,0 +1,99 @@
|
|||
# 🎮 Minecraft Dashboard
|
||||
|
||||
마인크래프트 서버 상태를 실시간으로 모니터링하는 웹 대시보드입니다.
|
||||
|
||||

|
||||

|
||||

|
||||
|
||||
---
|
||||
|
||||
## ✨ 주요 기능
|
||||
|
||||
- 🟢 **서버 상태** - 온라인/오프라인 상태 및 플레이어 수 실시간 표시
|
||||
- 👥 **플레이어 정보** - 접속 중인 플레이어 목록 및 3D 스킨 뷰어
|
||||
- 🗺️ **월드 정보** - 서버 월드 목록 및 상세 정보
|
||||
- 📊 **플레이어 통계** - 개별 플레이어 활동 통계
|
||||
- ⚡ **WebSocket** - Socket.IO 기반 실시간 데이터 업데이트
|
||||
|
||||
---
|
||||
|
||||
## 📁 프로젝트 구조
|
||||
|
||||
```
|
||||
minecraft-web/
|
||||
├── frontend/ # React + Vite 프론트엔드
|
||||
├── backend/ # Node.js + Express 백엔드
|
||||
├── Dockerfile # 멀티스테이지 Docker 빌드
|
||||
└── docker-compose.yml # Docker Compose 설정
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🛠️ 기술 스택
|
||||
|
||||
### Frontend
|
||||
|
||||
| 기술 | 설명 |
|
||||
| --------------- | -------------- |
|
||||
| **React 18** | UI 라이브러리 |
|
||||
| **Vite** | 빌드 도구 |
|
||||
| **TailwindCSS** | CSS 프레임워크 |
|
||||
| **Socket.IO** | 실시간 통신 |
|
||||
| **skinview3d** | 3D 스킨 렌더링 |
|
||||
|
||||
### Backend
|
||||
|
||||
| 기술 | 설명 |
|
||||
| ------------------------- | ----------------- |
|
||||
| **Node.js** | 런타임 환경 |
|
||||
| **Express** | 웹 프레임워크 |
|
||||
| **Socket.IO** | 실시간 통신 |
|
||||
| **minecraft-server-util** | 서버 상태 조회 |
|
||||
| **MySQL2** | 데이터베이스 연동 |
|
||||
|
||||
---
|
||||
|
||||
## 🚀 실행 방법
|
||||
|
||||
### Docker (권장)
|
||||
|
||||
```bash
|
||||
docker compose up -d --build
|
||||
```
|
||||
|
||||
### 개발 모드
|
||||
|
||||
```bash
|
||||
# 프론트엔드
|
||||
cd frontend && npm install && npm run dev
|
||||
|
||||
# 백엔드
|
||||
cd backend && npm install && npm start
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## ⚙️ 환경 변수
|
||||
|
||||
`.env` 파일에 다음 변수들을 설정하세요:
|
||||
|
||||
```env
|
||||
DB_HOST=mariadb
|
||||
DB_USER=minecraft
|
||||
DB_PASSWORD=your_password
|
||||
DB_NAME=minecraft
|
||||
MOD_API_URL=http://minecraft-server:25566
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🌐 접속
|
||||
|
||||
- **URL**: https://minecraft.caadiq.co.kr
|
||||
|
||||
---
|
||||
|
||||
## 📄 라이선스
|
||||
|
||||
MIT License
|
||||
85
backend/README.md
Normal file
85
backend/README.md
Normal file
|
|
@ -0,0 +1,85 @@
|
|||
# 🖥️ Minecraft Dashboard - Backend
|
||||
|
||||
마인크래프트 서버 상태를 조회하고 실시간으로 전달하는 Node.js API 서버입니다.
|
||||
|
||||

|
||||

|
||||

|
||||
|
||||
---
|
||||
|
||||
## 🛠️ 기술 스택
|
||||
|
||||
| 기술 | 설명 |
|
||||
| ------------------------- | ----------------- |
|
||||
| **Node.js** | 런타임 환경 |
|
||||
| **Express** | 웹 프레임워크 |
|
||||
| **Socket.IO** | 실시간 통신 |
|
||||
| **minecraft-server-util** | 서버 상태 조회 |
|
||||
| **MySQL2** | 데이터베이스 연동 |
|
||||
|
||||
---
|
||||
|
||||
## 📡 API 엔드포인트
|
||||
|
||||
### REST API
|
||||
|
||||
| 엔드포인트 | 설명 |
|
||||
| ------------------ | -------------- |
|
||||
| `GET /api/status` | 서버 상태 조회 |
|
||||
| `GET /api/players` | 플레이어 목록 |
|
||||
| `GET /api/worlds` | 월드 정보 |
|
||||
|
||||
### WebSocket 이벤트
|
||||
|
||||
| 이벤트 | 방향 | 설명 |
|
||||
| ------------------ | --------------- | ---------------------- |
|
||||
| `status` | Server → Client | 서버 상태 브로드캐스트 |
|
||||
| `players` | Server → Client | 플레이어 목록 전송 |
|
||||
| `get_player` | Client → Server | 플레이어 상세 요청 |
|
||||
| `player_detail` | Server → Client | 플레이어 상세 응답 |
|
||||
| `get_worlds` | Client → Server | 월드 목록 요청 |
|
||||
| `worlds` | Server → Client | 월드 목록 응답 |
|
||||
| `get_player_stats` | Client → Server | 플레이어 통계 요청 |
|
||||
| `player_stats` | Server → Client | 플레이어 통계 응답 |
|
||||
|
||||
---
|
||||
|
||||
## 🚀 실행 방법
|
||||
|
||||
```bash
|
||||
# 개발 모드
|
||||
npm install
|
||||
npm start
|
||||
```
|
||||
|
||||
서버는 **포트 80**에서 실행됩니다.
|
||||
|
||||
---
|
||||
|
||||
## 📁 구조
|
||||
|
||||
```
|
||||
backend/
|
||||
├── server.js # 메인 서버 (Express + Socket.IO)
|
||||
├── routes/
|
||||
│ └── api.js # REST API 라우트
|
||||
├── lib/
|
||||
│ ├── db.js # MySQL 연결 및 번역 로드
|
||||
│ ├── minecraft.js # 마인크래프트 서버 통신
|
||||
│ ├── icons.js # 아이콘 유틸리티
|
||||
│ └── s3.js # S3 스토리지 연동
|
||||
└── data/ # 정적 데이터 파일
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## ⚙️ 환경 변수
|
||||
|
||||
| 변수 | 설명 |
|
||||
| ------------- | ------------------------ |
|
||||
| `DB_HOST` | MariaDB 호스트 |
|
||||
| `DB_USER` | 데이터베이스 사용자 |
|
||||
| `DB_PASSWORD` | 데이터베이스 비밀번호 |
|
||||
| `DB_NAME` | 데이터베이스 이름 |
|
||||
| `MOD_API_URL` | 마인크래프트 Mod API URL |
|
||||
31
backend/data/entityToMHF.json
Normal file
31
backend/data/entityToMHF.json
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
{
|
||||
"creeper": "057b1c4713214863a6fe8887f9ec265f",
|
||||
"ghast": "063085a6797f4785be1a21cd7580f752",
|
||||
"magma_cube": "0972bdd14b8649fb9ecca353f8491a51",
|
||||
"shulker": "160f7d8ac6b04fc889259e9d6c9c57d5",
|
||||
"zombified_piglin": "18a2bb50334a408491842c380251a24b",
|
||||
"pig_zombie": "18a2bb50334a408491842c380251a24b",
|
||||
"ocelot": "1bee9df54f7142a2bf52d97970d3fea3",
|
||||
"wither": "39af684468094d2f8ba47e92d087be18",
|
||||
"enderman": "40ffb37212f64678b3f22176bf56dd4b",
|
||||
"blaze": "4c38ed11596a4fd4ab1d26f386c1cbac",
|
||||
"spider": "5ad55f3441b64bd29c3218983c635936",
|
||||
"squid": "72e64683e3134c36a408c66b64e94af5",
|
||||
"iron_golem": "757f90b223444b8d8dac824232e2cece",
|
||||
"wither_skeleton": "7ed571a59fb8416c8b9dfb2f446ab5b2",
|
||||
"slime": "870aba9340e848b389c532ece00d6630",
|
||||
"pig": "8b57078bf1bd45df83c4d88d16768fbe",
|
||||
"wolf": "8d2d1d6d80344c89bd86809a31fd5193",
|
||||
"chicken": "92deafa9430742d9b00388601598d6c0",
|
||||
"skeleton": "a3f427a818c549c5a4fb64c6e0e1e0a8",
|
||||
"mooshroom": "a46817d673c54f3fb712af6b3ff47b96",
|
||||
"villager": "bd482739767c45dca1f8c33c40530952",
|
||||
"cave_spider": "cab28771f0cd4fe7b12902c69eba79a5",
|
||||
"illager": "d91da370bbfe421fab92bd327b1d67d6",
|
||||
"zombie": "daca2c3d719b41f5b624e4039e6c04bd",
|
||||
"sheep": "dfaad5514e7e45a1a6f7c6fc5ec823ac",
|
||||
"stray": "ed33403bbe7f4a38915cabea93ebc9bc",
|
||||
"cow": "f159b274c22e4340b7c152abde147713",
|
||||
"vex": "f5f20997217f44268ab9c6db6cce023f",
|
||||
"witch": "fef85c492fdf47f89132552046243223"
|
||||
}
|
||||
88
backend/lib/db.js
Normal file
88
backend/lib/db.js
Normal file
|
|
@ -0,0 +1,88 @@
|
|||
import mysql from "mysql2/promise";
|
||||
|
||||
// MariaDB 연결 풀 - 환경변수에서 로드
|
||||
const dbPool = mysql.createPool({
|
||||
host: process.env.DB_HOST,
|
||||
user: process.env.DB_USER,
|
||||
password: process.env.DB_PASSWORD,
|
||||
database: process.env.DB_NAME,
|
||||
waitForConnections: true,
|
||||
connectionLimit: 10,
|
||||
queueLimit: 0,
|
||||
});
|
||||
|
||||
// 캐시 데이터
|
||||
let translationsCache = {};
|
||||
let iconsCache = {}; // { "type:name": iconUrl } - 타입별로 구분
|
||||
let gamerulesCache = {};
|
||||
|
||||
/**
|
||||
* 번역 및 아이콘 데이터 로드 (blocks, items, entities 테이블에서)
|
||||
*/
|
||||
async function loadTranslations() {
|
||||
try {
|
||||
const [blocks] = await dbPool.query(
|
||||
"SELECT name, name_ko, icon FROM blocks WHERE mod_id = 'minecraft'"
|
||||
);
|
||||
const [items] = await dbPool.query(
|
||||
"SELECT name, name_ko, icon FROM items WHERE mod_id = 'minecraft'"
|
||||
);
|
||||
const [entities] = await dbPool.query(
|
||||
"SELECT name, name_ko, icon FROM entities WHERE mod_id = 'minecraft'"
|
||||
);
|
||||
const [gamerules] = await dbPool.query(
|
||||
"SELECT name, name_ko, description_ko FROM gamerules WHERE mod_id = 'minecraft'"
|
||||
);
|
||||
|
||||
// 캐시 초기화
|
||||
translationsCache = {};
|
||||
iconsCache = {};
|
||||
|
||||
// blocks, items, entities를 캐시에 저장 (type:name 형식으로 구분)
|
||||
blocks.forEach((row) => {
|
||||
translationsCache[row.name] = row.name_ko;
|
||||
if (row.icon) iconsCache[`block:${row.name}`] = row.icon;
|
||||
});
|
||||
items.forEach((row) => {
|
||||
translationsCache[row.name] = row.name_ko;
|
||||
if (row.icon) iconsCache[`item:${row.name}`] = row.icon;
|
||||
});
|
||||
entities.forEach((row) => {
|
||||
translationsCache[row.name] = row.name_ko;
|
||||
if (row.icon) iconsCache[`entity:${row.name}`] = row.icon;
|
||||
});
|
||||
|
||||
// gamerules 캐시
|
||||
gamerulesCache = {};
|
||||
gamerules.forEach((row) => {
|
||||
gamerulesCache[row.name] = {
|
||||
name: row.name_ko || row.name,
|
||||
description: row.description_ko || "",
|
||||
};
|
||||
});
|
||||
|
||||
const iconCount = Object.keys(iconsCache).length;
|
||||
console.log(
|
||||
`[DB] 번역 데이터 로드 완료: blocks ${blocks.length}개, items ${items.length}개, entities ${entities.length}개, icons ${iconCount}개`
|
||||
);
|
||||
} catch (error) {
|
||||
console.error("[DB] 번역 데이터 로드 실패:", error.message);
|
||||
}
|
||||
}
|
||||
|
||||
// 캐시 접근 함수
|
||||
const getTranslations = () => translationsCache;
|
||||
const getIcons = () => iconsCache;
|
||||
const getGamerules = () => gamerulesCache;
|
||||
const setIconCache = (key, value) => {
|
||||
iconsCache[key] = value;
|
||||
};
|
||||
|
||||
export {
|
||||
dbPool,
|
||||
loadTranslations,
|
||||
getTranslations,
|
||||
getIcons,
|
||||
getGamerules,
|
||||
setIconCache,
|
||||
};
|
||||
111
backend/lib/icons.js
Normal file
111
backend/lib/icons.js
Normal file
|
|
@ -0,0 +1,111 @@
|
|||
import path from "path";
|
||||
import { fileURLToPath } from "url";
|
||||
import { readFileSync } from "fs";
|
||||
import https from "https";
|
||||
import http from "http";
|
||||
import { dbPool, getIcons, setIconCache } from "./db.js";
|
||||
import { s3Config, uploadToS3 } from "./s3.js";
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = path.dirname(__filename);
|
||||
|
||||
// MHF UUID 매핑 로드
|
||||
const entityToMHF = JSON.parse(
|
||||
readFileSync(path.join(__dirname, "..", "data", "entityToMHF.json"), "utf-8")
|
||||
);
|
||||
|
||||
/**
|
||||
* URL에서 이미지 다운로드
|
||||
*/
|
||||
function downloadImage(url) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const protocol = url.startsWith("https") ? https : http;
|
||||
protocol
|
||||
.get(url, (res) => {
|
||||
if (res.statusCode === 301 || res.statusCode === 302) {
|
||||
return downloadImage(res.headers.location)
|
||||
.then(resolve)
|
||||
.catch(reject);
|
||||
}
|
||||
if (res.statusCode !== 200) {
|
||||
reject(new Error(`HTTP ${res.statusCode}`));
|
||||
return;
|
||||
}
|
||||
const chunks = [];
|
||||
res.on("data", (chunk) => chunks.push(chunk));
|
||||
res.on("end", () => resolve(Buffer.concat(chunks)));
|
||||
res.on("error", reject);
|
||||
})
|
||||
.on("error", reject);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 온디맨드 아이콘 가져오기 (없으면 다운로드/저장)
|
||||
*/
|
||||
async function getIconUrl(name, type, modId = "minecraft") {
|
||||
const iconsCache = getIcons();
|
||||
|
||||
// item 타입일 때 실제로 blocks인지 items인지 DB에서 확인
|
||||
let actualType = type;
|
||||
if (type === "item") {
|
||||
const [itemRows] = await dbPool.query(
|
||||
"SELECT 1 FROM items WHERE name = ? AND mod_id = ? LIMIT 1",
|
||||
[name, modId]
|
||||
);
|
||||
if (itemRows.length === 0) {
|
||||
const [blockRows] = await dbPool.query(
|
||||
"SELECT 1 FROM blocks WHERE name = ? AND mod_id = ? LIMIT 1",
|
||||
[name, modId]
|
||||
);
|
||||
if (blockRows.length > 0) {
|
||||
actualType = "block";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 캐시 키: type:name 형식
|
||||
const cacheKey = `${actualType}:${name}`;
|
||||
|
||||
// 캐시에 있으면 반환
|
||||
if (iconsCache[cacheKey]) return iconsCache[cacheKey];
|
||||
|
||||
// 소스 URL 결정
|
||||
let sourceUrl;
|
||||
let s3Key;
|
||||
|
||||
if (actualType === "entity") {
|
||||
const mhfUuid = entityToMHF[name];
|
||||
if (mhfUuid) {
|
||||
sourceUrl = `https://mc-heads.net/avatar/${mhfUuid}/64`;
|
||||
} else {
|
||||
sourceUrl = `https://mc.nerothe.com/img/1.21/minecraft_${name}_spawn_egg.png`;
|
||||
}
|
||||
s3Key = `icon/entities/${modId}_entity_${name}.png`;
|
||||
} else {
|
||||
sourceUrl = `https://mc.nerothe.com/img/1.21/minecraft_${name}.png`;
|
||||
s3Key = `icon/${actualType}s/${modId}_${actualType}_${name}.png`;
|
||||
}
|
||||
|
||||
try {
|
||||
const imageData = await downloadImage(sourceUrl);
|
||||
const publicUrl = await uploadToS3(s3Config.bucket, s3Key, imageData);
|
||||
|
||||
// DB 업데이트
|
||||
const table = actualType === "entity" ? "entities" : `${actualType}s`;
|
||||
await dbPool.query(
|
||||
`UPDATE ${table} SET icon = ? WHERE name = ? AND mod_id = ?`,
|
||||
[publicUrl, name, modId]
|
||||
);
|
||||
|
||||
// 캐시 업데이트
|
||||
setIconCache(cacheKey, publicUrl);
|
||||
console.log(`[Icon] 저장 완료: ${cacheKey} → ${publicUrl}`);
|
||||
return publicUrl;
|
||||
} catch (error) {
|
||||
console.error(`[Icon] 다운로드 실패: ${cacheKey} - ${error.message}`);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export { getIconUrl };
|
||||
147
backend/lib/minecraft.js
Normal file
147
backend/lib/minecraft.js
Normal file
|
|
@ -0,0 +1,147 @@
|
|||
import { status } from "minecraft-server-util";
|
||||
|
||||
const MOD_API_URL = process.env.MOD_API_URL || "http://minecraft-server:8080";
|
||||
|
||||
// 캐시된 데이터
|
||||
let cachedStatus = null;
|
||||
let cachedPlayers = [];
|
||||
|
||||
/**
|
||||
* 모드 API에서 상태 가져오기
|
||||
*/
|
||||
async function fetchModStatus() {
|
||||
try {
|
||||
const response = await fetch(`${MOD_API_URL}/status`);
|
||||
if (response.ok) return await response.json();
|
||||
return null;
|
||||
} catch (error) {
|
||||
console.error("[ModAPI] 상태 조회 실패:", error.message);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* MOTD 가져오기 (마인크래프트 프로토콜 사용)
|
||||
*/
|
||||
async function fetchMotd() {
|
||||
try {
|
||||
const result = await status("minecraft-server", 25565, { timeout: 5000 });
|
||||
return {
|
||||
motd: result.motd?.html || null,
|
||||
icon: result.favicon || null,
|
||||
};
|
||||
} catch (error) {
|
||||
return { motd: null, icon: null };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 클라이언트용 상태 포맷 변환
|
||||
*/
|
||||
function formatStatusForClient(modStatus, motdData) {
|
||||
if (!modStatus) {
|
||||
return {
|
||||
online: false,
|
||||
motd: null,
|
||||
players: { current: 0, max: 0, list: [] },
|
||||
version: "Unknown",
|
||||
icon: null,
|
||||
uptime: "오프라인",
|
||||
};
|
||||
}
|
||||
|
||||
const uptimeMinutes = modStatus.uptimeMinutes || 0;
|
||||
const days = Math.floor(uptimeMinutes / 1440);
|
||||
const hours = Math.floor((uptimeMinutes % 1440) / 60);
|
||||
const minutes = uptimeMinutes % 60;
|
||||
|
||||
let uptime;
|
||||
if (days > 0) uptime = `${days}일 ${hours}시간`;
|
||||
else if (hours > 0) uptime = `${hours}시간 ${minutes}분`;
|
||||
else uptime = `${minutes}분`;
|
||||
|
||||
return {
|
||||
online: modStatus.online,
|
||||
motd: motdData?.motd || null,
|
||||
icon: motdData?.icon || null,
|
||||
players: {
|
||||
current: modStatus.players?.current || 0,
|
||||
max: modStatus.players?.max || 20,
|
||||
list:
|
||||
modStatus.players?.online?.map((p) => ({
|
||||
name: p.name,
|
||||
uuid: p.uuid,
|
||||
id: p.uuid,
|
||||
isOp: p.isOp,
|
||||
})) || [],
|
||||
},
|
||||
version: modStatus.version || "Unknown",
|
||||
modLoader: modStatus.modLoader || null,
|
||||
uptime: modStatus.online ? uptime : "오프라인",
|
||||
difficulty: modStatus.difficulty || "알 수 없음",
|
||||
gameRules: modStatus.gameRules || {},
|
||||
mods: modStatus.mods || [],
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 전체 플레이어 목록 조회
|
||||
*/
|
||||
async function fetchAllPlayers() {
|
||||
try {
|
||||
const response = await fetch(`${MOD_API_URL}/players`);
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
return data.players || [];
|
||||
}
|
||||
return [];
|
||||
} catch (error) {
|
||||
console.error("[ModAPI] 플레이어 목록 조회 실패:", error.message);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 특정 플레이어 정보 조회
|
||||
*/
|
||||
async function fetchPlayerDetail(uuid) {
|
||||
try {
|
||||
const response = await fetch(`${MOD_API_URL}/player/${uuid}`);
|
||||
if (response.ok) return await response.json();
|
||||
return null;
|
||||
} catch (error) {
|
||||
console.error("[ModAPI] 플레이어 조회 실패:", error.message);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 주기적 데이터 갱신
|
||||
*/
|
||||
async function refreshData() {
|
||||
const [modStatus, motdData, players] = await Promise.all([
|
||||
fetchModStatus(),
|
||||
fetchMotd(),
|
||||
fetchAllPlayers(),
|
||||
]);
|
||||
|
||||
cachedStatus = formatStatusForClient(modStatus, motdData);
|
||||
cachedPlayers = players;
|
||||
|
||||
return { cachedStatus, cachedPlayers };
|
||||
}
|
||||
|
||||
const getCachedStatus = () => cachedStatus;
|
||||
const getCachedPlayers = () => cachedPlayers;
|
||||
|
||||
export {
|
||||
MOD_API_URL,
|
||||
fetchModStatus,
|
||||
fetchMotd,
|
||||
formatStatusForClient,
|
||||
fetchAllPlayers,
|
||||
fetchPlayerDetail,
|
||||
refreshData,
|
||||
getCachedStatus,
|
||||
getCachedPlayers,
|
||||
};
|
||||
113
backend/lib/s3.js
Normal file
113
backend/lib/s3.js
Normal file
|
|
@ -0,0 +1,113 @@
|
|||
import crypto from "crypto";
|
||||
import http from "http";
|
||||
|
||||
// S3 설정 (RustFS) - 환경변수에서 로드
|
||||
const s3Config = {
|
||||
endpoint: process.env.S3_ENDPOINT,
|
||||
accessKey: process.env.S3_ACCESS_KEY,
|
||||
secretKey: process.env.S3_SECRET_KEY,
|
||||
bucket: "minecraft",
|
||||
publicUrl: "https://s3.caadiq.co.kr",
|
||||
};
|
||||
|
||||
/**
|
||||
* AWS Signature V4 서명
|
||||
*/
|
||||
function signV4(method, path, headers, payload, accessKey, secretKey) {
|
||||
const service = "s3";
|
||||
const region = "us-east-1";
|
||||
const now = new Date();
|
||||
const amzDate = now.toISOString().replace(/[:-]|\.\d{3}/g, "");
|
||||
const dateStamp = amzDate.slice(0, 8);
|
||||
|
||||
headers["x-amz-date"] = amzDate;
|
||||
headers["x-amz-content-sha256"] = crypto
|
||||
.createHash("sha256")
|
||||
.update(payload)
|
||||
.digest("hex");
|
||||
|
||||
const signedHeaders = Object.keys(headers)
|
||||
.map((k) => k.toLowerCase())
|
||||
.sort()
|
||||
.join(";");
|
||||
const canonicalHeaders = Object.keys(headers)
|
||||
.sort((a, b) => a.toLowerCase().localeCompare(b.toLowerCase()))
|
||||
.map((k) => `${k.toLowerCase()}:${headers[k].trim()}`)
|
||||
.join("\n");
|
||||
|
||||
const canonicalRequest = [
|
||||
method,
|
||||
path,
|
||||
"",
|
||||
canonicalHeaders + "\n",
|
||||
signedHeaders,
|
||||
headers["x-amz-content-sha256"],
|
||||
].join("\n");
|
||||
const credentialScope = `${dateStamp}/${region}/${service}/aws4_request`;
|
||||
const stringToSign = [
|
||||
"AWS4-HMAC-SHA256",
|
||||
amzDate,
|
||||
credentialScope,
|
||||
crypto.createHash("sha256").update(canonicalRequest).digest("hex"),
|
||||
].join("\n");
|
||||
|
||||
const getSignatureKey = (key, ds, rg, sv) => {
|
||||
const kDate = crypto.createHmac("sha256", `AWS4${key}`).update(ds).digest();
|
||||
const kRegion = crypto.createHmac("sha256", kDate).update(rg).digest();
|
||||
const kService = crypto.createHmac("sha256", kRegion).update(sv).digest();
|
||||
return crypto
|
||||
.createHmac("sha256", kService)
|
||||
.update("aws4_request")
|
||||
.digest();
|
||||
};
|
||||
|
||||
const signingKey = getSignatureKey(secretKey, dateStamp, region, service);
|
||||
const signature = crypto
|
||||
.createHmac("sha256", signingKey)
|
||||
.update(stringToSign)
|
||||
.digest("hex");
|
||||
headers[
|
||||
"Authorization"
|
||||
] = `AWS4-HMAC-SHA256 Credential=${accessKey}/${credentialScope}, SignedHeaders=${signedHeaders}, Signature=${signature}`;
|
||||
return headers;
|
||||
}
|
||||
|
||||
/**
|
||||
* S3(RustFS)에 파일 업로드
|
||||
*/
|
||||
function uploadToS3(bucket, key, data) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const url = new URL(s3Config.endpoint);
|
||||
const path = `/${bucket}/${key}`;
|
||||
const headers = {
|
||||
Host: url.host,
|
||||
"Content-Type": "image/png",
|
||||
"Content-Length": data.length.toString(),
|
||||
};
|
||||
signV4("PUT", path, headers, data, s3Config.accessKey, s3Config.secretKey);
|
||||
|
||||
const options = {
|
||||
hostname: url.hostname,
|
||||
port: url.port || 80,
|
||||
path,
|
||||
method: "PUT",
|
||||
headers,
|
||||
};
|
||||
const req = http.request(options, (res) => {
|
||||
let body = "";
|
||||
res.on("data", (chunk) => (body += chunk));
|
||||
res.on("end", () => {
|
||||
if (res.statusCode >= 200 && res.statusCode < 300) {
|
||||
resolve(`${s3Config.publicUrl}/${bucket}/${key}`);
|
||||
} else {
|
||||
reject(new Error(`S3 Upload failed: ${res.statusCode}`));
|
||||
}
|
||||
});
|
||||
});
|
||||
req.on("error", reject);
|
||||
req.write(data);
|
||||
req.end();
|
||||
});
|
||||
}
|
||||
|
||||
export { s3Config, uploadToS3 };
|
||||
16
backend/package.json
Normal file
16
backend/package.json
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
{
|
||||
"name": "minecraft-backend",
|
||||
"private": true,
|
||||
"version": "1.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"start": "node server.js",
|
||||
"sync-icons": "node scripts/sync-icons.cjs"
|
||||
},
|
||||
"dependencies": {
|
||||
"express": "^4.18.2",
|
||||
"minecraft-server-util": "^5.3.0",
|
||||
"mysql2": "^3.11.0",
|
||||
"socket.io": "^4.8.1"
|
||||
}
|
||||
}
|
||||
113
backend/routes/api.js
Normal file
113
backend/routes/api.js
Normal file
|
|
@ -0,0 +1,113 @@
|
|||
import express from "express";
|
||||
import { getTranslations, getIcons, getGamerules } from "../lib/db.js";
|
||||
import { getIconUrl } from "../lib/icons.js";
|
||||
import {
|
||||
MOD_API_URL,
|
||||
fetchModStatus,
|
||||
fetchMotd,
|
||||
formatStatusForClient,
|
||||
fetchAllPlayers,
|
||||
fetchPlayerDetail,
|
||||
getCachedStatus,
|
||||
getCachedPlayers,
|
||||
} from "../lib/minecraft.js";
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
// 서버 상태 API
|
||||
router.get("/status", async (req, res) => {
|
||||
const cached = getCachedStatus();
|
||||
if (cached) {
|
||||
res.json(cached);
|
||||
} else {
|
||||
const [modStatus, motdData] = await Promise.all([
|
||||
fetchModStatus(),
|
||||
fetchMotd(),
|
||||
]);
|
||||
res.json(formatStatusForClient(modStatus, motdData));
|
||||
}
|
||||
});
|
||||
|
||||
// 플레이어 목록 API
|
||||
router.get("/players", async (req, res) => {
|
||||
const cached = getCachedPlayers();
|
||||
if (cached.length > 0) {
|
||||
res.json(cached);
|
||||
} else {
|
||||
const players = await fetchAllPlayers();
|
||||
res.json(players);
|
||||
}
|
||||
});
|
||||
|
||||
// 번역 데이터 API
|
||||
router.get("/translations", (req, res) => {
|
||||
res.json(getTranslations());
|
||||
});
|
||||
|
||||
// 아이콘 캐시 API
|
||||
router.get("/icons", (req, res) => {
|
||||
res.json(getIcons());
|
||||
});
|
||||
|
||||
// 온디맨드 아이콘 API
|
||||
router.get("/icon/:type/:name", async (req, res) => {
|
||||
const { type, name } = req.params;
|
||||
if (!["block", "item", "entity"].includes(type)) {
|
||||
return res.status(400).json({ error: "Invalid type" });
|
||||
}
|
||||
const iconUrl = await getIconUrl(name, type);
|
||||
if (iconUrl) {
|
||||
res.json({ icon: iconUrl });
|
||||
} else {
|
||||
res.status(404).json({ error: "Icon not found" });
|
||||
}
|
||||
});
|
||||
|
||||
// 게임룰 API
|
||||
router.get("/gamerules", (req, res) => {
|
||||
res.json(getGamerules());
|
||||
});
|
||||
|
||||
// 플레이어 통계 API
|
||||
router.get("/player/:uuid/stats", async (req, res) => {
|
||||
const { uuid } = req.params;
|
||||
try {
|
||||
const response = await fetch(`${MOD_API_URL}/player/${uuid}/stats`);
|
||||
if (response.ok) {
|
||||
res.json(await response.json());
|
||||
} else {
|
||||
res.status(404).json({ error: "통계를 불러올 수 없습니다" });
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("[ModAPI] 통계 조회 실패:", error.message);
|
||||
res.status(500).json({ error: "서버 오류" });
|
||||
}
|
||||
});
|
||||
|
||||
// 플레이어 상세 정보 API
|
||||
router.get("/player/:uuid", async (req, res) => {
|
||||
const { uuid } = req.params;
|
||||
const player = await fetchPlayerDetail(uuid);
|
||||
if (player) {
|
||||
res.json(player);
|
||||
} else {
|
||||
res.status(404).json({ error: "플레이어 데이터를 찾을 수 없습니다" });
|
||||
}
|
||||
});
|
||||
|
||||
// 월드 정보 API
|
||||
router.get("/worlds", async (req, res) => {
|
||||
try {
|
||||
const response = await fetch(`${MOD_API_URL}/worlds`);
|
||||
if (response.ok) {
|
||||
res.json(await response.json());
|
||||
} else {
|
||||
res.json({ worlds: [] });
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("[ModAPI] 월드 조회 실패:", error.message);
|
||||
res.json({ worlds: [] });
|
||||
}
|
||||
});
|
||||
|
||||
export default router;
|
||||
109
backend/server.js
Normal file
109
backend/server.js
Normal file
|
|
@ -0,0 +1,109 @@
|
|||
import express from "express";
|
||||
import { createServer } from "http";
|
||||
import { Server } from "socket.io";
|
||||
import path from "path";
|
||||
import { fileURLToPath } from "url";
|
||||
|
||||
// 모듈 import
|
||||
import { loadTranslations } from "./lib/db.js";
|
||||
import {
|
||||
MOD_API_URL,
|
||||
refreshData,
|
||||
fetchPlayerDetail,
|
||||
getCachedStatus,
|
||||
getCachedPlayers,
|
||||
} from "./lib/minecraft.js";
|
||||
import apiRoutes from "./routes/api.js";
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = path.dirname(__filename);
|
||||
|
||||
const app = express();
|
||||
const httpServer = createServer(app);
|
||||
const io = new Server(httpServer, {
|
||||
cors: {
|
||||
origin: "*",
|
||||
methods: ["GET", "POST"],
|
||||
},
|
||||
});
|
||||
|
||||
const PORT = process.env.PORT || 80;
|
||||
|
||||
// dist 디렉토리에서 정적 파일 제공
|
||||
app.use(express.static(path.join(__dirname, "dist")));
|
||||
|
||||
// API 라우트
|
||||
app.use("/api", apiRoutes);
|
||||
|
||||
// Socket.IO 연결 처리
|
||||
io.on("connection", (socket) => {
|
||||
console.log("클라이언트 연결됨:", socket.id);
|
||||
|
||||
// 연결 시 즉시 캐시된 데이터 전송
|
||||
const cachedStatus = getCachedStatus();
|
||||
const cachedPlayers = getCachedPlayers();
|
||||
|
||||
if (cachedStatus) socket.emit("status", cachedStatus);
|
||||
if (cachedPlayers.length > 0) socket.emit("players", cachedPlayers);
|
||||
|
||||
// 특정 플레이어 정보 요청 처리
|
||||
socket.on("get_player", async (uuid) => {
|
||||
const player = await fetchPlayerDetail(uuid);
|
||||
if (player) socket.emit("player_detail", player);
|
||||
});
|
||||
|
||||
// 월드 정보 요청 처리
|
||||
socket.on("get_worlds", async () => {
|
||||
try {
|
||||
const response = await fetch(`${MOD_API_URL}/worlds`);
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
socket.emit("worlds", data);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("[ModAPI] 월드 조회 실패:", error.message);
|
||||
}
|
||||
});
|
||||
|
||||
// 플레이어 통계 요청 처리
|
||||
socket.on("get_player_stats", async (uuid) => {
|
||||
try {
|
||||
const response = await fetch(`${MOD_API_URL}/player/${uuid}/stats`);
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
socket.emit("player_stats", data);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("[ModAPI] 통계 조회 실패:", error.message);
|
||||
}
|
||||
});
|
||||
|
||||
socket.on("disconnect", () => {
|
||||
console.log("클라이언트 연결 해제:", socket.id);
|
||||
});
|
||||
});
|
||||
|
||||
// 주기적 데이터 갱신 및 브로드캐스트
|
||||
async function refreshAndBroadcast() {
|
||||
const { cachedStatus, cachedPlayers } = await refreshData();
|
||||
io.emit("status", cachedStatus);
|
||||
io.emit("players", cachedPlayers);
|
||||
}
|
||||
|
||||
// 1초마다 데이터 갱신
|
||||
setInterval(refreshAndBroadcast, 1000);
|
||||
refreshAndBroadcast();
|
||||
|
||||
// SPA 라우팅 - 모든 경로에 대해 index.html 제공
|
||||
app.get("*", (req, res) => {
|
||||
res.sendFile(path.join(__dirname, "dist", "index.html"));
|
||||
});
|
||||
|
||||
// 서버 시작
|
||||
httpServer.listen(PORT, async () => {
|
||||
console.log(`[Server] 웹 서버 시작: 포트 ${PORT}`);
|
||||
console.log(`[Server] 모드 API URL: ${MOD_API_URL}`);
|
||||
|
||||
// 번역 데이터 로드
|
||||
await loadTranslations();
|
||||
});
|
||||
21
docker-compose.yml
Normal file
21
docker-compose.yml
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
services:
|
||||
minecraft-status:
|
||||
build: .
|
||||
container_name: minecraft-status
|
||||
labels:
|
||||
- "com.centurylinklabs.watchtower.enable=false"
|
||||
env_file:
|
||||
- .env
|
||||
networks:
|
||||
- minecraft
|
||||
- db
|
||||
- app
|
||||
restart: unless-stopped
|
||||
|
||||
networks:
|
||||
minecraft:
|
||||
external: true
|
||||
db:
|
||||
external: true
|
||||
app:
|
||||
external: true
|
||||
61
frontend/README.md
Normal file
61
frontend/README.md
Normal file
|
|
@ -0,0 +1,61 @@
|
|||
# 🎨 Minecraft Dashboard - Frontend
|
||||
|
||||
마인크래프트 대시보드의 웹 인터페이스입니다.
|
||||
|
||||

|
||||

|
||||

|
||||
|
||||
---
|
||||
|
||||
## 🛠️ 기술 스택
|
||||
|
||||
| 기술 | 설명 |
|
||||
| ----------------- | -------------- |
|
||||
| **React 18** | UI 라이브러리 |
|
||||
| **Vite** | 빌드 도구 |
|
||||
| **TailwindCSS** | CSS 프레임워크 |
|
||||
| **Socket.IO** | 실시간 통신 |
|
||||
| **skinview3d** | 3D 스킨 렌더링 |
|
||||
| **Framer Motion** | 애니메이션 |
|
||||
| **Lucide React** | 아이콘 |
|
||||
|
||||
---
|
||||
|
||||
## ✨ 주요 기능
|
||||
|
||||
- 🎴 **서버 상태 카드** - 온라인/오프라인 상태 표시
|
||||
- 👤 **플레이어 목록** - 접속 중인 플레이어 및 3D 스킨 뷰어
|
||||
- 🗺️ **월드 정보** - 서버 월드 목록 표시
|
||||
- 📱 **반응형 디자인** - 모바일/데스크톱 최적화
|
||||
- 🌙 **다크 테마** - 세련된 UI 디자인
|
||||
|
||||
---
|
||||
|
||||
## 🚀 실행 방법
|
||||
|
||||
### 개발 모드
|
||||
|
||||
```bash
|
||||
npm install
|
||||
npm run dev
|
||||
```
|
||||
|
||||
### 프로덕션 빌드
|
||||
|
||||
```bash
|
||||
npm run build
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📁 구조
|
||||
|
||||
```
|
||||
frontend/
|
||||
├── src/
|
||||
│ ├── components/ # UI 컴포넌트
|
||||
│ ├── pages/ # 페이지 컴포넌트
|
||||
│ └── utils/ # 유틸리티 함수
|
||||
└── public/ # 정적 파일
|
||||
```
|
||||
19
frontend/index.html
Normal file
19
frontend/index.html
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="ko">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" href="/favicon.ico" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>마인크래프트 서버 상태</title>
|
||||
<link
|
||||
rel="stylesheet"
|
||||
as="style"
|
||||
crossorigin
|
||||
href="https://cdn.jsdelivr.net/gh/orioncactus/pretendard@v1.3.9/dist/web/static/pretendard.min.css"
|
||||
/>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.jsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
38
frontend/package.json
Normal file
38
frontend/package.json
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
{
|
||||
"name": "minecraft-frontend",
|
||||
"private": true,
|
||||
"version": "1.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"lint": "eslint .",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"clsx": "^2.1.1",
|
||||
"framer-motion": "^11.0.8",
|
||||
"lucide-react": "^0.344.0",
|
||||
"react": "^18.2.0",
|
||||
"react-device-detect": "^2.2.3",
|
||||
"react-dom": "^18.2.0",
|
||||
"react-router-dom": "^6.22.3",
|
||||
"skinview3d": "^3.4.1",
|
||||
"socket.io-client": "^4.8.1",
|
||||
"tailwind-merge": "^2.2.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "^18.3.3",
|
||||
"@types/react-dom": "^18.3.0",
|
||||
"@vitejs/plugin-react": "^4.3.1",
|
||||
"autoprefixer": "^10.4.22",
|
||||
"eslint": "^9.9.0",
|
||||
"eslint-plugin-react": "^7.35.0",
|
||||
"eslint-plugin-react-hooks": "^5.1.0-rc.0",
|
||||
"eslint-plugin-react-refresh": "^0.4.9",
|
||||
"globals": "^15.9.0",
|
||||
"postcss": "^8.5.6",
|
||||
"tailwindcss": "^3.4.18",
|
||||
"vite": "^5.4.1"
|
||||
}
|
||||
}
|
||||
6
frontend/postcss.config.js
Normal file
6
frontend/postcss.config.js
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
export default {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
};
|
||||
9
frontend/public/default_icon.svg
Normal file
9
frontend/public/default_icon.svg
Normal file
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 278 KiB |
BIN
frontend/public/favicon.ico
Normal file
BIN
frontend/public/favicon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 19 KiB |
60
frontend/src/App.jsx
Normal file
60
frontend/src/App.jsx
Normal file
|
|
@ -0,0 +1,60 @@
|
|||
import React, { useEffect } from 'react';
|
||||
import { Routes, Route, useLocation } from 'react-router-dom';
|
||||
import { AnimatePresence, motion } from 'framer-motion';
|
||||
import DeviceLayout, { useIsMobile } from './components/DeviceLayout';
|
||||
import Sidebar from './components/Sidebar';
|
||||
import ServerDetail from './pages/ServerDetail';
|
||||
import WorldsPage from './pages/WorldsPage';
|
||||
import PlayersPage from './pages/PlayersPage';
|
||||
import PlayerStatsPage from './pages/PlayerStatsPage';
|
||||
import WorldMapPage from './pages/WorldMapPage';
|
||||
|
||||
// 페이지 전환 애니메이션 래퍼 컴포넌트
|
||||
const PageWrapper = ({ children }) => (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: -20 }}
|
||||
transition={{ duration: 0.2, ease: 'easeOut' }}
|
||||
>
|
||||
{children}
|
||||
</motion.div>
|
||||
);
|
||||
|
||||
function App() {
|
||||
const isMobile = useIsMobile();
|
||||
const location = useLocation();
|
||||
|
||||
// 라우트 전환 시 스크롤 맨 위로
|
||||
useEffect(() => {
|
||||
window.scrollTo(0, 0);
|
||||
}, [location.pathname]);
|
||||
|
||||
return (
|
||||
<DeviceLayout>
|
||||
<div className="min-h-screen bg-mc-bg text-gray-200 font-sans selection:bg-mc-green/30">
|
||||
<div className="absolute inset-0 bg-[url('https://www.transparenttextures.com/patterns/cubes.png')] opacity-[0.03] pointer-events-none"></div>
|
||||
|
||||
{/* 사이드바 + 메인 콘텐츠 레이아웃 */}
|
||||
<div className={`flex min-h-screen ${isMobile ? 'flex-col' : ''}`}>
|
||||
<Sidebar isMobile={isMobile} />
|
||||
|
||||
{/* 메인 콘텐츠 영역 */}
|
||||
<main className="flex-1 min-w-0 overflow-hidden">
|
||||
<AnimatePresence mode="wait">
|
||||
<Routes location={location} key={location.pathname}>
|
||||
<Route path="/" element={<PageWrapper><ServerDetail isMobile={isMobile} /></PageWrapper>} />
|
||||
<Route path="/worlds" element={<PageWrapper><WorldsPage 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="/worldmap" element={<PageWrapper><WorldMapPage isMobile={isMobile} /></PageWrapper>} />
|
||||
</Routes>
|
||||
</AnimatePresence>
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
</DeviceLayout>
|
||||
);
|
||||
}
|
||||
|
||||
export default App;
|
||||
36
frontend/src/components/DeviceLayout.jsx
Normal file
36
frontend/src/components/DeviceLayout.jsx
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
import React, { useEffect } from 'react';
|
||||
import { isMobile, isTablet } from 'react-device-detect';
|
||||
|
||||
// 기기 타입에 따른 레이아웃 래퍼 컴포넌트
|
||||
const DeviceLayout = ({ children }) => {
|
||||
// 모바일/태블릿 여부 (태블릿도 모바일로 취급)
|
||||
const isMobileDevice = isMobile || isTablet;
|
||||
|
||||
useEffect(() => {
|
||||
// body에 레이아웃 클래스 적용
|
||||
if (isMobileDevice) {
|
||||
document.body.classList.add('mobile-layout');
|
||||
document.body.classList.remove('desktop-layout');
|
||||
} else {
|
||||
document.body.classList.add('desktop-layout');
|
||||
document.body.classList.remove('mobile-layout');
|
||||
}
|
||||
|
||||
return () => {
|
||||
document.body.classList.remove('mobile-layout', 'desktop-layout');
|
||||
};
|
||||
}, [isMobileDevice]);
|
||||
|
||||
return (
|
||||
<div className={isMobileDevice ? 'mobile-view' : 'desktop-view'}>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// 훅: 모바일 여부 반환
|
||||
export const useIsMobile = () => {
|
||||
return isMobile || isTablet;
|
||||
};
|
||||
|
||||
export default DeviceLayout;
|
||||
184
frontend/src/components/Sidebar.jsx
Normal file
184
frontend/src/components/Sidebar.jsx
Normal file
|
|
@ -0,0 +1,184 @@
|
|||
import React, { useState } from 'react';
|
||||
import { NavLink, useLocation } from 'react-router-dom';
|
||||
import { Home, Globe, Users, Menu, X, Gamepad2, Map } from 'lucide-react';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
|
||||
// 사이드바 네비게이션 컴포넌트
|
||||
const Sidebar = ({ isMobile = false }) => {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const location = useLocation();
|
||||
|
||||
const menuItems = [
|
||||
{ path: '/', icon: Home, label: '홈' },
|
||||
{ path: '/players', icon: Users, label: '플레이어', matchPaths: ['/players', '/player/'] },
|
||||
{ path: '/worlds', icon: Globe, label: '월드 정보' },
|
||||
{ path: '/worldmap', icon: Map, label: '월드맵' },
|
||||
];
|
||||
|
||||
// 커스텀 활성 상태 확인 함수
|
||||
const isMenuActive = (item) => {
|
||||
if (item.matchPaths) {
|
||||
return item.matchPaths.some(path => location.pathname.startsWith(path));
|
||||
}
|
||||
return location.pathname === item.path;
|
||||
};
|
||||
|
||||
// 모바일: 상단 툴바 + 햄버거 메뉴 사이드바
|
||||
if (isMobile) {
|
||||
return (
|
||||
<>
|
||||
{/* 상단 툴바 */}
|
||||
<header className="fixed top-0 left-0 right-0 z-50 bg-mc-dark/95 backdrop-blur-xl border-b border-white/10 safe-area-top">
|
||||
<div className="flex items-center justify-between h-14 px-4">
|
||||
{/* 햄버거 메뉴 버튼 */}
|
||||
<button
|
||||
onClick={() => setIsOpen(true)}
|
||||
className="p-2 -ml-2 rounded-lg text-white hover:bg-white/10 transition-all"
|
||||
>
|
||||
<Menu size={24} />
|
||||
</button>
|
||||
|
||||
{/* 로고/타이틀 */}
|
||||
<div className="flex items-center gap-2">
|
||||
<Gamepad2 className="text-mc-green" size={20} />
|
||||
<span className="font-bold text-white">마인크래프트</span>
|
||||
</div>
|
||||
|
||||
{/* 우측 공간 (균형용) */}
|
||||
<div className="w-10" />
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* 상단 툴바 높이만큼 공간 확보 */}
|
||||
<div className="h-14" />
|
||||
|
||||
{/* 오버레이 */}
|
||||
<AnimatePresence>
|
||||
{isOpen && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
transition={{ duration: 0.2 }}
|
||||
className="fixed inset-0 bg-black/60 backdrop-blur-sm z-[60]"
|
||||
onClick={() => setIsOpen(false)}
|
||||
/>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
{/* 사이드바 (슬라이드) */}
|
||||
<AnimatePresence>
|
||||
{isOpen && (
|
||||
<motion.aside
|
||||
initial={{ x: -280 }}
|
||||
animate={{ x: 0 }}
|
||||
exit={{ x: -280 }}
|
||||
transition={{ type: 'tween', duration: 0.25, ease: 'easeOut' }}
|
||||
className="fixed left-0 top-0 bottom-0 w-72 bg-mc-dark/98 backdrop-blur-xl border-r border-white/10 z-[70] flex flex-col safe-area-left"
|
||||
>
|
||||
{/* 사이드바 헤더 */}
|
||||
<div className="flex items-center justify-between p-4 border-b border-white/10">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="p-2 rounded-xl bg-gradient-to-br from-mc-green/20 to-mc-emerald/20 border border-mc-green/30">
|
||||
<Gamepad2 className="text-mc-green" size={20} />
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-base font-bold text-white">마인크래프트</h1>
|
||||
<p className="text-xs text-gray-500">서버 대시보드</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setIsOpen(false)}
|
||||
className="p-2 rounded-lg text-gray-400 hover:text-white hover:bg-white/10 transition-all"
|
||||
>
|
||||
<X size={20} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* 메뉴 */}
|
||||
<nav className="flex-1 p-3 space-y-1">
|
||||
{menuItems.map((item) => {
|
||||
const active = isMenuActive(item);
|
||||
return (
|
||||
<NavLink
|
||||
key={item.path}
|
||||
to={item.path}
|
||||
onClick={() => setIsOpen(false)}
|
||||
className={`flex items-center gap-3 px-4 py-3 rounded-xl transition-colors outline-none focus:ring-0 border ${
|
||||
active
|
||||
? 'bg-mc-green/10 text-mc-green border-mc-green/20'
|
||||
: 'text-gray-400 hover:text-white hover:bg-white/5 border-transparent'
|
||||
}`}
|
||||
>
|
||||
<item.icon size={20} />
|
||||
<span className="font-medium">{item.label}</span>
|
||||
</NavLink>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
|
||||
|
||||
</motion.aside>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// PC: 기존 사이드바
|
||||
return (
|
||||
<>
|
||||
{/* 데스크탑 사이드바 (항상 표시) */}
|
||||
<aside className="hidden md:flex fixed left-0 top-0 bottom-0 w-64 bg-mc-dark/95 backdrop-blur-xl border-r border-white/10 z-30 flex-col">
|
||||
<SidebarContent menuItems={menuItems} isMenuActive={isMenuActive} />
|
||||
</aside>
|
||||
|
||||
{/* 데스크톱용 사이드바 spacer */}
|
||||
<div className="hidden md:block w-64 shrink-0" />
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
// 사이드바 내용 컴포넌트 (PC 전용)
|
||||
const SidebarContent = ({ menuItems, isMenuActive, onClose }) => (
|
||||
<>
|
||||
{/* 로고 */}
|
||||
<div className="p-6 border-b border-white/10">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="p-2 rounded-xl bg-gradient-to-br from-mc-green/20 to-mc-emerald/20 border border-mc-green/30">
|
||||
<Gamepad2 className="text-mc-green" size={24} />
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-lg font-bold text-white">마인크래프트</h1>
|
||||
<p className="text-xs text-gray-500">서버 대시보드</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 메뉴 */}
|
||||
<nav className="flex-1 p-4 space-y-2">
|
||||
{menuItems.map((item) => {
|
||||
const active = isMenuActive(item);
|
||||
return (
|
||||
<NavLink
|
||||
key={item.path}
|
||||
to={item.path}
|
||||
onClick={onClose}
|
||||
className={`flex items-center gap-3 px-4 py-3 rounded-xl transition-colors outline-none focus:ring-0 border ${
|
||||
active
|
||||
? 'bg-mc-green/10 text-mc-green border-mc-green/20'
|
||||
: 'text-gray-400 hover:text-white hover:bg-white/5 border-transparent'
|
||||
}`}
|
||||
>
|
||||
<item.icon size={20} />
|
||||
<span className="font-medium">{item.label}</span>
|
||||
</NavLink>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
|
||||
|
||||
</>
|
||||
);
|
||||
|
||||
export default Sidebar;
|
||||
86
frontend/src/components/SkeletonCard.jsx
Normal file
86
frontend/src/components/SkeletonCard.jsx
Normal file
|
|
@ -0,0 +1,86 @@
|
|||
import React from 'react';
|
||||
|
||||
// 로딩 중일 때 표시되는 스켈레톤 카드 컴포넌트
|
||||
// 다크 테마 shimmer 애니메이션 적용
|
||||
const SkeletonCard = () => {
|
||||
return (
|
||||
<div className="glass rounded-2xl overflow-hidden shadow-2xl shadow-black/50">
|
||||
{/* 서버 정보 배너 스켈레톤 */}
|
||||
<div className="h-44 md:h-56 relative overflow-hidden">
|
||||
{/* 배경 그라디언트 */}
|
||||
<div className="absolute inset-0 bg-gradient-to-br from-mc-gray/30 via-mc-gray/20 to-transparent" />
|
||||
|
||||
<div className="absolute bottom-0 left-0 p-6 md:p-8 w-full">
|
||||
<div className="flex items-end gap-4 md:gap-6">
|
||||
{/* 아이콘 스켈레톤 */}
|
||||
<div className="w-20 h-20 md:w-28 md:h-28 shimmer rounded-xl shrink-0" />
|
||||
<div className="flex-1 space-y-3">
|
||||
{/* 제목 스켈레톤 */}
|
||||
<div className="h-8 md:h-10 shimmer rounded-lg w-3/4" />
|
||||
{/* 상태 뱃지 스켈레톤 */}
|
||||
<div className="flex gap-2">
|
||||
<div className="h-7 md:h-9 shimmer rounded-lg w-24" />
|
||||
<div className="h-7 md:h-9 shimmer rounded-lg w-20" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="p-6 md:p-8 space-y-6 md:space-y-8">
|
||||
{/* 상단 통계 그리드 스켈레톤 */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 md:gap-5">
|
||||
{[1, 2, 3].map((i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="glow-card rounded-xl p-5 md:p-6"
|
||||
>
|
||||
<div className="flex items-center gap-3 mb-3">
|
||||
<div className="w-10 h-10 shimmer rounded-lg" />
|
||||
<div className="h-4 shimmer rounded w-16" />
|
||||
</div>
|
||||
<div className="h-7 shimmer rounded-lg w-2/3" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* 플레이어 목록 스켈레톤 */}
|
||||
<div className="glow-card rounded-xl p-5 md:p-6">
|
||||
<div className="flex items-center gap-3 mb-4">
|
||||
<div className="w-6 h-6 shimmer rounded" />
|
||||
<div className="h-6 shimmer rounded-lg w-48" />
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{[1, 2, 3, 4, 5].map((i) => (
|
||||
<div key={i} className="h-11 w-32 shimmer rounded-lg" />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 전체 플레이어 목록 스켈레톤 */}
|
||||
<div className="glow-card rounded-xl p-5 md:p-6">
|
||||
<div className="flex items-center gap-3 mb-4">
|
||||
<div className="w-6 h-6 shimmer rounded" />
|
||||
<div className="h-6 shimmer rounded-lg w-36" />
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{[1, 2, 3, 4, 5, 6, 7, 8].map((i) => (
|
||||
<div key={i} className="h-8 w-24 shimmer rounded-md" />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* MOTD 스켈레톤 */}
|
||||
<div className="glow-card rounded-xl p-5 md:p-6">
|
||||
<div className="flex items-center gap-3 mb-4">
|
||||
<div className="w-6 h-6 shimmer rounded" />
|
||||
<div className="h-6 shimmer rounded-lg w-40" />
|
||||
</div>
|
||||
<div className="h-20 shimmer rounded-lg w-full" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default SkeletonCard;
|
||||
123
frontend/src/components/SkinViewer.jsx
Normal file
123
frontend/src/components/SkinViewer.jsx
Normal file
|
|
@ -0,0 +1,123 @@
|
|||
import React, { useEffect, useRef } from 'react';
|
||||
import * as skinview3d from 'skinview3d';
|
||||
|
||||
const STEVE_SKIN_BASE64 = "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAEAAAABACAYAAACqaXHeAAAEwElEQVR4Xu1av2sUURgMqCABQQVBBK0SCdooMQQD5jSFkNgpKdIEwSZoZ6GYJohNUmlhqrSxsUlhYZM/If/TmdncrLOz3+7drcneXtyB4f36bvNm3vd2l32ZmOiD+3eudMHZqWtJyTrbn189LqVfb+xAwZ17N1LxqLsBH17MZniuDFDBXn5b6+TEkxjz640dXLSmP8r/wgDd+74VILJoC5wLA3TFNRsgHqRQv/mh7+v64vkwwO8BSoiE4KLSrzd2UNF+QwQpdnn6aobs9+s1DpraFDZ183Im5ZkFSjVBf6PX0WtEcSh9PrUjmiBIIVpX8V7yd5Foxvn10efzqR0qwifMvr03y91fn9a7v7+8S8qf79eSvkfT13OxINvs0zpNYJ/Pp3ZwQjpRXUkIhWAIp3gSY5Go6JqsM55tn0/t0ImxxCMM4iACQoGXT3ZS0agDaCMGffhNdC0aGhnUCAN8hShIyRV/+/RBQs0AJUX5yuv9QQ1B2+dTOzgRlFx5PsO/v15MX2jwaPu4NJdw6fZkJoaPPGaCXtMN8LbPp3bwrU3FuwkoIRomsKRoGqS/5fX0zfDurckM2e/zGTkWHu50lfPz8wlnZmYSenwOR0fp9lBj0Ycxv77TL5fDwUE34f5+Znt6WGX4hCicRnh8DsciVbyacNoGqNEeVhk+oSoZcNYGTB4epgY0MgPOdAscC9ct8M8Z8Gxurwvij6N8vvAjJftYkjp+cXc3Q0/PVHxv4kXxYKafQpW9uLS9tZWlxg4KChzUAB0HMdlL29sJU0E9oUxP74/iMyK9j/2RARsbJ6QBHB8UZQI9O3wchJALm5sJUdcURel9Hu8muFkZ8WJGMlZkADgoygQWGaB1iiH7CfL4UJi0c2mupHg1gRwULpDtqE/HSBeUWXEzQFc/MiC39/f/3uh4s9N6RI67zkK4Ab7CkSHa9pRODegJcQM83jMkFd9r66OON1Rtq+hKj8XIAN8GZeMURXoK66p6bBTvv3VhKj4ygKXrLASFFQnsZwAmqWKSDNAVZTb0BHl8aIDED2oAykoZ4OmtwpUeR1KQiqNwF8cYj8sYIOIR4yvrBqgRlQxYWVnpgn5zczLOqaIiM1Sgx6YGiGgVD6rgoizQLTC0AXi97XQ6GVGrq6s5oYjxWNR1i6hhmjk0gsI8RrePxiATIwM0K9wAlq6zEHzHpzgXSOFRHOpqAKnbiCVX1OP9XsMY9pcJ1/3vRrjOFi1atGjRosX4AR9Ai5h5fT44+ViajrVo0aJFixYtWtQN/5Q29OGqfELTDyEe1li4AUMfr4sBmZPlcYEbUCUD+JrLDBhrAypnwHFZ6airbujXX//I6WM6zpjcgaefEbDtbAoGEcls8DEwPOJWA9wEtpsCNwClG6Dj/QxID0vcCK83BS7QRfq49iUGlJ3xl5HQTOmZ498NnKdqoItnO+rTMdLP9/XG5wce2u/zGBmKBPtq67jGu1h99Hm7kY/FSGC/LaB0A4pEs5+lz2NkcIEoIwMic8DofM/Fq/DGZYCnv6+09kfbJNrnkXDv93mMDDxJpsAi+nE7WSY+Et44A3hcrqKG+f+CyAAV7Qaw7fMYGaL/G1CBFB7FoQ5BRfeByAiO+zyq4g8lK5z2I+oYkQAAAABJRU5ErkJggg==";
|
||||
|
||||
// 3D 스킨 뷰어 컴포넌트
|
||||
const SkinViewer = ({ skinUrl }) => {
|
||||
const containerRef = useRef(null);
|
||||
const canvasRef = useRef(null);
|
||||
const viewerRef = useRef(null);
|
||||
const isDraggingRef = useRef(false);
|
||||
const lastXRef = useRef(0);
|
||||
|
||||
useEffect(() => {
|
||||
if (!canvasRef.current || !containerRef.current) return;
|
||||
|
||||
const viewer = new skinview3d.SkinViewer({
|
||||
canvas: canvasRef.current,
|
||||
width: containerRef.current.clientWidth,
|
||||
height: containerRef.current.clientHeight,
|
||||
skin: STEVE_SKIN_BASE64,
|
||||
});
|
||||
|
||||
// 설정
|
||||
viewer.fov = 70;
|
||||
viewer.zoom = 0.9;
|
||||
viewer.autoRotate = false;
|
||||
viewer.animation = new skinview3d.IdleAnimation();
|
||||
viewer.playerObject.rotation.y = Math.PI / 6;
|
||||
viewer.playerObject.position.y = -0.5;
|
||||
|
||||
viewerRef.current = viewer;
|
||||
|
||||
// 수동 드래그 회전 구현
|
||||
const canvas = canvasRef.current;
|
||||
|
||||
const handleMouseDown = (e) => {
|
||||
isDraggingRef.current = true;
|
||||
lastXRef.current = e.clientX;
|
||||
canvas.style.cursor = 'grabbing';
|
||||
};
|
||||
|
||||
const handleMouseMove = (e) => {
|
||||
if (!isDraggingRef.current || !viewerRef.current) return;
|
||||
const deltaX = e.clientX - lastXRef.current;
|
||||
viewerRef.current.playerObject.rotation.y += deltaX * 0.01;
|
||||
lastXRef.current = e.clientX;
|
||||
};
|
||||
|
||||
const handleMouseUp = () => {
|
||||
isDraggingRef.current = false;
|
||||
canvas.style.cursor = 'grab';
|
||||
};
|
||||
|
||||
// 터치 이벤트 지원
|
||||
const handleTouchStart = (e) => {
|
||||
if (e.touches.length === 1) {
|
||||
isDraggingRef.current = true;
|
||||
lastXRef.current = e.touches[0].clientX;
|
||||
}
|
||||
};
|
||||
|
||||
const handleTouchMove = (e) => {
|
||||
if (!isDraggingRef.current || !viewerRef.current || e.touches.length !== 1) return;
|
||||
e.preventDefault();
|
||||
const deltaX = e.touches[0].clientX - lastXRef.current;
|
||||
viewerRef.current.playerObject.rotation.y += deltaX * 0.01;
|
||||
lastXRef.current = e.touches[0].clientX;
|
||||
};
|
||||
|
||||
const handleTouchEnd = () => {
|
||||
isDraggingRef.current = false;
|
||||
};
|
||||
|
||||
canvas.addEventListener('mousedown', handleMouseDown);
|
||||
window.addEventListener('mousemove', handleMouseMove);
|
||||
window.addEventListener('mouseup', handleMouseUp);
|
||||
canvas.addEventListener('touchstart', handleTouchStart, { passive: false });
|
||||
canvas.addEventListener('touchmove', handleTouchMove, { passive: false });
|
||||
canvas.addEventListener('touchend', handleTouchEnd);
|
||||
|
||||
// Resize Observer for responsive sizing
|
||||
const resizeObserver = new ResizeObserver(entries => {
|
||||
for (let entry of entries) {
|
||||
const { width, height } = entry.contentRect;
|
||||
viewer.setSize(width, height);
|
||||
}
|
||||
});
|
||||
resizeObserver.observe(containerRef.current);
|
||||
|
||||
return () => {
|
||||
resizeObserver.disconnect();
|
||||
canvas.removeEventListener('mousedown', handleMouseDown);
|
||||
window.removeEventListener('mousemove', handleMouseMove);
|
||||
window.removeEventListener('mouseup', handleMouseUp);
|
||||
canvas.removeEventListener('touchstart', handleTouchStart);
|
||||
canvas.removeEventListener('touchmove', handleTouchMove);
|
||||
canvas.removeEventListener('touchend', handleTouchEnd);
|
||||
viewer.dispose();
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (viewerRef.current) {
|
||||
// 로딩 상태를 보여주기 위해 스티브 스킨으로 초기화 (동기/즉시)
|
||||
viewerRef.current.loadSkin(STEVE_SKIN_BASE64);
|
||||
|
||||
const targetSkin = skinUrl || STEVE_SKIN_BASE64;
|
||||
if (targetSkin !== STEVE_SKIN_BASE64) {
|
||||
// 그 다음 실제 스킨 로드
|
||||
viewerRef.current.loadSkin(targetSkin);
|
||||
}
|
||||
}
|
||||
}, [skinUrl]);
|
||||
|
||||
return (
|
||||
<div ref={containerRef} className="w-full h-full flex items-center justify-center">
|
||||
<canvas ref={canvasRef} style={{ cursor: 'grab' }} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default SkinViewer;
|
||||
65
frontend/src/components/Tooltip.jsx
Normal file
65
frontend/src/components/Tooltip.jsx
Normal file
|
|
@ -0,0 +1,65 @@
|
|||
import React, { useState, useRef } from 'react';
|
||||
import ReactDOM from 'react-dom';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
|
||||
const Tooltip = ({ children, content, className = "" }) => {
|
||||
const [isVisible, setIsVisible] = useState(false);
|
||||
const [position, setPosition] = useState({ bottom: 0, left: 0 });
|
||||
const triggerRef = useRef(null);
|
||||
|
||||
const handleMouseEnter = (e) => {
|
||||
// 마우스 커서 위치를 기준으로 툴팁 위치 설정 (커서 위로)
|
||||
setPosition({
|
||||
bottom: window.innerHeight - e.clientY + 5,
|
||||
left: e.clientX
|
||||
});
|
||||
setIsVisible(true);
|
||||
};
|
||||
|
||||
const handleMouseMove = (e) => {
|
||||
// 마우스 이동 시 툴팁 위치 업데이트
|
||||
setPosition({
|
||||
bottom: window.innerHeight - e.clientY + 5,
|
||||
left: e.clientX
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
ref={triggerRef}
|
||||
className={`relative flex items-center ${className}`}
|
||||
onMouseEnter={handleMouseEnter}
|
||||
onMouseMove={handleMouseMove}
|
||||
onMouseLeave={() => setIsVisible(false)}
|
||||
onClick={() => setIsVisible(!isVisible)}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
{isVisible && ReactDOM.createPortal(
|
||||
<AnimatePresence>
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 5, scale: 0.95 }}
|
||||
animate={{ opacity: 1, y: 0, scale: 1 }}
|
||||
exit={{ opacity: 0, y: 5, scale: 0.95 }}
|
||||
transition={{ duration: 0.15 }}
|
||||
style={{
|
||||
bottom: position.bottom,
|
||||
left: position.left,
|
||||
}}
|
||||
className="fixed z-[9999] -translate-x-1/2 px-3 py-2 bg-black/90 text-white text-xs rounded-md shadow-xl border border-white/10 max-w-[200px] pointer-events-none"
|
||||
>
|
||||
<div style={{ wordBreak: 'keep-all' }}>{content}</div>
|
||||
<div className="absolute top-full left-1/2 -translate-x-1/2 -mt-1 border-4 border-transparent border-t-black/90"></div>
|
||||
</motion.div>
|
||||
</AnimatePresence>,
|
||||
document.body
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default Tooltip;
|
||||
|
||||
|
||||
|
||||
223
frontend/src/index.css
Normal file
223
frontend/src/index.css
Normal file
|
|
@ -0,0 +1,223 @@
|
|||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
/* 기본 body 스타일 - 다크 배경 (약간 밝게) */
|
||||
body {
|
||||
background: linear-gradient(135deg, #141414 0%, #181a18 50%, #141414 100%);
|
||||
background-attachment: fixed;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
/* PC 레이아웃 - min-width 적용 */
|
||||
body.desktop-layout {
|
||||
min-width: 1400px;
|
||||
}
|
||||
|
||||
/* 모바일 레이아웃 */
|
||||
body.mobile-layout {
|
||||
min-width: unset;
|
||||
width: 100%;
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
@layer utilities {
|
||||
/* 텍스트 그림자 */
|
||||
.text-shadow {
|
||||
text-shadow: 2px 2px 0px rgba(0, 0, 0, 0.5);
|
||||
}
|
||||
.text-shadow-sm {
|
||||
text-shadow: 1px 1px 0px rgba(0, 0, 0, 0.5);
|
||||
}
|
||||
.text-shadow-lg {
|
||||
text-shadow: 3px 3px 0px rgba(0, 0, 0, 0.5);
|
||||
}
|
||||
|
||||
/* 그라디언트 텍스트 */
|
||||
.text-gradient {
|
||||
background: linear-gradient(135deg, #4ade80 0%, #22d3ee 100%);
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
background-clip: text;
|
||||
}
|
||||
|
||||
/* 글래스모피즘 (다크 테마) */
|
||||
.glass {
|
||||
background: rgba(28, 28, 28, 0.8);
|
||||
backdrop-filter: blur(12px);
|
||||
-webkit-backdrop-filter: blur(12px);
|
||||
border: 1px solid rgba(74, 222, 128, 0.1);
|
||||
}
|
||||
|
||||
.glass-dark {
|
||||
background: rgba(20, 20, 20, 0.9);
|
||||
backdrop-filter: blur(16px);
|
||||
-webkit-backdrop-filter: blur(16px);
|
||||
border: 1px solid rgba(255, 255, 255, 0.05);
|
||||
}
|
||||
|
||||
/* 글로우 카드 효과 */
|
||||
.glow-card {
|
||||
position: relative;
|
||||
background: linear-gradient(
|
||||
135deg,
|
||||
rgba(28, 28, 28, 0.95) 0%,
|
||||
rgba(24, 24, 24, 0.95) 100%
|
||||
);
|
||||
border: 1px solid rgba(74, 222, 128, 0.15);
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.glow-card::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
inset: -1px;
|
||||
background: linear-gradient(
|
||||
135deg,
|
||||
rgba(74, 222, 128, 0.2) 0%,
|
||||
rgba(34, 211, 238, 0.2) 100%
|
||||
);
|
||||
border-radius: inherit;
|
||||
z-index: -1;
|
||||
opacity: 0;
|
||||
transition: opacity 0.3s ease;
|
||||
}
|
||||
|
||||
.glow-card:hover::before {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.glow-card:hover {
|
||||
border-color: rgba(74, 222, 128, 0.4);
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 10px 40px rgba(74, 222, 128, 0.15);
|
||||
}
|
||||
|
||||
/* Shimmer 효과 */
|
||||
.shimmer {
|
||||
background: linear-gradient(
|
||||
90deg,
|
||||
rgba(37, 37, 37, 0.5) 25%,
|
||||
rgba(74, 222, 128, 0.1) 50%,
|
||||
rgba(37, 37, 37, 0.5) 75%
|
||||
);
|
||||
background-size: 200% 100%;
|
||||
animation: shimmer 2s infinite linear;
|
||||
}
|
||||
|
||||
/* 아이콘 글로우 */
|
||||
.icon-glow {
|
||||
filter: drop-shadow(0 0 8px rgba(74, 222, 128, 0.5));
|
||||
}
|
||||
|
||||
/* 호버 리프트 효과 */
|
||||
.hover-lift {
|
||||
transition: transform 0.2s ease, box-shadow 0.2s ease;
|
||||
}
|
||||
|
||||
.hover-lift:hover {
|
||||
transform: translateY(-4px);
|
||||
box-shadow: 0 12px 24px rgba(0, 0, 0, 0.4);
|
||||
}
|
||||
|
||||
/* 버튼 글로우 */
|
||||
.btn-glow {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.btn-glow::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: linear-gradient(
|
||||
135deg,
|
||||
transparent 0%,
|
||||
rgba(255, 255, 255, 0.1) 50%,
|
||||
transparent 100%
|
||||
);
|
||||
transform: translateX(-100%);
|
||||
transition: transform 0.3s ease;
|
||||
}
|
||||
|
||||
.btn-glow:hover::after {
|
||||
transform: translateX(100%);
|
||||
}
|
||||
}
|
||||
|
||||
/* 온라인 상태 표시기 펄스 */
|
||||
.online-pulse {
|
||||
animation: pulse-ring 1.5s cubic-bezier(0.455, 0.03, 0.515, 0.955) infinite;
|
||||
}
|
||||
|
||||
@keyframes pulse-ring {
|
||||
0% {
|
||||
box-shadow: 0 0 0 0 rgba(74, 222, 128, 0.6);
|
||||
}
|
||||
70% {
|
||||
box-shadow: 0 0 0 8px rgba(74, 222, 128, 0);
|
||||
}
|
||||
100% {
|
||||
box-shadow: 0 0 0 0 rgba(74, 222, 128, 0);
|
||||
}
|
||||
}
|
||||
|
||||
/* 배경 파티클 효과용 */
|
||||
.particles-bg {
|
||||
background-image: radial-gradient(
|
||||
circle at 20% 80%,
|
||||
rgba(74, 222, 128, 0.04) 0%,
|
||||
transparent 50%
|
||||
),
|
||||
radial-gradient(
|
||||
circle at 80% 20%,
|
||||
rgba(34, 211, 238, 0.04) 0%,
|
||||
transparent 50%
|
||||
),
|
||||
radial-gradient(
|
||||
circle at 40% 40%,
|
||||
rgba(74, 222, 128, 0.03) 0%,
|
||||
transparent 30%
|
||||
);
|
||||
}
|
||||
|
||||
/* 커스텀 스크롤바 */
|
||||
.custom-scrollbar::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
}
|
||||
|
||||
.custom-scrollbar::-webkit-scrollbar-track {
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.custom-scrollbar::-webkit-scrollbar-thumb {
|
||||
background: rgba(74, 222, 128, 0.3);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.custom-scrollbar::-webkit-scrollbar-thumb:hover {
|
||||
background: rgba(74, 222, 128, 0.5);
|
||||
}
|
||||
|
||||
/* 픽셀 이미지 렌더링 */
|
||||
.pixelated {
|
||||
image-rendering: pixelated;
|
||||
image-rendering: -moz-crisp-edges;
|
||||
image-rendering: crisp-edges;
|
||||
}
|
||||
|
||||
/* Details 펼치기/접기 애니메이션 */
|
||||
details .details-content {
|
||||
display: grid;
|
||||
grid-template-rows: 0fr;
|
||||
transition: grid-template-rows 0.3s ease-out;
|
||||
}
|
||||
|
||||
details[open] .details-content {
|
||||
grid-template-rows: 1fr;
|
||||
}
|
||||
|
||||
details .details-content > div {
|
||||
overflow: hidden;
|
||||
}
|
||||
13
frontend/src/main.jsx
Normal file
13
frontend/src/main.jsx
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
import React from 'react'
|
||||
import ReactDOM from 'react-dom/client'
|
||||
import App from './App.jsx'
|
||||
import './index.css'
|
||||
import { BrowserRouter } from 'react-router-dom'
|
||||
|
||||
ReactDOM.createRoot(document.getElementById('root')).render(
|
||||
<React.StrictMode>
|
||||
<BrowserRouter>
|
||||
<App />
|
||||
</BrowserRouter>
|
||||
</React.StrictMode>,
|
||||
)
|
||||
442
frontend/src/pages/PlayerStatsPage.jsx
Normal file
442
frontend/src/pages/PlayerStatsPage.jsx
Normal file
File diff suppressed because one or more lines are too long
182
frontend/src/pages/PlayersPage.jsx
Normal file
182
frontend/src/pages/PlayersPage.jsx
Normal file
|
|
@ -0,0 +1,182 @@
|
|||
import React, { useState, useEffect, useRef } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { Users, Clock, Circle, ServerOff } from 'lucide-react';
|
||||
import { motion } from 'framer-motion';
|
||||
import { io } from 'socket.io-client';
|
||||
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 PlayerAvatar = ({ uuid, name }) => {
|
||||
const [src, setSrc] = useState(STEVE_HEAD_BASE64);
|
||||
|
||||
useEffect(() => {
|
||||
const img = new Image();
|
||||
const realUrl = `https://mc-heads.net/avatar/${uuid}/48`;
|
||||
img.onload = () => setSrc(realUrl);
|
||||
img.src = realUrl;
|
||||
}, [uuid]);
|
||||
|
||||
return (
|
||||
<img
|
||||
src={src}
|
||||
alt={name}
|
||||
className="w-12 h-12 rounded-lg"
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
// 전체 플레이어 목록 페이지
|
||||
const PlayersPage = ({ isMobile = false }) => {
|
||||
const [players, setPlayers] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [serverOnline, setServerOnline] = useState(null);
|
||||
const socketRef = useRef(null);
|
||||
|
||||
useEffect(() => {
|
||||
// 소켓 연결
|
||||
const socket = io('/', {
|
||||
path: '/socket.io',
|
||||
transports: ['websocket', 'polling']
|
||||
});
|
||||
socketRef.current = socket;
|
||||
|
||||
socket.on('status', (data) => {
|
||||
setServerOnline(data.online);
|
||||
if (!data.online) {
|
||||
setLoading(false);
|
||||
}
|
||||
});
|
||||
|
||||
socket.on('players', (data) => {
|
||||
setPlayers(data);
|
||||
setLoading(false);
|
||||
});
|
||||
|
||||
// 1초마다 갱신
|
||||
const interval = setInterval(() => {
|
||||
socket.emit('get_players');
|
||||
}, 1000);
|
||||
|
||||
// 초기 요청
|
||||
socket.emit('get_players');
|
||||
|
||||
return () => {
|
||||
clearInterval(interval);
|
||||
socket.disconnect();
|
||||
};
|
||||
}, []);
|
||||
|
||||
// 온라인 플레이어 먼저, 그 다음 이름순 정렬
|
||||
const sortedPlayers = [...players].sort((a, b) => {
|
||||
if (a.isOnline !== b.isOnline) return b.isOnline ? 1 : -1;
|
||||
return a.name.localeCompare(b.name, 'ko');
|
||||
});
|
||||
|
||||
const onlineCount = players.filter(p => p.isOnline).length;
|
||||
|
||||
// 서버 오프라인 상태 - 전체 화면 가운데 정렬
|
||||
if (serverOnline === false) {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center">
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
className="text-center p-8"
|
||||
>
|
||||
<div className="w-20 h-20 mx-auto mb-6 rounded-2xl bg-gradient-to-br from-red-500/20 to-orange-500/20 border border-red-500/30 flex items-center justify-center">
|
||||
<ServerOff className="w-10 h-10 text-red-400" />
|
||||
</div>
|
||||
<h2 className="text-2xl font-bold text-white mb-2">서버 오프라인</h2>
|
||||
<p className="text-gray-400">
|
||||
마인크래프트 서버가 현재 오프라인 상태입니다.<br />
|
||||
서버가 시작되면 플레이어 정보를 확인할 수 있습니다.
|
||||
</p>
|
||||
</motion.div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen p-4 md:p-6 lg:p-8">
|
||||
<div className="max-w-4xl mx-auto">
|
||||
{/* 헤더 */}
|
||||
<div className="mb-8">
|
||||
<h1 className="text-3xl md:text-4xl font-bold text-white flex items-center gap-3">
|
||||
<Users className="text-mc-diamond" />
|
||||
플레이어
|
||||
</h1>
|
||||
<p className="text-gray-400 mt-2">
|
||||
전체 플레이어 <span className="text-white font-bold">{players.length}</span>명
|
||||
<span className="mx-2">•</span>
|
||||
<span className="text-mc-green">온라인 {onlineCount}명</span>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* 플레이어 목록 */}
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center py-20">
|
||||
<div className="w-12 h-12 border-4 border-mc-green/30 border-t-mc-green rounded-full animate-spin" />
|
||||
</div>
|
||||
) : sortedPlayers.length > 0 ? (
|
||||
<div className="grid gap-3">
|
||||
{sortedPlayers.map((player, index) => (
|
||||
<motion.div
|
||||
key={player.uuid}
|
||||
initial={{ opacity: 0, x: -20 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
transition={{ delay: index * 0.03 }}
|
||||
>
|
||||
<Link
|
||||
to={`/player/${player.uuid}/stats`}
|
||||
className="glow-card rounded-xl p-4 flex items-center gap-4 group hover:bg-white/5 transition-colors"
|
||||
>
|
||||
{/* 플레이어 아바타 */}
|
||||
<div className="relative shrink-0">
|
||||
<PlayerAvatar uuid={player.uuid} name={player.name} />
|
||||
{/* 온라인 상태 표시 */}
|
||||
<div className={`absolute -bottom-1 -right-1 w-4 h-4 rounded-full border-2 border-mc-dark ${
|
||||
player.isOnline ? 'bg-mc-green' : 'bg-gray-500'
|
||||
}`} />
|
||||
</div>
|
||||
|
||||
{/* 플레이어 정보 */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<h3 className="text-white font-bold truncate">{player.name}</h3>
|
||||
<span className={`text-xs px-2 py-0.5 rounded-full ${
|
||||
player.isOnline
|
||||
? 'bg-mc-green/20 text-mc-green'
|
||||
: 'bg-gray-500/20 text-gray-400'
|
||||
}`}>
|
||||
{player.isOnline ? '온라인' : '오프라인'}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1 text-gray-400 text-sm mt-1">
|
||||
<Clock size={14} />
|
||||
<span>{formatPlayTimeMs(player.totalPlayTimeMs)}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 통계 보기 안내 */}
|
||||
<div className="text-gray-500 group-hover:text-mc-green transition-colors">
|
||||
<span className="text-sm hidden md:block">통계 보기 →</span>
|
||||
</div>
|
||||
</Link>
|
||||
</motion.div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-center py-20 text-gray-400">
|
||||
<Users size={48} className="mx-auto mb-4 opacity-50" />
|
||||
<p>등록된 플레이어가 없습니다.</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default PlayersPage;
|
||||
461
frontend/src/pages/ServerDetail.jsx
Normal file
461
frontend/src/pages/ServerDetail.jsx
Normal file
|
|
@ -0,0 +1,461 @@
|
|||
import React, { useState, useEffect, useCallback } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { Users, Cpu, Activity, MessageSquare, Swords, ChevronDown, Blocks } from 'lucide-react';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import SkeletonCard from '../components/SkeletonCard';
|
||||
import Tooltip from '../components/Tooltip';
|
||||
|
||||
import { io } from 'socket.io-client';
|
||||
|
||||
const ServerDetail = ({ isMobile = false }) => {
|
||||
const [server, setServer] = useState(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [gameRuleDescriptions, setGameRuleDescriptions] = useState({});
|
||||
const [isGameRulesOpen, setIsGameRulesOpen] = useState(false);
|
||||
const [isModsOpen, setIsModsOpen] = useState(false);
|
||||
|
||||
// 서버 상태 업데이트 함수
|
||||
const updateServerState = useCallback((data) => {
|
||||
setServer({
|
||||
id: 'proxy',
|
||||
name: '마인크래프트 서버',
|
||||
icon: data.icon || '/default_icon.svg',
|
||||
status: data.online ? 'online' : 'offline',
|
||||
players: {
|
||||
current: data.players.current,
|
||||
max: data.players.max
|
||||
},
|
||||
version: data.version,
|
||||
modLoader: data.modLoader,
|
||||
motd: data.motd,
|
||||
uptime: data.uptime,
|
||||
playerList: data.players.list || [],
|
||||
mods: data.mods || [],
|
||||
difficulty: data.difficulty,
|
||||
gameRules: data.gameRules
|
||||
});
|
||||
setLoading(false);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
// 소켓 연결
|
||||
const socket = io('/', {
|
||||
path: '/socket.io',
|
||||
transports: ['websocket', 'polling']
|
||||
});
|
||||
|
||||
socket.on('connect', () => {
|
||||
console.log('서버 소켓 연결 성공');
|
||||
});
|
||||
|
||||
socket.on('status', (data) => {
|
||||
updateServerState(data);
|
||||
});
|
||||
|
||||
socket.on('disconnect', () => {
|
||||
console.log('서버 소켓 연결 해제');
|
||||
});
|
||||
|
||||
return () => {
|
||||
socket.disconnect();
|
||||
};
|
||||
}, [updateServerState]);
|
||||
|
||||
// 게임 규칙 설명 데이터 로드
|
||||
useEffect(() => {
|
||||
fetch('/api/gamerules')
|
||||
.then(res => res.json())
|
||||
.then(data => setGameRuleDescriptions(data))
|
||||
.catch(err => console.error('게임 규칙 로드 실패:', err));
|
||||
}, []);
|
||||
|
||||
// 서버가 없고 로딩도 아닌 경우 (에러 상태)
|
||||
if (!server && !loading) {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center particles-bg">
|
||||
<div className="text-center glass p-8 rounded-2xl">
|
||||
<h2 className="text-2xl font-bold text-white mb-4">서버를 찾을 수 없습니다</h2>
|
||||
<Link
|
||||
to="/"
|
||||
className="inline-flex items-center gap-2 px-6 py-3 bg-gradient-to-r from-mc-green to-mc-emerald text-black font-bold rounded-lg hover:shadow-glow transition-all"
|
||||
>
|
||||
홈으로 돌아가기
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen particles-bg">
|
||||
<div className="container mx-auto px-4 py-8 md:py-12 max-w-4xl min-w-[360px]">
|
||||
{/* 헤더 영역 - "실시간 업데이트" 뱃지 제거됨 */}
|
||||
<motion.header
|
||||
initial={{ opacity: 0, y: -20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.6 }}
|
||||
className="mb-8 md:mb-12 text-center space-y-3 md:space-y-4"
|
||||
>
|
||||
<h1 className="text-4xl md:text-6xl font-black tracking-tight">
|
||||
<span className="text-white">마인크래프트 </span>
|
||||
<span className="text-gradient">서버 상태</span>
|
||||
</h1>
|
||||
<p className="text-gray-400 text-sm md:text-lg max-w-md mx-auto">
|
||||
서버의 현재 상태를 실시간으로 확인하세요
|
||||
</p>
|
||||
</motion.header>
|
||||
|
||||
{loading ? (
|
||||
<SkeletonCard />
|
||||
) : (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.5, delay: 0.2 }}
|
||||
className="glass rounded-2xl overflow-hidden shadow-2xl shadow-black/50"
|
||||
>
|
||||
{/* 서버 정보 배너 */}
|
||||
<div className="relative h-44 md:h-56 overflow-hidden">
|
||||
{/* 배경 그라디언트 */}
|
||||
<div className="absolute inset-0 bg-gradient-to-br from-mc-green/20 via-mc-emerald/10 to-transparent" />
|
||||
<div className="absolute inset-0 bg-[url('https://www.transparenttextures.com/patterns/cubes.png')] opacity-5" />
|
||||
|
||||
{/* 빛나는 원형 효과 */}
|
||||
<div className="absolute -top-20 -right-20 w-64 h-64 bg-mc-green/10 rounded-full blur-3xl" />
|
||||
<div className="absolute -bottom-10 -left-10 w-48 h-48 bg-mc-diamond/10 rounded-full blur-3xl" />
|
||||
|
||||
{/* 서버 정보 */}
|
||||
<div className="absolute bottom-0 left-0 right-0 p-6 md:p-8">
|
||||
<div className="flex items-end gap-4 md:gap-6">
|
||||
{/* 서버 아이콘 */}
|
||||
<motion.div
|
||||
whileHover={{ scale: 1.05 }}
|
||||
className="w-20 h-20 md:w-28 md:h-28 rounded-xl overflow-hidden border-2 border-white/10 shadow-xl shadow-black/50 bg-mc-gray shrink-0"
|
||||
>
|
||||
<img
|
||||
src={server.icon || '/default_icon.svg'}
|
||||
alt="Server Icon"
|
||||
className="w-full h-full object-cover"
|
||||
onError={(e) => {
|
||||
e.target.onerror = null;
|
||||
e.target.src = '/default_icon.svg';
|
||||
}}
|
||||
/>
|
||||
</motion.div>
|
||||
|
||||
{/* 서버 이름 및 상태 */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<h1 className="text-2xl md:text-4xl font-bold text-white mb-2 md:mb-3 truncate">
|
||||
{server.name}
|
||||
</h1>
|
||||
<div className="flex flex-wrap items-center gap-2 md:gap-3">
|
||||
{/* 온라인/오프라인 상태 */}
|
||||
<span className={`
|
||||
inline-flex items-center gap-2 px-3 py-1.5 rounded-lg text-xs md:text-sm font-semibold
|
||||
${server.status === 'online'
|
||||
? 'bg-mc-green/20 text-mc-green border border-mc-green/30'
|
||||
: 'bg-red-500/20 text-red-400 border border-red-500/30'}
|
||||
`}>
|
||||
<span className={`w-2 h-2 rounded-full ${server.status === 'online' ? 'bg-mc-green online-pulse' : 'bg-red-500'}`} />
|
||||
{server.status === 'online' ? '온라인' : '오프라인'}
|
||||
</span>
|
||||
|
||||
{/* 플레이어 수 */}
|
||||
<span className="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-xs md:text-sm font-medium bg-white/5 text-gray-300 border border-white/10">
|
||||
<Users size={14} />
|
||||
<span className="text-mc-green font-bold">{server.players.current}</span>
|
||||
<span className="text-gray-500">/</span>
|
||||
<span>{server.players.max}</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 메인 콘텐츠 */}
|
||||
<div className="p-6 md:p-8 space-y-6 md:space-y-8">
|
||||
{/* 통계 카드 그리드 */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 md:gap-5">
|
||||
{/* 버전 카드 */}
|
||||
<div className="glow-card rounded-xl p-5 md:p-6 transition-colors hover:bg-white/5">
|
||||
<div className="flex items-center gap-3 mb-3 text-gray-400">
|
||||
<div className="p-2 rounded-lg bg-mc-green/10">
|
||||
<Cpu size={18} className="text-mc-green icon-glow" />
|
||||
</div>
|
||||
<span className="text-sm font-medium">버전</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<span className="text-xl md:text-2xl font-bold text-white">{server.version}</span>
|
||||
{server.modLoader && (
|
||||
<span className="text-xs px-2 py-1 bg-gradient-to-r from-mc-green/20 to-mc-emerald/20 text-mc-green rounded-md border border-mc-green/20 font-semibold">
|
||||
{server.modLoader}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 난이도 카드 */}
|
||||
<div className="glow-card rounded-xl p-5 md:p-6 transition-colors hover:bg-white/5">
|
||||
<div className="flex items-center gap-3 mb-3 text-gray-400">
|
||||
<div className="p-2 rounded-lg bg-mc-gold/10">
|
||||
<Swords size={18} className="text-mc-gold" style={{ filter: 'drop-shadow(0 0 8px rgba(251, 191, 36, 0.5))' }} />
|
||||
</div>
|
||||
<span className="text-sm font-medium">난이도</span>
|
||||
</div>
|
||||
<span className="text-xl md:text-2xl font-bold text-white">
|
||||
{server.difficulty || "알 수 없음"}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* 가동 시간 카드 */}
|
||||
<div className="glow-card rounded-xl p-5 md:p-6 transition-colors hover:bg-white/5">
|
||||
<div className="flex items-center gap-3 mb-3 text-gray-400">
|
||||
<div className="p-2 rounded-lg bg-mc-diamond/10">
|
||||
<Activity size={18} className="text-mc-diamond" style={{ filter: 'drop-shadow(0 0 8px rgba(34, 211, 238, 0.5))' }} />
|
||||
</div>
|
||||
<span className="text-sm font-medium">가동 시간</span>
|
||||
</div>
|
||||
<span className="text-xl md:text-2xl font-bold text-white">
|
||||
{server.uptime}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 접속 중인 플레이어 */}
|
||||
<div className="glow-card rounded-xl p-5 md:p-6">
|
||||
<h3 className="text-lg font-semibold text-white mb-4 flex items-center gap-2">
|
||||
<Users size={20} className="text-mc-green icon-glow" />
|
||||
접속 중인 플레이어
|
||||
<span className="text-sm font-normal text-gray-500">
|
||||
({server.players.current}/{server.players.max})
|
||||
</span>
|
||||
</h3>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{server.playerList.length > 0 ? (
|
||||
server.playerList
|
||||
.sort((a, b) => {
|
||||
if (a.isOp && !b.isOp) return -1;
|
||||
if (!a.isOp && b.isOp) return 1;
|
||||
return a.name.localeCompare(b.name);
|
||||
})
|
||||
.map((player, i) => (
|
||||
<Link
|
||||
key={i}
|
||||
to={`/player/${player.uuid}/stats`}
|
||||
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">
|
||||
<img
|
||||
src={`https://minotar.net/helm/${player.name}/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" />
|
||||
</div>
|
||||
<span className="text-gray-300 text-sm font-medium group-hover:text-white transition-colors">
|
||||
{player.name}
|
||||
</span>
|
||||
{player.isOp && (
|
||||
<span className="bg-gradient-to-r from-red-500/20 to-orange-500/20 text-red-400 text-xs px-1.5 py-0.5 rounded border border-red-500/30 font-bold">
|
||||
OP
|
||||
</span>
|
||||
)}
|
||||
</Link>
|
||||
))
|
||||
) : (
|
||||
<div className="flex items-center gap-3 text-gray-500 italic py-4">
|
||||
<Users size={20} className="opacity-50" />
|
||||
<span>접속 중인 플레이어가 없습니다.</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* MOTD (서버 메시지) */}
|
||||
{server.motd && (
|
||||
<div className="glow-card rounded-xl p-5 md:p-6">
|
||||
<h3 className="text-lg font-semibold text-white mb-4 flex items-center gap-2">
|
||||
<MessageSquare size={20} className="text-mc-diamond" style={{ filter: 'drop-shadow(0 0 8px rgba(34, 211, 238, 0.5))' }} />
|
||||
서버 메시지 (MOTD)
|
||||
</h3>
|
||||
<div
|
||||
className="font-minecraft text-lg leading-relaxed bg-black/30 p-4 rounded-lg border border-white/5 whitespace-pre-wrap"
|
||||
dangerouslySetInnerHTML={{ __html: server.motd }}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 게임 규칙 */}
|
||||
{server.gameRules && Object.keys(server.gameRules).length > 0 && (
|
||||
<div className="glow-card rounded-xl overflow-hidden">
|
||||
<button
|
||||
onClick={(e) => {
|
||||
const willOpen = !isGameRulesOpen;
|
||||
const container = e.currentTarget.parentElement;
|
||||
setIsGameRulesOpen(willOpen);
|
||||
if (willOpen) {
|
||||
if (isMobile) {
|
||||
// 모바일: 즉시 맨 아래로 스크롤
|
||||
setTimeout(() => container.scrollIntoView({ behavior: 'smooth', block: 'end' }), 50);
|
||||
} else {
|
||||
// PC: 애니메이션 후 상단 스크롤
|
||||
setTimeout(() => container.scrollIntoView({ behavior: 'smooth', block: 'start' }), 350);
|
||||
}
|
||||
}
|
||||
}}
|
||||
className="w-full flex items-center justify-between p-5 md:p-6 cursor-pointer text-white font-semibold select-none hover:bg-white/5 transition-colors"
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="p-2 rounded-lg bg-mc-green/10">
|
||||
<Blocks size={18} className="text-mc-green icon-glow" />
|
||||
</div>
|
||||
<span>게임 규칙</span>
|
||||
<span className="text-sm font-normal text-gray-500">
|
||||
({Object.keys(server.gameRules).length})
|
||||
</span>
|
||||
</div>
|
||||
<ChevronDown size={20} className={`text-gray-400 transition-transform duration-300 ${isGameRulesOpen ? 'rotate-180' : ''}`} />
|
||||
</button>
|
||||
{isMobile ? (
|
||||
// 모바일: 애니메이션 없이 즉시 표시
|
||||
isGameRulesOpen && (
|
||||
<div className="px-5 pb-5 md:px-6 md:pb-6 border-t border-white/5">
|
||||
<div className="max-h-60 overflow-y-auto custom-scrollbar pt-4 pr-3">
|
||||
<ul className="grid grid-cols-1 md:grid-cols-2 gap-2">
|
||||
{Object.entries(server.gameRules).map(([rule, value]) => (
|
||||
<li key={rule} className="flex items-center justify-between text-sm p-3 bg-black/20 rounded-lg hover:bg-black/30 transition-colors">
|
||||
<Tooltip content={gameRuleDescriptions[rule]?.description || gameRuleDescriptions[rule]?.name || "설명이 없습니다."} className="min-w-0 flex-1 mr-2">
|
||||
<span className="text-gray-400 cursor-default truncate block w-full hover:text-gray-300 transition-colors">{rule}</span>
|
||||
</Tooltip>
|
||||
<span className={`font-mono font-bold text-xs px-2 py-1 rounded ${value ? 'bg-mc-green/20 text-mc-green' : 'bg-red-500/20 text-red-400'}`}>
|
||||
{value ? 'TRUE' : 'FALSE'}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
) : (
|
||||
// PC: height 애니메이션 적용
|
||||
<AnimatePresence initial={false}>
|
||||
{isGameRulesOpen && (
|
||||
<motion.div
|
||||
initial={{ height: 0, opacity: 0 }}
|
||||
animate={{ height: 'auto', opacity: 1 }}
|
||||
exit={{ height: 0, opacity: 0 }}
|
||||
transition={{ duration: 0.3, ease: 'easeInOut' }}
|
||||
className="overflow-hidden"
|
||||
>
|
||||
<div className="px-5 pb-5 md:px-6 md:pb-6 border-t border-white/5">
|
||||
<div className="max-h-60 overflow-y-auto custom-scrollbar pt-4 pr-3">
|
||||
<ul className="grid grid-cols-1 md:grid-cols-2 gap-2">
|
||||
{Object.entries(server.gameRules).map(([rule, value]) => (
|
||||
<li key={rule} className="flex items-center justify-between text-sm p-3 bg-black/20 rounded-lg hover:bg-black/30 transition-colors">
|
||||
<Tooltip content={gameRuleDescriptions[rule]?.description || gameRuleDescriptions[rule]?.name || "설명이 없습니다."} className="min-w-0 flex-1 mr-2">
|
||||
<span className="text-gray-400 cursor-default truncate block w-full hover:text-gray-300 transition-colors">{rule}</span>
|
||||
</Tooltip>
|
||||
<span className={`font-mono font-bold text-xs px-2 py-1 rounded ${value ? 'bg-mc-green/20 text-mc-green' : 'bg-red-500/20 text-red-400'}`}>
|
||||
{value ? 'TRUE' : 'FALSE'}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 적용된 모드 */}
|
||||
{server.mods && server.mods.length > 0 && (
|
||||
<div className="glow-card rounded-xl overflow-hidden">
|
||||
<button
|
||||
onClick={(e) => {
|
||||
const willOpen = !isModsOpen;
|
||||
const container = e.currentTarget.parentElement;
|
||||
setIsModsOpen(willOpen);
|
||||
if (willOpen) {
|
||||
if (isMobile) {
|
||||
setTimeout(() => container.scrollIntoView({ behavior: 'smooth', block: 'end' }), 50);
|
||||
} else {
|
||||
setTimeout(() => container.scrollIntoView({ behavior: 'smooth', block: 'start' }), 350);
|
||||
}
|
||||
}
|
||||
}}
|
||||
className="w-full flex items-center justify-between p-5 md:p-6 cursor-pointer text-white font-semibold select-none hover:bg-white/5 transition-colors"
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="p-2 rounded-lg bg-purple-500/10">
|
||||
<Cpu size={18} className="text-purple-400" style={{ filter: 'drop-shadow(0 0 8px rgba(168, 85, 247, 0.5))' }} />
|
||||
</div>
|
||||
<span>적용된 모드</span>
|
||||
<span className="text-sm font-normal text-gray-500">
|
||||
({server.mods.length})
|
||||
</span>
|
||||
</div>
|
||||
<ChevronDown size={20} className={`text-gray-400 transition-transform duration-300 ${isModsOpen ? 'rotate-180' : ''}`} />
|
||||
</button>
|
||||
{isMobile ? (
|
||||
// 모바일: 애니메이션 없이 즉시 표시
|
||||
isModsOpen && (
|
||||
<div className="px-5 pb-5 md:px-6 md:pb-6 border-t border-white/5">
|
||||
<div className="max-h-60 overflow-y-auto custom-scrollbar pt-4 pr-3">
|
||||
<ul className="grid grid-cols-1 md:grid-cols-2 gap-2">
|
||||
{server.mods.map((mod, index) => (
|
||||
<li key={index} className="text-sm flex items-center justify-between p-3 bg-black/20 rounded-lg hover:bg-black/30 transition-colors">
|
||||
<span className="truncate font-medium text-gray-300" title={mod.id}>{mod.id}</span>
|
||||
<span className="text-xs text-gray-500 font-mono ml-2 px-2 py-0.5 bg-white/5 rounded">{mod.version}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
) : (
|
||||
// PC: height 애니메이션 적용
|
||||
<AnimatePresence initial={false}>
|
||||
{isModsOpen && (
|
||||
<motion.div
|
||||
initial={{ height: 0, opacity: 0 }}
|
||||
animate={{ height: 'auto', opacity: 1 }}
|
||||
exit={{ height: 0, opacity: 0 }}
|
||||
transition={{ duration: 0.3, ease: 'easeInOut' }}
|
||||
className="overflow-hidden"
|
||||
>
|
||||
<div className="px-5 pb-5 md:px-6 md:pb-6 border-t border-white/5">
|
||||
<div className="max-h-60 overflow-y-auto custom-scrollbar pt-4 pr-3">
|
||||
<ul className="grid grid-cols-1 md:grid-cols-2 gap-2">
|
||||
{server.mods.map((mod, index) => (
|
||||
<li key={index} className="text-sm flex items-center justify-between p-3 bg-black/20 rounded-lg hover:bg-black/30 transition-colors">
|
||||
<span className="truncate font-medium text-gray-300" title={mod.id}>{mod.id}</span>
|
||||
<span className="text-xs text-gray-500 font-mono ml-2 px-2 py-0.5 bg-white/5 rounded">{mod.version}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ServerDetail;
|
||||
97
frontend/src/pages/WorldMapPage.jsx
Normal file
97
frontend/src/pages/WorldMapPage.jsx
Normal file
|
|
@ -0,0 +1,97 @@
|
|||
import React, { useState, useEffect, useRef } from 'react';
|
||||
import { motion } from 'framer-motion';
|
||||
import { io } from 'socket.io-client';
|
||||
import { ServerOff, Map } from 'lucide-react';
|
||||
|
||||
const WorldMapPage = ({ isMobile = false }) => {
|
||||
const [serverOnline, setServerOnline] = useState(null); // null = loading, true/false = 상태
|
||||
const socketRef = useRef(null);
|
||||
|
||||
useEffect(() => {
|
||||
// 소켓 연결하여 서버 상태 확인
|
||||
const socket = io('/', {
|
||||
path: '/socket.io',
|
||||
transports: ['websocket', 'polling']
|
||||
});
|
||||
socketRef.current = socket;
|
||||
|
||||
socket.on('status', (data) => {
|
||||
setServerOnline(data.online);
|
||||
});
|
||||
|
||||
return () => {
|
||||
socket.disconnect();
|
||||
};
|
||||
}, []);
|
||||
|
||||
// 로딩 중
|
||||
if (serverOnline === null) {
|
||||
return (
|
||||
<div
|
||||
className="w-full flex items-center justify-center bg-mc-dark"
|
||||
style={{ height: isMobile ? 'calc(100dvh - 56px)' : '100dvh' }}
|
||||
>
|
||||
<div className="text-center">
|
||||
<div className="w-12 h-12 border-4 border-mc-green/30 border-t-mc-green rounded-full animate-spin mx-auto mb-4" />
|
||||
<p className="text-gray-400">서버 상태 확인 중...</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 서버 오프라인
|
||||
if (!serverOnline) {
|
||||
return (
|
||||
<div
|
||||
className="w-full flex items-center justify-center bg-mc-dark"
|
||||
style={{ height: isMobile ? 'calc(100dvh - 56px)' : '100dvh' }}
|
||||
>
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
className="text-center p-8"
|
||||
>
|
||||
<div className="w-20 h-20 mx-auto mb-6 rounded-2xl bg-gradient-to-br from-red-500/20 to-orange-500/20 border border-red-500/30 flex items-center justify-center">
|
||||
<ServerOff className="w-10 h-10 text-red-400" />
|
||||
</div>
|
||||
<h2 className="text-2xl font-bold text-white mb-2">서버 오프라인</h2>
|
||||
<p className="text-gray-400 mb-6">
|
||||
마인크래프트 서버가 현재 오프라인 상태입니다.<br />
|
||||
서버가 시작되면 월드맵을 확인할 수 있습니다.
|
||||
</p>
|
||||
<div className="inline-flex items-center gap-2 px-4 py-2 bg-white/5 rounded-lg text-gray-500 text-sm">
|
||||
<Map size={16} />
|
||||
월드맵 이용 불가
|
||||
</div>
|
||||
</motion.div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 서버 온라인 - BlueMap 표시
|
||||
return (
|
||||
<div
|
||||
className="w-full"
|
||||
style={{
|
||||
height: isMobile ? 'calc(100dvh - 56px)' : '100dvh'
|
||||
}}
|
||||
>
|
||||
{/* BlueMap iframe - 전체 화면 */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
transition={{ duration: 0.3 }}
|
||||
className="h-full w-full"
|
||||
>
|
||||
<iframe
|
||||
src="/map/"
|
||||
title="BlueMap 3D World Map"
|
||||
className="w-full h-full border-none"
|
||||
allowFullScreen
|
||||
/>
|
||||
</motion.div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default WorldMapPage;
|
||||
233
frontend/src/pages/WorldsPage.jsx
Normal file
233
frontend/src/pages/WorldsPage.jsx
Normal file
|
|
@ -0,0 +1,233 @@
|
|||
import React, { useState, useEffect, useRef } from 'react';
|
||||
import { Globe, Sun, CloudRain, CloudLightning, Clock, Users, MapPin, ServerOff } from 'lucide-react';
|
||||
import { motion } from 'framer-motion';
|
||||
import { io } from 'socket.io-client';
|
||||
|
||||
// 월드 정보 페이지
|
||||
const WorldsPage = ({ isMobile = false }) => {
|
||||
const [worlds, setWorlds] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [serverOnline, setServerOnline] = useState(null);
|
||||
const socketRef = useRef(null);
|
||||
|
||||
// 월드 순서 정렬 함수 (오버월드 -> 네더 -> 엔드)
|
||||
const sortWorlds = (worldList) => {
|
||||
const order = {
|
||||
'minecraft:overworld': 0,
|
||||
'minecraft:the_nether': 1,
|
||||
'minecraft:the_end': 2
|
||||
};
|
||||
return [...worldList].sort((a, b) => {
|
||||
const orderA = order[a.dimension] ?? 999;
|
||||
const orderB = order[b.dimension] ?? 999;
|
||||
return orderA - orderB;
|
||||
});
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
// 소켓 연결
|
||||
const socket = io('/', {
|
||||
path: '/socket.io',
|
||||
transports: ['websocket', 'polling']
|
||||
});
|
||||
socketRef.current = socket;
|
||||
|
||||
socket.on('status', (data) => {
|
||||
setServerOnline(data.online);
|
||||
if (!data.online) {
|
||||
setLoading(false);
|
||||
}
|
||||
});
|
||||
|
||||
socket.on('worlds', (data) => {
|
||||
setWorlds(sortWorlds(data.worlds || []));
|
||||
setLoading(false);
|
||||
});
|
||||
|
||||
// 초기 데이터 요청
|
||||
socket.emit('get_worlds');
|
||||
|
||||
// 5초마다 갱신
|
||||
const interval = setInterval(() => {
|
||||
socket.emit('get_worlds');
|
||||
}, 1000);
|
||||
|
||||
return () => {
|
||||
clearInterval(interval);
|
||||
socket.disconnect();
|
||||
};
|
||||
}, []);
|
||||
|
||||
// 시간 포맷팅 (틱 -> 시:분)
|
||||
const formatTime = (dayTime) => {
|
||||
const hours = Math.floor(dayTime / 1000);
|
||||
const minutes = Math.floor((dayTime % 1000) / 16.67);
|
||||
const hour24 = (hours + 6) % 24;
|
||||
const ampm = hour24 >= 12 ? '오후' : '오전';
|
||||
const hour12 = hour24 % 12 || 12;
|
||||
return `${ampm} ${hour12}:${String(Math.floor(minutes)).padStart(2, '0')}`;
|
||||
};
|
||||
|
||||
// 날씨 아이콘
|
||||
const WeatherIcon = ({ type }) => {
|
||||
switch (type) {
|
||||
case 'thunderstorm':
|
||||
return <CloudLightning className="text-yellow-400" size={20} />;
|
||||
case 'rain':
|
||||
return <CloudRain className="text-blue-400" size={20} />;
|
||||
default:
|
||||
return <Sun className="text-yellow-300" size={20} />;
|
||||
}
|
||||
};
|
||||
|
||||
// 차원 색상
|
||||
const getDimensionStyle = (dimension) => {
|
||||
if (dimension.includes('nether')) {
|
||||
return {
|
||||
gradient: 'from-red-500/20 to-orange-500/20',
|
||||
border: 'border-red-500/30',
|
||||
text: 'text-red-400'
|
||||
};
|
||||
}
|
||||
if (dimension.includes('end')) {
|
||||
return {
|
||||
gradient: 'from-purple-500/20 to-pink-500/20',
|
||||
border: 'border-purple-500/30',
|
||||
text: 'text-purple-400'
|
||||
};
|
||||
}
|
||||
return {
|
||||
gradient: 'from-mc-green/20 to-emerald-500/20',
|
||||
border: 'border-mc-green/30',
|
||||
text: 'text-mc-green'
|
||||
};
|
||||
};
|
||||
|
||||
// 서버 오프라인 상태 - 전체 화면 가운데 정렬
|
||||
if (serverOnline === false) {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center">
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
className="text-center p-8"
|
||||
>
|
||||
<div className="w-20 h-20 mx-auto mb-6 rounded-2xl bg-gradient-to-br from-red-500/20 to-orange-500/20 border border-red-500/30 flex items-center justify-center">
|
||||
<ServerOff className="w-10 h-10 text-red-400" />
|
||||
</div>
|
||||
<h2 className="text-2xl font-bold text-white mb-2">서버 오프라인</h2>
|
||||
<p className="text-gray-400">
|
||||
마인크래프트 서버가 현재 오프라인 상태입니다.<br />
|
||||
서버가 시작되면 월드 정보를 확인할 수 있습니다.
|
||||
</p>
|
||||
</motion.div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen p-4 md:p-6 lg:p-8">
|
||||
<div className="max-w-4xl mx-auto">
|
||||
{/* 헤더 */}
|
||||
<div className="mb-8">
|
||||
<h1 className="text-3xl md:text-4xl font-bold text-white flex items-center gap-3">
|
||||
<Globe className="text-mc-green" />
|
||||
월드 정보
|
||||
</h1>
|
||||
</div>
|
||||
|
||||
{/* 월드 카드 목록 */}
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center py-20">
|
||||
<div className="w-12 h-12 border-4 border-mc-green/30 border-t-mc-green rounded-full animate-spin" />
|
||||
</div>
|
||||
) : worlds.length > 0 ? (
|
||||
<div className="grid gap-4">
|
||||
{worlds.map((world, index) => {
|
||||
const style = getDimensionStyle(world.dimension);
|
||||
return (
|
||||
<motion.div
|
||||
key={world.dimension}
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
transition={{ delay: index * 0.1, duration: 0.3 }}
|
||||
className={`glow-card rounded-xl p-5 bg-gradient-to-br ${style.gradient} ${style.border}`}
|
||||
>
|
||||
<div className="flex flex-col gap-4">
|
||||
{/* 상단: 월드 이름 & 날씨/시간 */}
|
||||
<div className="flex flex-wrap items-start justify-between gap-3">
|
||||
<div>
|
||||
<h2 className={`text-xl font-bold ${style.text}`}>{world.displayName}</h2>
|
||||
<div className="flex items-center gap-2 text-gray-400 text-sm mt-1">
|
||||
<Users size={14} />
|
||||
<span>{world.playerCount}명 접속 중</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<div className="flex items-center gap-2 bg-white/5 px-3 py-1.5 rounded-lg">
|
||||
<WeatherIcon type={world.weather?.type} />
|
||||
<span className="text-white font-medium text-sm">{world.weather?.displayName}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 bg-white/5 px-3 py-1.5 rounded-lg text-white text-sm">
|
||||
<Clock size={14} className="text-gray-400" />
|
||||
<span className="font-medium">{formatTime(world.time?.dayTime || 0)}</span>
|
||||
<span className="text-gray-500 text-xs">(Day {world.time?.day || 1})</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
{/* 하단: 접속 중인 플레이어 목록 */}
|
||||
{world.players && world.players.length > 0 && (
|
||||
<div className="pt-3 border-t border-white/10">
|
||||
<div className="flex flex-wrap gap-3">
|
||||
{world.players.map((player) => (
|
||||
<div
|
||||
key={player.uuid}
|
||||
className={`flex items-center gap-3 bg-white/5 rounded-xl ${isMobile ? 'p-2' : 'p-3'}`}
|
||||
>
|
||||
{/* 모바일: 머리만, PC: 전신 */}
|
||||
<img
|
||||
src={isMobile
|
||||
? `https://mc-heads.net/avatar/${player.name}/40`
|
||||
: `https://mc-heads.net/body/${player.name}/60`
|
||||
}
|
||||
alt={player.name}
|
||||
className={isMobile ? 'w-10 h-10 rounded' : 'h-16 w-auto'}
|
||||
/>
|
||||
<div>
|
||||
<p className={`text-white font-semibold ${isMobile ? 'text-sm' : ''}`}>{player.name}</p>
|
||||
<div className={`flex items-center gap-1 text-gray-400 mt-0.5 ${isMobile ? 'text-xs' : 'text-sm'}`}>
|
||||
<MapPin size={isMobile ? 10 : 12} />
|
||||
<span>{player.x}, {player.y}, {player.z}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{world.playerCount === 0 && (
|
||||
<div className="text-gray-500 text-sm pt-3 border-t border-white/10">
|
||||
현재 접속 중인 플레이어가 없습니다
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</motion.div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-center py-20 text-gray-400">
|
||||
<Globe size={48} className="mx-auto mb-4 opacity-50" />
|
||||
<p>월드 정보를 불러올 수 없습니다.</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default WorldsPage;
|
||||
44
frontend/src/utils/formatters.js
Normal file
44
frontend/src/utils/formatters.js
Normal file
|
|
@ -0,0 +1,44 @@
|
|||
/**
|
||||
* 공통 유틸리티 함수 모음
|
||||
* 날짜/시간 포맷팅 관련 함수들을 제공합니다.
|
||||
*/
|
||||
|
||||
/**
|
||||
* 타임스탬프를 한국어 날짜/시간 형식으로 변환
|
||||
* @param {number} timestamp - Unix 타임스탬프 (밀리초)
|
||||
* @returns {{ date: string, time: string }} - 날짜와 시간 문자열
|
||||
*/
|
||||
export const formatDate = (timestamp) => {
|
||||
if (!timestamp || timestamp <= 0) return { date: "-", time: "" };
|
||||
|
||||
const date = new Date(timestamp);
|
||||
const year = date.getFullYear();
|
||||
const month = date.getMonth() + 1;
|
||||
const day = date.getDate();
|
||||
const hour = date.getHours();
|
||||
const minute = date.getMinutes();
|
||||
const second = date.getSeconds();
|
||||
const ampm = hour >= 12 ? "오후" : "오전";
|
||||
const formattedHour = hour % 12 || 12;
|
||||
|
||||
return {
|
||||
date: `${year}년 ${month}월 ${day}일`,
|
||||
time: `${ampm} ${formattedHour}시 ${minute}분 ${second}초`,
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* 밀리초를 "X시간 Y분" 형식으로 변환
|
||||
* @param {number} ms - 밀리초
|
||||
* @returns {string} - 포맷된 시간 문자열
|
||||
*/
|
||||
export const formatPlayTimeMs = (ms) => {
|
||||
if (!ms || ms <= 0) return "0시간 0분";
|
||||
|
||||
const seconds = Math.floor(ms / 1000);
|
||||
const hours = Math.floor(seconds / 3600);
|
||||
const minutes = Math.floor((seconds % 3600) / 60);
|
||||
|
||||
if (hours > 0) return `${hours}시간 ${minutes}분`;
|
||||
return `${minutes}분`;
|
||||
};
|
||||
82
frontend/tailwind.config.js
Normal file
82
frontend/tailwind.config.js
Normal file
|
|
@ -0,0 +1,82 @@
|
|||
/** @type {import('tailwindcss').Config} */
|
||||
export default {
|
||||
content: ["./index.html", "./src/**/*.{js,ts,jsx,tsx}"],
|
||||
theme: {
|
||||
extend: {
|
||||
fontFamily: {
|
||||
sans: ["Pretendard", "Inter", "sans-serif"],
|
||||
},
|
||||
colors: {
|
||||
mc: {
|
||||
// 기존 다크 테마에서 약간 밝게 조정
|
||||
bg: "#141414",
|
||||
card: "#1c1c1c",
|
||||
green: {
|
||||
DEFAULT: "#4ade80",
|
||||
hover: "#22c55e",
|
||||
dark: "#166534",
|
||||
light: "#22c55e20",
|
||||
},
|
||||
emerald: "#10b981",
|
||||
diamond: "#22d3ee",
|
||||
gold: "#fbbf24",
|
||||
gray: "#252525",
|
||||
"gray-light": "#333333",
|
||||
"gray-dark": "#64748b",
|
||||
},
|
||||
},
|
||||
// 애니메이션 키프레임 정의
|
||||
keyframes: {
|
||||
shimmer: {
|
||||
"0%": { backgroundPosition: "-200% 0" },
|
||||
"100%": { backgroundPosition: "200% 0" },
|
||||
},
|
||||
float: {
|
||||
"0%, 100%": { transform: "translateY(0px)" },
|
||||
"50%": { transform: "translateY(-10px)" },
|
||||
},
|
||||
"glow-pulse": {
|
||||
"0%, 100%": { boxShadow: "0 0 20px rgba(74, 222, 128, 0.3)" },
|
||||
"50%": { boxShadow: "0 0 40px rgba(74, 222, 128, 0.5)" },
|
||||
},
|
||||
"border-glow": {
|
||||
"0%, 100%": { borderColor: "rgba(74, 222, 128, 0.3)" },
|
||||
"50%": { borderColor: "rgba(74, 222, 128, 0.6)" },
|
||||
},
|
||||
"fade-in-up": {
|
||||
"0%": { opacity: "0", transform: "translateY(20px)" },
|
||||
"100%": { opacity: "1", transform: "translateY(0)" },
|
||||
},
|
||||
"scale-in": {
|
||||
"0%": { opacity: "0", transform: "scale(0.95)" },
|
||||
"100%": { opacity: "1", transform: "scale(1)" },
|
||||
},
|
||||
},
|
||||
animation: {
|
||||
shimmer: "shimmer 2s infinite linear",
|
||||
float: "float 3s ease-in-out infinite",
|
||||
"glow-pulse": "glow-pulse 2s ease-in-out infinite",
|
||||
"border-glow": "border-glow 2s ease-in-out infinite",
|
||||
"fade-in-up": "fade-in-up 0.5s ease-out",
|
||||
"scale-in": "scale-in 0.3s ease-out",
|
||||
},
|
||||
boxShadow: {
|
||||
glow: "0 0 20px rgba(74, 222, 128, 0.3)",
|
||||
"glow-lg": "0 0 40px rgba(74, 222, 128, 0.4)",
|
||||
"glow-emerald": "0 0 20px rgba(16, 185, 129, 0.3)",
|
||||
"glow-diamond": "0 0 20px rgba(34, 211, 238, 0.3)",
|
||||
"inner-glow": "inset 0 0 20px rgba(74, 222, 128, 0.1)",
|
||||
soft: "0 4px 20px rgba(0, 0, 0, 0.3)",
|
||||
},
|
||||
backgroundImage: {
|
||||
"gradient-radial": "radial-gradient(var(--tw-gradient-stops))",
|
||||
"gradient-conic":
|
||||
"conic-gradient(from 180deg at 50% 50%, var(--tw-gradient-stops))",
|
||||
"mc-gradient": "linear-gradient(135deg, #4ade80 0%, #22d3ee 100%)",
|
||||
"mc-gradient-dark":
|
||||
"linear-gradient(135deg, rgba(74, 222, 128, 0.2) 0%, rgba(34, 211, 238, 0.2) 100%)",
|
||||
},
|
||||
},
|
||||
},
|
||||
plugins: [],
|
||||
};
|
||||
31
frontend/vite.config.js
Normal file
31
frontend/vite.config.js
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
import { defineConfig } from "vite";
|
||||
import react from "@vitejs/plugin-react";
|
||||
import path from "path";
|
||||
|
||||
// https://vitejs.dev/config/
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
resolve: {
|
||||
alias: {
|
||||
"@": path.resolve(__dirname, "./src"),
|
||||
},
|
||||
},
|
||||
server: {
|
||||
host: "0.0.0.0",
|
||||
port: 80,
|
||||
strictPort: true,
|
||||
allowedHosts: ["minecraft.caadiq.co.kr", "status.caadiq.co.kr"],
|
||||
// 백엔드 API 프록시 설정 (같은 컨테이너 내 server.js가 3000 포트 사용)
|
||||
proxy: {
|
||||
"/api": {
|
||||
target: "http://127.0.0.1:3000",
|
||||
changeOrigin: true,
|
||||
},
|
||||
"/socket.io": {
|
||||
target: "http://127.0.0.1:3000",
|
||||
ws: true,
|
||||
changeOrigin: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
Loading…
Add table
Reference in a new issue