commit aa0f339f276f7f94745ea538828e55af4f77eb1f Author: caadiq Date: Tue Dec 16 08:40:32 2025 +0900 Initial commit: Minecraft Dashboard diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..9926869 --- /dev/null +++ b/.gitignore @@ -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/ diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..43ade58 --- /dev/null +++ b/Dockerfile @@ -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"] diff --git a/README.md b/README.md new file mode 100644 index 0000000..5a0d266 --- /dev/null +++ b/README.md @@ -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 diff --git a/backend/README.md b/backend/README.md new file mode 100644 index 0000000..34d8083 --- /dev/null +++ b/backend/README.md @@ -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 | diff --git a/backend/data/entityToMHF.json b/backend/data/entityToMHF.json new file mode 100644 index 0000000..803a286 --- /dev/null +++ b/backend/data/entityToMHF.json @@ -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" +} \ No newline at end of file diff --git a/backend/lib/db.js b/backend/lib/db.js new file mode 100644 index 0000000..2ccca8d --- /dev/null +++ b/backend/lib/db.js @@ -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, +}; diff --git a/backend/lib/icons.js b/backend/lib/icons.js new file mode 100644 index 0000000..4b48bdb --- /dev/null +++ b/backend/lib/icons.js @@ -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 }; diff --git a/backend/lib/minecraft.js b/backend/lib/minecraft.js new file mode 100644 index 0000000..7569d2c --- /dev/null +++ b/backend/lib/minecraft.js @@ -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, +}; diff --git a/backend/lib/s3.js b/backend/lib/s3.js new file mode 100644 index 0000000..e41cd92 --- /dev/null +++ b/backend/lib/s3.js @@ -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 }; diff --git a/backend/package.json b/backend/package.json new file mode 100644 index 0000000..eaa974b --- /dev/null +++ b/backend/package.json @@ -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" + } +} \ No newline at end of file diff --git a/backend/routes/api.js b/backend/routes/api.js new file mode 100644 index 0000000..f07d71f --- /dev/null +++ b/backend/routes/api.js @@ -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; diff --git a/backend/server.js b/backend/server.js new file mode 100644 index 0000000..374b54a --- /dev/null +++ b/backend/server.js @@ -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(); +}); diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..b291645 --- /dev/null +++ b/docker-compose.yml @@ -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 diff --git a/frontend/README.md b/frontend/README.md new file mode 100644 index 0000000..ebd31f3 --- /dev/null +++ b/frontend/README.md @@ -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/ # 정적 파일 +``` diff --git a/frontend/index.html b/frontend/index.html new file mode 100644 index 0000000..97bbac4 --- /dev/null +++ b/frontend/index.html @@ -0,0 +1,19 @@ + + + + + + + 마인크래프트 서버 상태 + + + +
+ + + diff --git a/frontend/package.json b/frontend/package.json new file mode 100644 index 0000000..47eb9c7 --- /dev/null +++ b/frontend/package.json @@ -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" + } +} \ No newline at end of file diff --git a/frontend/postcss.config.js b/frontend/postcss.config.js new file mode 100644 index 0000000..2aa7205 --- /dev/null +++ b/frontend/postcss.config.js @@ -0,0 +1,6 @@ +export default { + plugins: { + tailwindcss: {}, + autoprefixer: {}, + }, +}; diff --git a/frontend/public/default_icon.svg b/frontend/public/default_icon.svg new file mode 100644 index 0000000..e9842e3 --- /dev/null +++ b/frontend/public/default_icon.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/frontend/public/favicon.ico b/frontend/public/favicon.ico new file mode 100644 index 0000000..fc82e05 Binary files /dev/null and b/frontend/public/favicon.ico differ diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx new file mode 100644 index 0000000..8cc40fb --- /dev/null +++ b/frontend/src/App.jsx @@ -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 }) => ( + + {children} + +); + +function App() { + const isMobile = useIsMobile(); + const location = useLocation(); + + // 라우트 전환 시 스크롤 맨 위로 + useEffect(() => { + window.scrollTo(0, 0); + }, [location.pathname]); + + return ( + +
+
+ + {/* 사이드바 + 메인 콘텐츠 레이아웃 */} +
+ + + {/* 메인 콘텐츠 영역 */} +
+ + + } /> + } /> + } /> + } /> + } /> + + +
+
+
+
+ ); +} + +export default App; diff --git a/frontend/src/components/DeviceLayout.jsx b/frontend/src/components/DeviceLayout.jsx new file mode 100644 index 0000000..b4d9633 --- /dev/null +++ b/frontend/src/components/DeviceLayout.jsx @@ -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 ( +
+ {children} +
+ ); +}; + +// 훅: 모바일 여부 반환 +export const useIsMobile = () => { + return isMobile || isTablet; +}; + +export default DeviceLayout; diff --git a/frontend/src/components/Sidebar.jsx b/frontend/src/components/Sidebar.jsx new file mode 100644 index 0000000..3e5ce1d --- /dev/null +++ b/frontend/src/components/Sidebar.jsx @@ -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 ( + <> + {/* 상단 툴바 */} +
+
+ {/* 햄버거 메뉴 버튼 */} + + + {/* 로고/타이틀 */} +
+ + 마인크래프트 +
+ + {/* 우측 공간 (균형용) */} +
+
+
+ + {/* 상단 툴바 높이만큼 공간 확보 */} +
+ + {/* 오버레이 */} + + {isOpen && ( + setIsOpen(false)} + /> + )} + + + {/* 사이드바 (슬라이드) */} + + {isOpen && ( + + {/* 사이드바 헤더 */} +
+
+
+ +
+
+

마인크래프트

+

서버 대시보드

+
+
+ +
+ + {/* 메뉴 */} + + + +
+ )} +
+ + ); + } + + // PC: 기존 사이드바 + return ( + <> + {/* 데스크탑 사이드바 (항상 표시) */} + + + {/* 데스크톱용 사이드바 spacer */} +
+ + ); +}; + +// 사이드바 내용 컴포넌트 (PC 전용) +const SidebarContent = ({ menuItems, isMenuActive, onClose }) => ( + <> + {/* 로고 */} +
+
+
+ +
+
+

마인크래프트

+

서버 대시보드

+
+
+
+ + {/* 메뉴 */} + + + + +); + +export default Sidebar; diff --git a/frontend/src/components/SkeletonCard.jsx b/frontend/src/components/SkeletonCard.jsx new file mode 100644 index 0000000..8f84260 --- /dev/null +++ b/frontend/src/components/SkeletonCard.jsx @@ -0,0 +1,86 @@ +import React from 'react'; + +// 로딩 중일 때 표시되는 스켈레톤 카드 컴포넌트 +// 다크 테마 shimmer 애니메이션 적용 +const SkeletonCard = () => { + return ( +
+ {/* 서버 정보 배너 스켈레톤 */} +
+ {/* 배경 그라디언트 */} +
+ +
+
+ {/* 아이콘 스켈레톤 */} +
+
+ {/* 제목 스켈레톤 */} +
+ {/* 상태 뱃지 스켈레톤 */} +
+
+
+
+
+
+
+
+ +
+ {/* 상단 통계 그리드 스켈레톤 */} +
+ {[1, 2, 3].map((i) => ( +
+
+
+
+
+
+
+ ))} +
+ + {/* 플레이어 목록 스켈레톤 */} +
+
+
+
+
+
+ {[1, 2, 3, 4, 5].map((i) => ( +
+ ))} +
+
+ + {/* 전체 플레이어 목록 스켈레톤 */} +
+
+
+
+
+
+ {[1, 2, 3, 4, 5, 6, 7, 8].map((i) => ( +
+ ))} +
+
+ + {/* MOTD 스켈레톤 */} +
+
+
+
+
+
+
+
+
+ ); +}; + +export default SkeletonCard; diff --git a/frontend/src/components/SkinViewer.jsx b/frontend/src/components/SkinViewer.jsx new file mode 100644 index 0000000..797fcc6 --- /dev/null +++ b/frontend/src/components/SkinViewer.jsx @@ -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 ( +
+ +
+ ); +}; + +export default SkinViewer; diff --git a/frontend/src/components/Tooltip.jsx b/frontend/src/components/Tooltip.jsx new file mode 100644 index 0000000..a61d55a --- /dev/null +++ b/frontend/src/components/Tooltip.jsx @@ -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 ( + <> +
setIsVisible(false)} + onClick={() => setIsVisible(!isVisible)} + > + {children} +
+ {isVisible && ReactDOM.createPortal( + + +
{content}
+
+
+
, + document.body + )} + + ); +}; + +export default Tooltip; + + + diff --git a/frontend/src/index.css b/frontend/src/index.css new file mode 100644 index 0000000..8f4b7a1 --- /dev/null +++ b/frontend/src/index.css @@ -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; +} diff --git a/frontend/src/main.jsx b/frontend/src/main.jsx new file mode 100644 index 0000000..cb32e73 --- /dev/null +++ b/frontend/src/main.jsx @@ -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( + + + + + , +) diff --git a/frontend/src/pages/PlayerStatsPage.jsx b/frontend/src/pages/PlayerStatsPage.jsx new file mode 100644 index 0000000..97bbdb4 --- /dev/null +++ b/frontend/src/pages/PlayerStatsPage.jsx @@ -0,0 +1,442 @@ +import React, { useState, useEffect, useRef } from 'react'; +import { useParams } from 'react-router-dom'; +import { Activity, Skull, Heart, LocateFixed, Box, Sword, Clock, Calendar, RefreshCw } from 'lucide-react'; +import { motion } from 'framer-motion'; +import { io } from 'socket.io-client'; +import { formatDate, formatPlayTimeMs } from '../utils/formatters'; + + +// 스티브 3D 스킨 기본 이미지 (로딩 전/실패 시 사용) +const STEVE_BODY_BASE64 = 'data:image/png;base64,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'; + +// 플레이어 3D 스킨 컴포넌트 - 로딩 중에는 스티브, 로드 완료 시 실제 스킨 표시 +const PlayerSkinImage = ({ uuid, playerName }) => { + const [src, setSrc] = useState(STEVE_BODY_BASE64); + + useEffect(() => { + const img = new Image(); + const realUrl = `https://mc-heads.net/body/${uuid}/80`; + img.onload = () => setSrc(realUrl); + img.src = realUrl; + }, [uuid]); + + return ( + {playerName} + ); +}; + +// 아이템/몹 아이콘 기본 이미지 (로딩 실패 시 사용) +const DEFAULT_ICON = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAACAAAAAgCAYAAABzenr0AAAAAXNSR0IArs4c6QAAAARnQU1BAACxjwv8YQUAAAAJcEhZcwAADsMAAA7DAcdvqGQAAABhSURBVFhH7c4xDQAgDATAH/+fxsaCj8FqJndzCSFJkiRJkvRnvW+v+wGAzwfgM/4MAH7HD/Bh/BkA/Iof4MP4fgDwd/wZAPwaP8CH8f0A4O/4MwD4NT6AD+MbAPr9ALgBj+YLdQAAAABJRU5ErkJggg=='; + +// 거리 포맷 함수 - 1만 이상이면 "1.2만m" 형식으로 표시 +const formatDistance = (meters) => { + if (meters >= 100000000) { + // 1억 이상 + return `${(meters / 100000000).toFixed(1)}억m`; + } else if (meters >= 10000000) { + // 천만 이상 + return `${(meters / 10000000).toFixed(1)}천만m`; + } else if (meters >= 10000) { + // 만 이상 + return `${(meters / 10000).toFixed(1)}만m`; + } + return `${meters.toLocaleString()}m`; +}; + +// 플레이어 통계 페이지 +const PlayerStatsPage = ({ isMobile = false }) => { + const { uuid } = useParams(); + const [playerName, setPlayerName] = useState(''); + const [playerDetail, setPlayerDetail] = useState(null); + const [stats, setStats] = useState(null); + const [translations, setTranslations] = useState({}); + const [icons, setIcons] = useState({}); // 아이콘 캐시 + const [loading, setLoading] = useState(true); + const socketRef = useRef(null); + + // 번역 및 아이콘 데이터 로드 + useEffect(() => { + Promise.all([ + fetch('/api/translations').then(res => res.json()), + fetch('/api/icons').then(res => res.json()) + ]) + .then(([transData, iconsData]) => { + setTranslations(transData); + setIcons(iconsData); + }) + .catch(err => console.error('데이터 로드 실패:', err)); + }, []); + + useEffect(() => { + const socket = io('/', { + path: '/socket.io', + transports: ['websocket', 'polling'] + }); + socketRef.current = socket; + + socket.on('player_stats', (data) => { + setStats(data); + setLoading(false); + }); + + socket.on('player_detail', (data) => { + if (data) { + setPlayerName(data.name); + setPlayerDetail(data); + } + }); + + socket.emit('get_player', uuid); + socket.emit('get_player_stats', uuid); + + const interval = setInterval(() => { + socket.emit('get_player', uuid); + socket.emit('get_player_stats', uuid); + }, 1000); + + return () => { + clearInterval(interval); + socket.disconnect(); + }; + }, [uuid]); + + + + const firstPlayed = formatDate(playerDetail?.firstJoin); + const lastPlayed = formatDate(playerDetail?.lastLeave > 0 ? playerDetail?.lastLeave : playerDetail?.lastJoin); + + // 번역 함수 (DB에 없으면 영어 그대로) + const translate = (id) => translations[id] || id.replace(/_/g, ' '); + + // 아이템 통계 정렬 (이름순) + const sortedItems = stats?.items + ? Object.entries(stats.items) + .map(([id, stat]) => ({ + id, + ...stat, + total: (stat.mined || 0) + (stat.used || 0) + (stat.pickedUp || 0) + (stat.crafted || 0) + })) + .sort((a, b) => translate(a.id).localeCompare(translate(b.id), 'ko')) + : []; + + // 몹 통계 정렬 (이름순) + const sortedMobs = stats?.mobs + ? Object.entries(stats.mobs) + .map(([id, stat]) => ({ + id, + ...stat, + total: (stat.killed || 0) + (stat.killedBy || 0) + })) + .sort((a, b) => translate(a.id).localeCompare(translate(b.id), 'ko')) + : []; + + return ( +
+
+ {/* 헤더 */} +
+
+ +
+

{playerName || '로딩중...'}

+

플레이어 통계

+
+
+
+ + {loading ? ( +
+
+
+ ) : !stats ? ( +
+ +

통계를 불러올 수 없습니다. 플레이어가 접속 중이어야 합니다.

+
+ ) : ( +
+ {/* 플레이어 정보 */} + {playerDetail && ( + +

+ + 플레이어 정보 +

+ +
+ {/* 현재 세션 플레이타임 (접속 중일 때만) */} + {playerDetail.isOnline && ( +
+
+
+ +
+ 현재 세션 플레이타임 +
+
+ {formatPlayTimeMs(playerDetail.currentSessionMs)} +
+
+ )} + + {/* 누적 플레이타임 */} +
+
+
+ +
+ 누적 플레이타임 +
+
+ {formatPlayTimeMs(playerDetail.totalPlayTimeMs + (playerDetail.isOnline ? playerDetail.currentSessionMs : 0))} +
+
+ + {/* 첫 접속 */} +
+
+
+ +
+ 첫 접속 +
+
+
{firstPlayed.date}
+
{firstPlayed.time}
+
+
+ + {/* 마지막 접속 */} +
+
+
+ +
+ 마지막 접속 +
+
+
{lastPlayed.date}
+
{lastPlayed.time}
+
+
+
+
+ )} + + {/* 일반 통계 */} + +

+ + 일반 통계 +

+
+ + + + + +
+
+ + {/* 이동 거리 */} + +

+ + 이동 거리 +

+
+
+

걸은 거리

+

+ {isMobile ? formatDistance(stats.general.distanceWalked) : `${stats.general.distanceWalked.toLocaleString()}m`} +

+
+
+

비행 거리

+

+ {isMobile ? formatDistance(stats.general.distanceFlown) : `${stats.general.distanceFlown.toLocaleString()}m`} +

+
+
+

수영 거리

+

+ {isMobile ? formatDistance(stats.general.distanceSwum) : `${stats.general.distanceSwum.toLocaleString()}m`} +

+
+
+
+ + {/* 아이템 통계 - 4열 그리드 + 내부 스크롤 */} + {sortedItems.length > 0 && ( + +

+ + 아이템 통계 + ({sortedItems.length}개) +

+
+
+
+ {sortedItems.map((item) => ( + + ))} +
+
+
+
+ )} + + {/* 몹 통계 - 4열 그리드 + 내부 스크롤 */} + {sortedMobs.length > 0 && ( + +

+ + 몹 통계 + ({sortedMobs.length}마리) +

+
+
+
+ {sortedMobs.map((mob) => ( + + ))} +
+
+
+
+ )} + + {sortedItems.length === 0 && sortedMobs.length === 0 && ( +
+

아직 기록된 통계가 없습니다.

+
+ )} +
+ )} +
+
+ ); +}; + +// 통계 카드 컴포넌트 +const StatCard = ({ icon: Icon, label, value, color }) => ( +
+
+ +
+
+

{label}

+

{typeof value === 'number' ? value.toLocaleString() : value}

+
+
+); + +// 아이템 통계 행 컴포넌트 (온디맨드 아이콘 지원) +const ItemStatRow = ({ item, translate, icons }) => { + const [iconSrc, setIconSrc] = useState(icons[item.id] || DEFAULT_ICON); + const [loading, setLoading] = useState(!icons[item.id]); + + // 아이콘이 없으면 온디맨드로 가져오기 + useEffect(() => { + if (!icons[item.id]) { + fetch(`/api/icon/item/${item.id}`) + .then(res => res.json()) + .then(data => { + if (data.icon) { + setIconSrc(data.icon); + } + }) + .catch(() => {}) + .finally(() => setLoading(false)); + } + }, [item.id, icons]); + + return ( +
+ {item.id} { e.target.src = DEFAULT_ICON; }} + /> +
+

+ {translate(item.id)} +

+
+ {item.mined > 0 && 채굴 {item.mined}} + {item.used > 0 && 사용 {item.used}} + {item.pickedUp > 0 && 획득 {item.pickedUp}} + {item.crafted > 0 && 제작 {item.crafted}} +
+
+
+ ); +}; + +// 몹 통계 행 컴포넌트 (온디맨드 아이콘 지원) +const MobStatRow = ({ mob, translate, icons }) => { + const [iconSrc, setIconSrc] = useState(icons[mob.id] || DEFAULT_ICON); + const [loading, setLoading] = useState(!icons[mob.id]); + + // 아이콘이 없으면 온디맨드로 가져오기 + useEffect(() => { + if (!icons[mob.id]) { + fetch(`/api/icon/entity/${mob.id}`) + .then(res => res.json()) + .then(data => { + if (data.icon) { + setIconSrc(data.icon); + } + }) + .catch(() => {}) + .finally(() => setLoading(false)); + } + }, [mob.id, icons]); + + return ( +
+ {mob.id} { e.target.src = DEFAULT_ICON; }} + /> +
+

+ {translate(mob.id)} +

+
+ {mob.killed > 0 && 처치 {mob.killed}} + {mob.killedBy > 0 && 죽음 {mob.killedBy}} +
+
+
+ ); +}; + +export default PlayerStatsPage; diff --git a/frontend/src/pages/PlayersPage.jsx b/frontend/src/pages/PlayersPage.jsx new file mode 100644 index 0000000..7e6938e --- /dev/null +++ b/frontend/src/pages/PlayersPage.jsx @@ -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 ( + {name} + ); +}; + +// 전체 플레이어 목록 페이지 +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 ( +
+ +
+ +
+

서버 오프라인

+

+ 마인크래프트 서버가 현재 오프라인 상태입니다.
+ 서버가 시작되면 플레이어 정보를 확인할 수 있습니다. +

+
+
+ ); + } + + return ( +
+
+ {/* 헤더 */} +
+

+ + 플레이어 +

+

+ 전체 플레이어 {players.length}명 + + 온라인 {onlineCount}명 +

+
+ + {/* 플레이어 목록 */} + {loading ? ( +
+
+
+ ) : sortedPlayers.length > 0 ? ( +
+ {sortedPlayers.map((player, index) => ( + + + {/* 플레이어 아바타 */} +
+ + {/* 온라인 상태 표시 */} +
+
+ + {/* 플레이어 정보 */} +
+
+

{player.name}

+ + {player.isOnline ? '온라인' : '오프라인'} + +
+
+ + {formatPlayTimeMs(player.totalPlayTimeMs)} +
+
+ + {/* 통계 보기 안내 */} +
+ 통계 보기 → +
+ + + ))} +
+ ) : ( +
+ +

등록된 플레이어가 없습니다.

+
+ )} +
+
+ ); +}; + +export default PlayersPage; diff --git a/frontend/src/pages/ServerDetail.jsx b/frontend/src/pages/ServerDetail.jsx new file mode 100644 index 0000000..53264c3 --- /dev/null +++ b/frontend/src/pages/ServerDetail.jsx @@ -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 ( +
+
+

서버를 찾을 수 없습니다

+ + 홈으로 돌아가기 + +
+
+ ); + } + + return ( +
+
+ {/* 헤더 영역 - "실시간 업데이트" 뱃지 제거됨 */} + +

+ 마인크래프트 + 서버 상태 +

+

+ 서버의 현재 상태를 실시간으로 확인하세요 +

+
+ + {loading ? ( + + ) : ( + + {/* 서버 정보 배너 */} +
+ {/* 배경 그라디언트 */} +
+
+ + {/* 빛나는 원형 효과 */} +
+
+ + {/* 서버 정보 */} +
+
+ {/* 서버 아이콘 */} + + Server Icon { + e.target.onerror = null; + e.target.src = '/default_icon.svg'; + }} + /> + + + {/* 서버 이름 및 상태 */} +
+

+ {server.name} +

+
+ {/* 온라인/오프라인 상태 */} + + + {server.status === 'online' ? '온라인' : '오프라인'} + + + {/* 플레이어 수 */} + + + {server.players.current} + / + {server.players.max} + +
+
+
+
+
+ + {/* 메인 콘텐츠 */} +
+ {/* 통계 카드 그리드 */} +
+ {/* 버전 카드 */} +
+
+
+ +
+ 버전 +
+
+ {server.version} + {server.modLoader && ( + + {server.modLoader} + + )} +
+
+ + {/* 난이도 카드 */} +
+
+
+ +
+ 난이도 +
+ + {server.difficulty || "알 수 없음"} + +
+ + {/* 가동 시간 카드 */} +
+
+
+ +
+ 가동 시간 +
+ + {server.uptime} + +
+
+ + {/* 접속 중인 플레이어 */} +
+

+ + 접속 중인 플레이어 + + ({server.players.current}/{server.players.max}) + +

+
+ {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) => ( + +
+ {player.name} { + e.target.onerror = null; + e.target.src = 'https://minotar.net/helm/Steve/24.png'; + }} + /> + {/* 온라인 표시 */} + +
+ + {player.name} + + {player.isOp && ( + + OP + + )} + + )) + ) : ( +
+ + 접속 중인 플레이어가 없습니다. +
+ )} +
+
+ + {/* MOTD (서버 메시지) */} + {server.motd && ( +
+

+ + 서버 메시지 (MOTD) +

+
+
+ )} + + {/* 게임 규칙 */} + {server.gameRules && Object.keys(server.gameRules).length > 0 && ( +
+ + {isMobile ? ( + // 모바일: 애니메이션 없이 즉시 표시 + isGameRulesOpen && ( +
+
+
    + {Object.entries(server.gameRules).map(([rule, value]) => ( +
  • + + {rule} + + + {value ? 'TRUE' : 'FALSE'} + +
  • + ))} +
+
+
+ ) + ) : ( + // PC: height 애니메이션 적용 + + {isGameRulesOpen && ( + +
+
+
    + {Object.entries(server.gameRules).map(([rule, value]) => ( +
  • + + {rule} + + + {value ? 'TRUE' : 'FALSE'} + +
  • + ))} +
+
+
+
+ )} +
+ )} +
+ )} + + {/* 적용된 모드 */} + {server.mods && server.mods.length > 0 && ( +
+ + {isMobile ? ( + // 모바일: 애니메이션 없이 즉시 표시 + isModsOpen && ( +
+
+
    + {server.mods.map((mod, index) => ( +
  • + {mod.id} + {mod.version} +
  • + ))} +
+
+
+ ) + ) : ( + // PC: height 애니메이션 적용 + + {isModsOpen && ( + +
+
+
    + {server.mods.map((mod, index) => ( +
  • + {mod.id} + {mod.version} +
  • + ))} +
+
+
+
+ )} +
+ )} +
+ )} +
+ + )} +
+
+ ); +}; + +export default ServerDetail; diff --git a/frontend/src/pages/WorldMapPage.jsx b/frontend/src/pages/WorldMapPage.jsx new file mode 100644 index 0000000..d74d26e --- /dev/null +++ b/frontend/src/pages/WorldMapPage.jsx @@ -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 ( +
+
+
+

서버 상태 확인 중...

+
+
+ ); + } + + // 서버 오프라인 + if (!serverOnline) { + return ( +
+ +
+ +
+

서버 오프라인

+

+ 마인크래프트 서버가 현재 오프라인 상태입니다.
+ 서버가 시작되면 월드맵을 확인할 수 있습니다. +

+
+ + 월드맵 이용 불가 +
+
+
+ ); + } + + // 서버 온라인 - BlueMap 표시 + return ( +
+ {/* BlueMap iframe - 전체 화면 */} + +