Initial commit: Minecraft Dashboard

This commit is contained in:
caadiq 2025-12-16 08:40:32 +09:00
commit aa0f339f27
35 changed files with 3476 additions and 0 deletions

24
.gitignore vendored Normal file
View 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
View 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
View file

@ -0,0 +1,99 @@
# 🎮 Minecraft Dashboard
마인크래프트 서버 상태를 실시간으로 모니터링하는 웹 대시보드입니다.
![React](https://img.shields.io/badge/React-18-61DAFB?logo=react)
![Node.js](https://img.shields.io/badge/Node.js-20-339933?logo=nodedotjs)
![Docker](https://img.shields.io/badge/Docker-2496ED?logo=docker)
---
## ✨ 주요 기능
- 🟢 **서버 상태** - 온라인/오프라인 상태 및 플레이어 수 실시간 표시
- 👥 **플레이어 정보** - 접속 중인 플레이어 목록 및 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
View file

@ -0,0 +1,85 @@
# 🖥️ Minecraft Dashboard - Backend
마인크래프트 서버 상태를 조회하고 실시간으로 전달하는 Node.js API 서버입니다.
![Node.js](https://img.shields.io/badge/Node.js-20-339933?logo=nodedotjs)
![Express](https://img.shields.io/badge/Express-4-000000?logo=express)
![Socket.IO](https://img.shields.io/badge/Socket.IO-4-010101?logo=socketdotio)
---
## 🛠️ 기술 스택
| 기술 | 설명 |
| ------------------------- | ----------------- |
| **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 |

View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View file

@ -0,0 +1,61 @@
# 🎨 Minecraft Dashboard - Frontend
마인크래프트 대시보드의 웹 인터페이스입니다.
![React](https://img.shields.io/badge/React-18-61DAFB?logo=react)
![Vite](https://img.shields.io/badge/Vite-5-646CFF?logo=vite)
![TailwindCSS](https://img.shields.io/badge/TailwindCSS-3-06B6D4?logo=tailwindcss)
---
## 🛠️ 기술 스택
| 기술 | 설명 |
| ----------------- | -------------- |
| **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
View 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
View 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"
}
}

View file

@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
};

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 278 KiB

BIN
frontend/public/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

60
frontend/src/App.jsx Normal file
View 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;

View 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;

View 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;

View 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;

View 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;

View 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
View 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
View 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>,
)

File diff suppressed because one or more lines are too long

View 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;

View 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;

View 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;

View 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;

View 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}`;
};

View 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
View 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,
},
},
},
});