Compare commits

...

18 commits

Author SHA1 Message Date
e5823d140e feat: 플레이어 통계 UI 개선
- 현재 세션 플레이타임 항상 표시 (비접속 시 0분)
- 모드/리소스팩/쉐이더 ABC순 정렬
- 드래그앤드롭 시각적 피드백 개선
2025-12-24 00:35:36 +09:00
259cd1449c feat: 모드팩 파일명 기능 개선
- AWS SDK 사용으로 UTF-8 파일명 지원 (한글/특수문자)
- 원본 파일명으로 S3 저장 및 다운로드
- multipart 헤더 UTF-8 디코딩 (파일명 깨짐 수정)
- 드래그앤드롭 시각적 피드백 추가
- 모드/리소스팩/쉐이더 ABC순 정렬
2025-12-23 21:58:45 +09:00
00be44fc33 feat: 모드팩 배포 시스템 UI/UX 개선
백엔드:
- 중복 모드팩 업로드 시 409 에러 반환
- changelog UTF-8 인코딩 수정
- S3 경로에서 한글 제거 (ASCII만 사용)

프론트엔드:
- 업로드 중 로딩 인디케이터 추가
- 에러 토스트 빨간색/성공 초록색 구분
- 다이얼로그 배경 클릭 시 닫히지 않음 + 스케일 바운스 효과
- 취소 버튼 로딩 중 비활성화
2025-12-23 17:15:32 +09:00
778a9597bd feat: 프론트엔드 백엔드 API 연동
- Modpack.jsx: API fetch, 더미 데이터 제거, 다운로드 링크 연결
- Admin.jsx: 모드팩 목록 fetch, 업로드/수정/삭제 API 연동
- 파일 선택 UI, 로딩 상태, 에러 처리 구현
2025-12-23 16:42:43 +09:00
83820c3951 feat: 모드팩 다운로드 API 추가
- GET /api/modpacks/:id/download: S3 URL로 리디렉션
2025-12-23 16:35:22 +09:00
3ab156cd56 feat: 모드팩 수정/삭제 API 추가
- PUT /api/admin/modpacks/🆔 변경 로그 수정
- DELETE /api/admin/modpacks/🆔 모드팩 삭제 (DB)
2025-12-23 16:33:51 +09:00
e586520b90 feat: 모드팩 업로드 API 구현
- POST /api/admin/modpacks: .mrpack 파일 업로드
- adm-zip으로 modrinth.index.json 파싱
- 모드팩 이름, 버전, MC 버전, 모드 로더 자동 추출
- mods/resourcepacks/shaderpacks 콘텐츠 파싱
- S3에 파일 저장, DB에 메타데이터 저장
2025-12-23 16:32:06 +09:00
b9dd596652 feat: 모드팩 목록 조회 API 추가
- GET /api/modpacks: DB에서 모드팩 목록 조회
- contents_json을 파싱하여 contents 객체로 반환
- created_at DESC 정렬
2025-12-23 16:29:10 +09:00
e25faa498e feat: modpacks 테이블 초기화 함수 추가
- db.js: initModpacksTable() 함수 추가
- server.js: 서버 시작 시 테이블 초기화 호출
- 스키마: id, name, version, minecraft_version, mod_loader, changelog, file_key, file_size, contents_json, created_at, updated_at
2025-12-23 16:26:59 +09:00
7532bff8aa feat: 모드팩 배포 시스템 UI 구현
- 사용자 페이지 (/modpack): GitHub Release 스타일 UI
  - 버전별 카드, 변경 로그/콘텐츠 접이식 표시
  - framer-motion 애니메이션 적용

- 관리자 콘솔: 모드팩 탭 추가
  - 목록 조회, 업로드/수정/삭제 다이얼로그
  - 모바일/데스크톱 분기 처리 (세로 카드 / 가로 레이아웃)
  - 모바일 바텀 네비게이션

- Sidebar: 모드팩 메뉴 추가 (월드맵 아래)
2025-12-23 16:19:13 +09:00
81ed6ebf9c feat: 모드팩 배포 시스템 UI 구현
- 사용자 페이지 (/modpack): GitHub Release 스타일 UI
  - 버전별 카드, 변경 로그/콘텐츠 접이식 표시
  - 애니메이션 적용 (AnimatePresence)

- 관리자 콘솔: 모드팩 탭 추가
  - 목록 조회, 업로드/수정 다이얼로그
  - 모바일/데스크톱 분기 처리
  - 모바일 바텀 네비게이션

- Sidebar: 모드팩 메뉴 추가 (월드맵 아래)
2025-12-23 16:14:51 +09:00
b952e73a6c chore: 사용되지 않는 더미 데이터 코드 정리
- DUMMY_LOGS 상수 제거
- 관련 주석 제거
2025-12-23 15:20:43 +09:00
f6e7a8922a feat(admin): 성능 모니터링 API 연동 및 UI 개선
- TPS, MSPT, 메모리 사용량 실시간 표시
- CPU → MSPT로 변경 (서버 틱 처리 시간)
- formatStatusForClient에 성능 필드 추가
- 모바일 성능 UI 반응형 레이아웃 (1열/3열)
- 타이틀 '관리자 콘솔'로 통일
2025-12-23 12:45:40 +09:00
dd17cb5c5e feat(admin): 화이트리스트 API 연동 및 UI 개선
- 화이트리스트 조회/추가/삭제/토글 API 연동 (Mod WhitelistHandler)
- 화이트리스트 아바타 S3 캐싱 (CachedSkin 컴포넌트)
- 플레이어 아바타 S3 캐싱 연동
- 플레이어 추가 시 즉시 목록 반영
- 토스트 중앙 정렬 (모바일 대응)
- URL 해시로 탭 상태 유지
- 화이트리스트 활성화 상태 정확히 조회 (white-list 값만 체크)
2025-12-23 12:17:58 +09:00
6fb441dc80 feat: 관리자 설정 탭 기능 완성
- 게임규칙: 서버에서 실시간 목록 가져오기, 툴팁 설명, 토글 시 gamerule 명령어 실행
- 난이도: 서버에서 현재 난이도 가져오기, difficulty 명령어 실행
- 시간: 실시간 동기화 (틱 기반), time set 명령어 실행
- 날씨: 실시간 동기화, weather 명령어 실행
- 백엔드: worlds 정보 주기적 브로드캐스트 추가
2025-12-23 10:36:53 +09:00
6fe6d0dda0 feat: 콘솔 스크롤 개선 및 닉네임 실시간 동기화 구현
- 콘솔 탭 스크롤 동작 개선 (조건부 자동 스크롤, 맨 아래로 버튼)
- 탭 전환 시 레이아웃 쉬프트 방지 (scrollbar-gutter: stable)
- 맨 아래로 버튼에 그림자 효과 추가
- Sidebar에 소켓 기반 닉네임 실시간 동기화 로직 추가
- /link/status API에서 displayName 사용하도록 수정
2025-12-23 10:07:34 +09:00
c4d148810e feat: 콘솔 명령어 실행 API 구현
- 백엔드: admin.js 라우트 (JWT + 관리자 권한)

- 프론트엔드: 실제 API 호출로 연동
2025-12-22 15:37:54 +09:00
1bb52f58d5 feat: 관리자 페이지 탭 UI 구현
- 탭 UI (콘솔/플레이어/설정)

- 콘솔: 로그 영역 + 명령어 입력 + 로그 파일 목록

- 플레이어: 전신 아바타 + 필터 + 킥/밴/OP

- 설정: 게임규칙 토글 + 난이도 + 시간 + 날씨

- 더미 데이터로 UI 미리보기
2025-12-22 15:30:09 +09:00
15 changed files with 3480 additions and 247 deletions

View file

@ -78,6 +78,34 @@ const setIconCache = (key, value) => {
iconsCache[key] = value;
};
/**
* 모드팩 테이블 초기화
*/
async function initModpacksTable() {
try {
await dbPool.query(`
CREATE TABLE IF NOT EXISTS modpacks (
id INT AUTO_INCREMENT PRIMARY KEY,
name VARCHAR(255) NOT NULL,
version VARCHAR(50) NOT NULL,
minecraft_version VARCHAR(20),
mod_loader VARCHAR(50),
changelog TEXT,
file_key VARCHAR(500) NOT NULL,
file_size BIGINT DEFAULT 0,
original_filename VARCHAR(255),
contents_json LONGTEXT,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
UNIQUE KEY unique_version (name, version)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
`);
console.log("[DB] modpacks 테이블 초기화 완료");
} catch (error) {
console.error("[DB] modpacks 테이블 초기화 실패:", error.message);
}
}
export {
dbPool,
dbPool as pool,
@ -86,4 +114,5 @@ export {
getIcons,
getGamerules,
setIconCache,
initModpacksTable,
};

View file

@ -47,6 +47,10 @@ function formatStatusForClient(modStatus, motdData) {
version: "Unknown",
icon: null,
uptime: "오프라인",
tps: 0,
mspt: 0,
memoryUsedMb: 0,
memoryMaxMb: 0,
};
}
@ -81,6 +85,10 @@ function formatStatusForClient(modStatus, motdData) {
difficulty: modStatus.difficulty || "알 수 없음",
gameRules: modStatus.gameRules || {},
mods: modStatus.mods || [],
tps: modStatus.tps || 0,
mspt: modStatus.mspt || 0,
memoryUsedMb: modStatus.memoryUsedMb || 0,
memoryMaxMb: modStatus.memoryMaxMb || 0,
};
}

View file

@ -1,5 +1,9 @@
import crypto from "crypto";
import http from "http";
import {
S3Client,
PutObjectCommand,
GetObjectCommand,
HeadObjectCommand,
} from "@aws-sdk/client-s3";
// S3 설정 (RustFS) - 환경변수에서 로드
const s3Config = {
@ -10,104 +14,77 @@ const s3Config = {
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 클라이언트 생성
const s3Client = new S3Client({
endpoint: s3Config.endpoint,
region: "us-east-1",
credentials: {
accessKeyId: s3Config.accessKey,
secretAccessKey: s3Config.secretKey,
},
forcePathStyle: true, // RustFS/MinIO용
});
/**
* 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);
async function uploadToS3(
bucket,
key,
data,
contentType = "application/octet-stream"
) {
const command = new PutObjectCommand({
Bucket: bucket,
Key: key,
Body: data,
ContentType: contentType,
});
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();
});
await s3Client.send(command);
// 공개 URL 반환 (key는 인코딩)
const encodedKey = key
.split("/")
.map((s) => encodeURIComponent(s))
.join("/");
return `${s3Config.publicUrl}/${bucket}/${encodedKey}`;
}
export { s3Config, uploadToS3 };
/**
* S3(RustFS)에서 파일 다운로드
*/
async function downloadFromS3(bucket, key) {
const command = new GetObjectCommand({
Bucket: bucket,
Key: key,
});
const response = await s3Client.send(command);
// ReadableStream을 Buffer로 변환
const chunks = [];
for await (const chunk of response.Body) {
chunks.push(chunk);
}
return Buffer.concat(chunks);
}
/**
* S3(RustFS) 파일이 존재하는지 확인
*/
async function checkS3Exists(bucket, key) {
try {
const command = new HeadObjectCommand({
Bucket: bucket,
Key: key,
});
await s3Client.send(command);
return true;
} catch (err) {
if (err.name === "NotFound" || err.$metadata?.httpStatusCode === 404) {
return false;
}
throw err;
}
}
export { s3Config, uploadToS3, downloadFromS3, checkS3Exists };

View file

@ -9,6 +9,7 @@
},
"dependencies": {
"@aws-sdk/client-s3": "^3.400.0",
"adm-zip": "^0.5.16",
"bcryptjs": "^2.4.3",
"express": "^4.18.2",
"express-session": "^1.17.3",

660
backend/routes/admin.js Normal file
View file

@ -0,0 +1,660 @@
/**
* 관리자 전용 API 라우트
* - 서버 명령어 실행
* - 인증 + 관리자 권한 필요
*/
import express from "express";
import jwt from "jsonwebtoken";
import { pool } from "../lib/db.js";
const router = express.Router();
const JWT_SECRET = process.env.JWT_SECRET || "minecraft-jwt-secret";
const MOD_API_URL = process.env.MOD_API_URL || "http://minecraft-server:8080";
/**
* JWT 토큰에서 사용자 정보 추출
*/
function getUserFromToken(req) {
const authHeader = req.headers.authorization;
if (!authHeader || !authHeader.startsWith("Bearer ")) {
return null;
}
try {
const token = authHeader.split(" ")[1];
return jwt.verify(token, JWT_SECRET);
} catch {
return null;
}
}
/**
* 관리자 권한 확인 미들웨어
*/
async function requireAdmin(req, res, next) {
const user = getUserFromToken(req);
if (!user) {
return res.status(401).json({ error: "인증이 필요합니다" });
}
try {
// DB에서 관리자 권한 확인 (MySQL 문법)
const [rows] = await pool.query("SELECT is_admin FROM users WHERE id = ?", [
user.id,
]);
if (rows.length === 0 || !rows[0].is_admin) {
return res.status(403).json({ error: "관리자 권한이 필요합니다" });
}
req.user = user;
next();
} catch (error) {
console.error("[Admin] 권한 확인 오류:", error);
res.status(500).json({ error: "서버 오류" });
}
}
/**
* POST /api/admin/logs/upload - 모드에서 로그 파일 업로드
* (인증 없음 - 내부 네트워크에서만 접근 가능)
*/
router.post(
"/logs/upload",
express.raw({ type: "multipart/form-data", limit: "50mb" }),
async (req, res) => {
try {
const boundary = req.headers["content-type"].split("boundary=")[1];
const body = req.body.toString("binary");
const parts = body.split(`--${boundary}`);
let serverId = "";
let fileType = "dated";
let fileName = "";
let fileData = null;
for (const part of parts) {
if (part.includes('name="serverId"')) {
serverId = part.split("\r\n\r\n")[1]?.trim().split("\r\n")[0] || "";
} else if (part.includes('name="fileType"')) {
fileType =
part.split("\r\n\r\n")[1]?.trim().split("\r\n")[0] || "dated";
} else if (part.includes('name="file"')) {
const match = part.match(/filename="([^"]+)"/);
if (match) fileName = match[1];
const contentStart = part.indexOf("\r\n\r\n") + 4;
const contentEnd = part.lastIndexOf("\r\n");
fileData = Buffer.from(
part.substring(contentStart, contentEnd),
"binary"
);
}
}
if (!serverId || !fileName || !fileData) {
return res.status(400).json({ error: "Missing required fields" });
}
const s3Key = `logs/${serverId}/${fileType}/${fileName}`;
const { uploadToS3 } = await import("../lib/s3.js");
await uploadToS3("minecraft", s3Key, fileData, "application/gzip");
await pool.query(
"INSERT INTO log_files (server_id, file_name, file_type, file_size, s3_key) VALUES (?, ?, ?, ?, ?)",
[serverId, fileName, fileType, fileData.length, s3Key]
);
console.log(`[Admin] 로그 업로드 완료: ${s3Key}`);
res.json({ success: true, s3Key });
} catch (error) {
console.error("[Admin] 로그 업로드 오류:", error);
res.status(500).json({ error: "업로드 실패" });
}
}
);
// 모든 라우트에 관리자 권한 필요
router.use(requireAdmin);
/**
* POST /admin/command - 서버 명령어 실행
*/
router.post("/command", async (req, res) => {
const { command } = req.body;
if (!command || typeof command !== "string" || !command.trim()) {
return res
.status(400)
.json({ success: false, message: "명령어를 입력해주세요" });
}
try {
console.log(`[Admin] ${req.user.email}님이 명령어 실행: ${command}`);
const response = await fetch(`${MOD_API_URL}/command`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ command: command.trim() }),
});
const data = await response.json();
res.json(data);
} catch (error) {
console.error("[Admin] 명령어 전송 오류:", error);
res
.status(500)
.json({ success: false, message: "서버에 연결할 수 없습니다" });
}
});
/**
* GET /api/admin/logs - 서버 로그 조회
*/
router.get("/logs", async (req, res) => {
try {
const response = await fetch(`${MOD_API_URL}/logs`);
const data = await response.json();
res.json(data);
} catch (error) {
console.error("[Admin] 로그 조회 오류:", error);
res.status(500).json({ logs: [], error: "서버에 연결할 수 없습니다" });
}
});
/**
* GET /api/admin/players - 플레이어 목록 조회
*/
router.get("/players", async (req, res) => {
try {
const response = await fetch(`${MOD_API_URL}/players`);
const data = await response.json();
res.json(data);
} catch (error) {
console.error("[Admin] 플레이어 목록 조회 오류:", error);
res.status(500).json({ players: [], error: "서버에 연결할 수 없습니다" });
}
});
/**
* GET /api/admin/banlist - 목록 조회 (모드 API 프록시)
*/
router.get("/banlist", async (req, res) => {
try {
const response = await fetch(`${MOD_API_URL}/banlist`);
const data = await response.json();
res.json(data);
} catch (error) {
console.error("[Admin] 밴 목록 조회 오류:", error);
res.status(500).json({ banList: [], error: "서버에 연결할 수 없습니다" });
}
});
/**
* GET /api/admin/whitelist - 화이트리스트 조회 (모드 API 프록시)
*/
router.get("/whitelist", async (req, res) => {
try {
const response = await fetch(`${MOD_API_URL}/whitelist`);
const data = await response.json();
res.json(data);
} catch (error) {
console.error("[Admin] 화이트리스트 조회 오류:", error);
res.status(500).json({
enabled: false,
players: [],
error: "서버에 연결할 수 없습니다",
});
}
});
/**
* GET /api/admin/logfiles - DB에서 로그 파일 목록 조회
*/
router.get("/logfiles", async (req, res) => {
try {
const { serverId, fileType } = req.query;
let query = "SELECT * FROM log_files";
const params = [];
const conditions = [];
if (serverId) {
conditions.push("server_id = ?");
params.push(serverId);
}
if (fileType) {
conditions.push("file_type = ?");
params.push(fileType);
}
if (conditions.length > 0) {
query += " WHERE " + conditions.join(" AND ");
}
query += " ORDER BY file_name DESC";
const [files] = await pool.query(query, params);
// 서버 ID 목록도 함께 반환
const [servers] = await pool.query(
"SELECT DISTINCT server_id FROM log_files ORDER BY server_id"
);
res.json({
files: files.map((f) => ({
id: f.id,
serverId: f.server_id,
fileName: f.file_name,
fileType: f.file_type,
fileSize: formatFileSize(f.file_size),
s3Key: f.s3_key,
uploadedAt: f.uploaded_at,
})),
servers: servers.map((s) => s.server_id),
});
} catch (error) {
console.error("[Admin] 로그 파일 목록 오류:", error);
res.status(500).json({ files: [], error: "DB 조회 실패" });
}
});
/**
* GET /api/admin/logfile - RustFS에서 로그 파일 다운로드
*/
router.get("/logfile", async (req, res) => {
try {
const fileId = req.query.id;
if (!fileId) {
return res.status(400).json({ error: "File ID required" });
}
// DB에서 파일 정보 조회
const [rows] = await pool.query("SELECT * FROM log_files WHERE id = ?", [
fileId,
]);
if (rows.length === 0) {
return res.status(404).json({ error: "File not found" });
}
const file = rows[0];
// RustFS에서 다운로드
const { downloadFromS3 } = await import("../lib/s3.js");
const buffer = await downloadFromS3("minecraft", file.s3_key);
res.setHeader("Content-Type", "application/gzip");
res.setHeader(
"Content-Disposition",
`attachment; filename="${file.file_name}"`
);
res.send(buffer);
} catch (error) {
console.error("[Admin] 로그 파일 다운로드 오류:", error);
res.status(500).json({ error: "다운로드 실패" });
}
});
/**
* DELETE /api/admin/logfile - 로그 파일 삭제
*/
router.delete("/logfile", async (req, res) => {
try {
const fileId = req.query.id;
if (!fileId) {
return res.status(400).json({ error: "File ID required" });
}
// DB에서 파일 정보 조회
const [rows] = await pool.query("SELECT * FROM log_files WHERE id = ?", [
fileId,
]);
if (rows.length === 0) {
return res.status(404).json({ error: "File not found" });
}
const file = rows[0];
// S3에서 파일 삭제 (선택적 - 실패해도 DB 레코드는 삭제)
try {
// S3 삭제는 복잡하므로 일단 DB만 삭제
} catch (s3Error) {
console.error("[Admin] S3 삭제 실패:", s3Error);
}
// DB에서 레코드 삭제
await pool.query("DELETE FROM log_files WHERE id = ?", [fileId]);
console.log(`[Admin] 로그 파일 삭제: ${file.file_name}`);
res.json({ success: true });
} catch (error) {
console.error("[Admin] 로그 파일 삭제 오류:", error);
res.status(500).json({ error: "삭제 실패" });
}
});
/**
* 파일 크기 포맷
*/
function formatFileSize(bytes) {
if (bytes >= 1024 * 1024) return (bytes / (1024 * 1024)).toFixed(1) + " MB";
if (bytes >= 1024) return (bytes / 1024).toFixed(1) + " KB";
return bytes + " B";
}
/**
* POST /api/admin/modpacks - 모드팩 업로드
* multipart/form-data: file (.mrpack), changelog (옵션)
*/
router.post(
"/modpacks",
express.raw({ type: "multipart/form-data", limit: "500mb" }),
async (req, res) => {
try {
const boundary = req.headers["content-type"]?.split("boundary=")[1];
if (!boundary) {
return res.status(400).json({ error: "Invalid content-type" });
}
const body = req.body.toString("binary");
const parts = body.split(`--${boundary}`);
let fileData = null;
let fileName = "";
let changelog = "";
// multipart 파싱
for (const part of parts) {
if (part.includes('name="file"')) {
// 헤더 부분을 UTF-8로 디코딩하여 파일명 추출
const headerEnd = part.indexOf("\r\n\r\n");
const headerBinary = part.slice(0, headerEnd);
const header = Buffer.from(headerBinary, "binary").toString("utf8");
const match = header.match(/filename="([^"]+)"/);
if (match) fileName = match[1];
const dataStart = headerEnd + 4;
const dataEnd = part.lastIndexOf("\r\n");
fileData = Buffer.from(part.slice(dataStart, dataEnd), "binary");
} else if (part.includes('name="changelog"')) {
const dataStart = part.indexOf("\r\n\r\n") + 4;
const dataEnd = part.lastIndexOf("\r\n");
changelog = Buffer.from(
part.slice(dataStart, dataEnd),
"binary"
).toString("utf8");
}
}
if (!fileData || !fileName.endsWith(".mrpack")) {
return res.status(400).json({ error: ".mrpack 파일이 필요합니다" });
}
// .mrpack은 ZIP 파일. modrinth.index.json 파싱
const { unzipSync } = await import("zlib");
const AdmZip = (await import("adm-zip")).default;
const zip = new AdmZip(fileData);
const indexEntry = zip.getEntry("modrinth.index.json");
if (!indexEntry) {
return res
.status(400)
.json({ error: "modrinth.index.json을 찾을 수 없습니다" });
}
const indexJson = JSON.parse(indexEntry.getData().toString("utf8"));
const modpackName = indexJson.name || "Unknown Modpack";
const modpackVersion = indexJson.versionId || "1.0.0";
const minecraftVersion = indexJson.dependencies?.minecraft || null;
const modLoader =
Object.keys(indexJson.dependencies || {}).find(
(k) => k !== "minecraft"
) || null;
// contents 파싱 - Modrinth API로 모드 정보 가져오기
const contents = { mods: [], resourcepacks: [], shaderpacks: [] };
// 각 카테고리별 파일 정보 수집 (sha1 해시 포함)
const filesByCategory = { mods: [], resourcepacks: [], shaderpacks: [] };
for (const file of indexJson.files || []) {
const path = file.path;
const sha1 = file.hashes?.sha1;
const fileName = path
.split("/")
.pop()
.replace(/\.(jar|zip)$/, "");
if (path.startsWith("mods/")) {
filesByCategory.mods.push({ fileName, sha1 });
} else if (path.startsWith("resourcepacks/")) {
filesByCategory.resourcepacks.push({ fileName, sha1 });
} else if (path.startsWith("shaderpacks/")) {
filesByCategory.shaderpacks.push({ fileName, sha1 });
}
}
// Modrinth API로 모드 정보 가져오기
const allHashes = [
...filesByCategory.mods,
...filesByCategory.resourcepacks,
...filesByCategory.shaderpacks,
]
.filter((f) => f.sha1)
.map((f) => f.sha1);
let modrinthData = {};
let projectsMap = {};
if (allHashes.length > 0) {
try {
// 1. version_files API로 버전 정보 가져오기
const versionsRes = await fetch(
"https://api.modrinth.com/v2/version_files",
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ hashes: allHashes, algorithm: "sha1" }),
}
);
if (versionsRes.ok) {
modrinthData = await versionsRes.json();
// 2. projects API로 프로젝트 정보 가져오기
const projectIds = [
...new Set(Object.values(modrinthData).map((v) => v.project_id)),
];
if (projectIds.length > 0) {
const projectsRes = await fetch(
"https://api.modrinth.com/v2/projects?ids=" +
encodeURIComponent(JSON.stringify(projectIds))
);
if (projectsRes.ok) {
const projectsData = await projectsRes.json();
projectsMap = Object.fromEntries(
projectsData.map((p) => [p.id, p])
);
}
}
}
} catch (err) {
console.error("Modrinth API 호출 실패:", err.message);
}
}
// API 실패해도 계속 진행 (fallback으로 파일명 사용)
// S3 아이콘 캐싱 함수
const { uploadToS3, checkS3Exists } = await import("../lib/s3.js");
const cacheIcon = async (iconUrl, slug) => {
if (!iconUrl || !slug) return null;
const s3Key = `mod_icons/${slug}.webp`;
const cachedUrl = `https://s3.caadiq.co.kr/minecraft/${s3Key}`;
try {
// S3에 이미 캐시되어 있는지 확인
const exists = await checkS3Exists("minecraft", s3Key);
if (exists) {
return cachedUrl;
}
// Modrinth CDN에서 다운로드
const iconRes = await fetch(iconUrl);
if (!iconRes.ok) return iconUrl; // 실패시 원본 URL 반환
const iconBuffer = Buffer.from(await iconRes.arrayBuffer());
// S3에 업로드
await uploadToS3("minecraft", s3Key, iconBuffer, "image/webp");
return cachedUrl;
} catch (err) {
console.error(`아이콘 캐싱 실패 (${slug}):`, err.message);
return iconUrl; // 실패시 원본 URL 반환
}
};
// 각 카테고리별로 모드 정보 구성 (아이콘 캐싱 포함)
const buildModInfo = async (file) => {
const versionInfo = modrinthData[file.sha1];
if (versionInfo) {
const project = projectsMap[versionInfo.project_id];
if (project) {
// 아이콘 캐싱
const cachedIconUrl = await cacheIcon(
project.icon_url,
project.slug
);
return {
title: project.title,
version: versionInfo.version_number,
icon_url: cachedIconUrl,
slug: project.slug,
};
}
}
// fallback: 파일명만 사용
return {
title: file.fileName,
version: null,
icon_url: null,
slug: null,
};
};
// 병렬로 모드 정보 구성
contents.mods = await Promise.all(filesByCategory.mods.map(buildModInfo));
contents.resourcepacks = await Promise.all(
filesByCategory.resourcepacks.map(buildModInfo)
);
contents.shaderpacks = await Promise.all(
filesByCategory.shaderpacks.map(buildModInfo)
);
// S3에 업로드 (원본 파일명 사용)
const s3Key = `modpacks/${fileName}`;
await uploadToS3("minecraft", s3Key, fileData, "application/zip");
// 중복 체크
const [existing] = await pool.query(
`SELECT id FROM modpacks WHERE name = ? AND version = ?`,
[modpackName, modpackVersion]
);
if (existing.length > 0) {
return res.status(409).json({
error: `${modpackName} v${modpackVersion}은(는) 이미 존재합니다.`,
});
}
// DB에 저장
const [result] = await pool.query(
`INSERT INTO modpacks (name, version, minecraft_version, mod_loader, changelog, file_key, file_size, contents_json)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
[
modpackName,
modpackVersion,
minecraftVersion,
modLoader,
changelog,
s3Key,
fileData.length,
JSON.stringify(contents),
]
);
console.log(
`[Admin] 모드팩 업로드 완료: ${modpackName} v${modpackVersion}`
);
res.json({
success: true,
id: result.insertId,
name: modpackName,
version: modpackVersion,
size: formatFileSize(fileData.length),
});
} catch (error) {
console.error("[Admin] 모드팩 업로드 오류:", error);
res.status(500).json({ error: "업로드 실패: " + error.message });
}
}
);
/**
* PUT /api/admin/modpacks/:id - 모드팩 수정 (변경 로그만)
*/
router.put("/modpacks/:id", async (req, res) => {
try {
const { id } = req.params;
const { changelog } = req.body;
const [result] = await pool.query(
`UPDATE modpacks SET changelog = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?`,
[changelog, id]
);
if (result.affectedRows === 0) {
return res.status(404).json({ error: "모드팩을 찾을 수 없습니다" });
}
console.log(`[Admin] 모드팩 수정 완료: ID ${id}`);
res.json({ success: true });
} catch (error) {
console.error("[Admin] 모드팩 수정 오류:", error);
res.status(500).json({ error: "수정 실패" });
}
});
/**
* DELETE /api/admin/modpacks/:id - 모드팩 삭제
*/
router.delete("/modpacks/:id", async (req, res) => {
try {
const { id } = req.params;
// DB에서 파일 정보 조회
const [rows] = await pool.query(`SELECT * FROM modpacks WHERE id = ?`, [
id,
]);
if (rows.length === 0) {
return res.status(404).json({ error: "모드팩을 찾을 수 없습니다" });
}
const modpack = rows[0];
// S3에서 삭제 (deleteFromS3 함수 필요 - 일단 생략, 나중에 추가)
// TODO: S3 파일 삭제 구현
// DB에서 삭제
await pool.query(`DELETE FROM modpacks WHERE id = ?`, [id]);
console.log(
`[Admin] 모드팩 삭제 완료: ${modpack.name} v${modpack.version}`
);
res.json({ success: true });
} catch (error) {
console.error("[Admin] 모드팩 삭제 오류:", error);
res.status(500).json({ error: "삭제 실패" });
}
});
export default router;

View file

@ -1,5 +1,5 @@
import express from "express";
import { getTranslations, getIcons, getGamerules } from "../lib/db.js";
import { getTranslations, getIcons, getGamerules, dbPool } from "../lib/db.js";
import { getIconUrl } from "../lib/icons.js";
import {
MOD_API_URL,
@ -110,4 +110,74 @@ router.get("/worlds", async (req, res) => {
}
});
// 모드팩 목록 조회 API
router.get("/modpacks", async (req, res) => {
try {
const [rows] = await dbPool.query(`
SELECT id, name, version, minecraft_version, mod_loader, changelog,
file_size, contents_json, created_at, updated_at
FROM modpacks
ORDER BY created_at DESC
`);
// contents_json을 파싱하여 반환
const modpacks = rows.map((row) => ({
...row,
contents: row.contents_json ? JSON.parse(row.contents_json) : null,
contents_json: undefined, // 원본 JSON 문자열은 제거
}));
res.json(modpacks);
} catch (error) {
console.error("[API] 모드팩 목록 조회 실패:", error.message);
res.status(500).json({ error: "서버 오류" });
}
});
// 모드팩 다운로드 API (S3 리디렉션)
router.get("/modpacks/:id/download", async (req, res) => {
try {
const { id } = req.params;
const [rows] = await dbPool.query(`SELECT * FROM modpacks WHERE id = ?`, [
id,
]);
if (rows.length === 0) {
return res.status(404).json({ error: "모드팩을 찾을 수 없습니다" });
}
const modpack = rows[0];
// file_key의 각 세그먼트를 인코딩
const encodedKey = modpack.file_key
.split("/")
.map((s) => encodeURIComponent(s))
.join("/");
const downloadUrl = `https://s3.caadiq.co.kr/minecraft/${encodedKey}`;
// file_key에서 파일명 추출 (인코딩 안 된 원본)
const filename = modpack.file_key.split("/").pop();
// S3에서 파일 가져오기
const s3Response = await fetch(downloadUrl);
if (!s3Response.ok) {
return res.status(404).json({ error: "파일을 찾을 수 없습니다" });
}
// 헤더 설정
res.setHeader("Content-Type", "application/zip");
res.setHeader("Content-Length", modpack.file_size);
res.setHeader(
"Content-Disposition",
`attachment; filename*=UTF-8''${encodeURIComponent(filename)}`
);
// 스트리밍 전달
const { Readable } = await import("stream");
Readable.fromWeb(s3Response.body).pipe(res);
} catch (error) {
console.error("[API] 모드팩 다운로드 실패:", error.message);
res.status(500).json({ error: "다운로드 실패" });
}
});
export default router;

View file

@ -219,17 +219,19 @@ router.get("/status", async (req, res) => {
const modRes = await fetch(`${MOD_API_URL}/player/${uuid}`);
if (modRes.ok) {
const playerData = await modRes.json();
if (playerData.name && playerData.name !== currentName) {
// displayName이 있으면 displayName 사용 (Essentials 닉네임), 없으면 name 사용
const newName = playerData.displayName || playerData.name;
if (newName && newName !== currentName) {
// 닉네임이 변경되었으면 minecraft_links 및 users 테이블 모두 업데이트
await pool.query(
"UPDATE minecraft_links SET minecraft_name = ? WHERE user_id = ?",
[playerData.name, user.id]
[newName, user.id]
);
await pool.query("UPDATE users SET name = ? WHERE id = ?", [
playerData.name,
newName,
user.id,
]);
currentName = playerData.name;
currentName = newName;
console.log(
`[Link] 닉네임 동기화: ${links[0].minecraft_name}${currentName}`
);

View file

@ -6,7 +6,7 @@ import { fileURLToPath } from "url";
import session from "express-session";
// 모듈 import
import { loadTranslations } from "./lib/db.js";
import { loadTranslations, initModpacksTable } from "./lib/db.js";
import {
MOD_API_URL,
refreshData,
@ -17,6 +17,7 @@ import {
import apiRoutes from "./routes/api.js";
import authRoutes from "./routes/auth.js";
import linkRoutes from "./routes/link.js";
import adminRoutes from "./routes/admin.js";
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
@ -66,6 +67,9 @@ app.use("/auth", authRoutes);
// 마인크래프트 연동 라우트
app.use("/link", linkRoutes);
// 관리자 라우트
app.use("/api/admin", adminRoutes);
// Socket.IO 연결 처리
io.on("connection", (socket) => {
console.log("클라이언트 연결됨:", socket.id);
@ -119,11 +123,41 @@ async function refreshAndBroadcast() {
const { cachedStatus, cachedPlayers } = await refreshData();
io.emit("status", cachedStatus);
io.emit("players", cachedPlayers);
// 월드 정보도 브로드캐스트 (시간/날씨 포함)
try {
const response = await fetch(`${MOD_API_URL}/worlds`);
if (response.ok) {
const data = await response.json();
io.emit("worlds", data);
}
} catch (error) {
// 연결 오류 무시
}
}
// 로그 캐시 (중복 브로드캐스트 방지)
let lastLogCount = 0;
// 로그 갱신 및 브로드캐스트
async function refreshLogs() {
try {
const response = await fetch(`${MOD_API_URL}/logs`);
const data = await response.json();
if (data.logs && data.logs.length !== lastLogCount) {
lastLogCount = data.logs.length;
io.emit("logs", data.logs);
}
} catch (error) {
// 연결 오류 무시
}
}
// 1초마다 데이터 갱신
setInterval(refreshAndBroadcast, 1000);
setInterval(refreshLogs, 1000);
refreshAndBroadcast();
refreshLogs();
// SPA 라우팅 - 모든 경로에 대해 index.html 제공
app.get("*", (req, res) => {
@ -137,4 +171,7 @@ httpServer.listen(PORT, async () => {
// 번역 데이터 로드
await loadTranslations();
// 모드팩 테이블 초기화
await initModpacksTable();
});

View file

@ -8,6 +8,7 @@ import WorldsPage from './pages/WorldsPage';
import PlayersPage from './pages/PlayersPage';
import PlayerStatsPage from './pages/PlayerStatsPage';
import WorldMapPage from './pages/WorldMapPage';
import Modpack from './pages/Modpack';
import LoginPage from './pages/LoginPage';
import RegisterPage from './pages/RegisterPage';
import VerifyEmailPage from './pages/VerifyEmailPage';
@ -95,6 +96,7 @@ function App() {
<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>} />
<Route path="/modpack" element={<PageWrapper><Modpack /></PageWrapper>} />
<Route path="/profile" element={<PageWrapper><ProfilePage isMobile={isMobile} /></PageWrapper>} />
<Route path="/admin" element={<PageWrapper><Admin isMobile={isMobile} /></PageWrapper>} />
</Routes>

View file

@ -1,6 +1,6 @@
import React, { useState, useRef, useEffect } from 'react';
import { NavLink, useLocation, Link, useNavigate } from 'react-router-dom';
import { Home, Globe, Users, Menu, X, Gamepad2, Map, Shield, LogIn, LogOut, User, Settings, BarChart2 } from 'lucide-react';
import { Home, Globe, Users, Menu, X, Gamepad2, Map, Shield, LogIn, LogOut, User, Settings, BarChart2, Package } from 'lucide-react';
import { motion, AnimatePresence } from 'framer-motion';
import { useAuth } from '../contexts/AuthContext';
import { io } from 'socket.io-client';
@ -23,6 +23,7 @@ const Sidebar = ({ isMobile = false }) => {
{ path: '/players', icon: Users, label: '플레이어', matchPaths: ['/players', '/player/'] },
{ path: '/worlds', icon: Globe, label: '월드 정보' },
{ path: '/worldmap', icon: Map, label: '월드맵' },
{ path: '/modpack', icon: Package, label: '모드팩' },
];
//
@ -67,46 +68,70 @@ const Sidebar = ({ isMobile = false }) => {
};
fetchLinkStatus();
}, [isLoggedIn, user]);
}, [isLoggedIn, user?.id]);
// (socket.io)
// useRef
const userNameRef = useRef(user?.name);
const checkAuthRef = useRef(checkAuth);
const minecraftUuidRef = useRef(minecraftLink?.minecraftUuid);
const lastSyncedServerNameRef = useRef(null); //
useEffect(() => {
userNameRef.current = user?.name;
}, [user?.name]);
useEffect(() => {
checkAuthRef.current = checkAuth;
}, [checkAuth]);
useEffect(() => {
minecraftUuidRef.current = minecraftLink?.minecraftUuid;
}, [minecraftLink?.minecraftUuid]);
// (socket.io) +
useEffect(() => {
const socket = io(window.location.origin, { path: '/socket.io' });
let isSyncing = false;
socket.on('status', async (status) => {
//
socket.on('status', (status) => {
setServerOnline(status?.online || false);
});
//
// players (displayName )
socket.on('players', async (playersList) => {
const currentUuid = minecraftUuidRef.current;
if (!currentUuid || !playersList) return;
if (isSyncing) return;
// ,
if (status?.online && minecraftLink?.minecraftUuid && status?.players?.list) {
const playerInGame = status.players.list.find(p => p.uuid === minecraftLink.minecraftUuid);
//
if (playerInGame && playerInGame.name !== user?.name) {
const playerInGame = playersList.find(p => p.uuid === currentUuid);
// displayName displayName , name
const serverName = playerInGame?.displayName || playerInGame?.name;
// ,
if (playerInGame && serverName &&
serverName !== lastSyncedServerNameRef.current &&
serverName !== userNameRef.current) {
isSyncing = true;
lastSyncedServerNameRef.current = serverName; //
try {
// /link/status DB
const token = localStorage.getItem('token');
const res = await fetch('/link/status', {
await fetch('/link/status', {
headers: { 'Authorization': `Bearer ${token}` }
});
const data = await res.json();
if (data.linked) {
setMinecraftLink(data);
await checkAuth();
}
// user
await checkAuthRef.current();
} catch (error) {
//
//
} finally {
isSyncing = false;
}
}
}
});
return () => socket.disconnect();
}, [isLoggedIn, minecraftLink?.minecraftUuid, user?.name, checkAuth]);
}, []); // - ref
//
useEffect(() => {

View file

@ -2,7 +2,11 @@
@tailwind components;
@tailwind utilities;
/* 기본 body 스타일 - 다크 배경 */
/* 기본 html, body 스타일 - 다크 배경 & 스크롤바 레이아웃 고정 */
html {
scrollbar-gutter: stable; /* 스크롤바 공간 예약 (세로 스크롤바) */
}
body {
background: #141414;
min-height: 100vh;

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,384 @@
/**
* 모드팩 페이지 - GitHub Release 스타일
*/
import React, { useState } from 'react';
import { Download, Package, ChevronDown, ChevronUp, Calendar, HardDrive, Gamepad2, Box, Image } from 'lucide-react';
import { motion, AnimatePresence } from 'framer-motion';
//
const formatFileSize = (bytes) => {
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1048576) return `${(bytes / 1024).toFixed(1)} KB`;
if (bytes < 1073741824) return `${(bytes / 1048576).toFixed(1)} MB`;
return `${(bytes / 1073741824).toFixed(2)} GB`;
};
//
const formatDate = (dateString) => {
const date = new Date(dateString);
return date.toLocaleDateString('ko-KR', {
year: 'numeric',
month: 'long',
day: 'numeric',
});
};
//
const ModpackCard = ({ modpack, isLatest }) => {
const [showChangelog, setShowChangelog] = useState(false);
const [showContents, setShowContents] = useState(false);
const totalMods = modpack.contents.mods.length;
const totalResourcepacks = modpack.contents.resourcepacks.length;
const totalShaderpacks = modpack.contents.shaderpacks.length;
return (
<div className={`bg-zinc-900 border rounded-2xl overflow-hidden ${isLatest ? 'border-mc-green' : 'border-zinc-800'}`}>
{/* 헤더 */}
<div className="p-5">
<div className="flex items-start justify-between">
<div className="flex items-center gap-3">
<div className={`p-2.5 rounded-xl ${isLatest ? 'bg-mc-green/20' : 'bg-zinc-800'}`}>
<Package className={isLatest ? 'text-mc-green' : 'text-zinc-400'} size={24} />
</div>
<div>
<div className="flex items-center gap-2">
<h3 className="text-xl font-bold text-white">v{modpack.version}</h3>
{isLatest && (
<span className="px-2 py-0.5 bg-mc-green/20 text-mc-green text-xs font-medium rounded-full">
최신
</span>
)}
</div>
<p className="text-sm text-zinc-400 mt-0.5">{modpack.name}</p>
</div>
</div>
{/* 다운로드 버튼 */}
<a
href={`/api/modpacks/${modpack.id}/download`}
className="flex items-center gap-2 px-4 py-2.5 bg-mc-green hover:bg-mc-green/80 text-white font-medium rounded-xl transition-colors"
>
<Download size={18} />
<span>다운로드</span>
</a>
</div>
{/* 메타 정보 */}
<div className="flex flex-wrap gap-4 mt-4 text-sm text-zinc-400">
<div className="flex items-center gap-1.5">
<Gamepad2 size={14} />
<span>MC {modpack.minecraftVersion}</span>
</div>
<div className="flex items-center gap-1.5">
<Box size={14} />
<span>{modpack.modLoader} {modpack.modLoaderVersion}</span>
</div>
<div className="flex items-center gap-1.5">
<HardDrive size={14} />
<span>{formatFileSize(modpack.fileSize)}</span>
</div>
<div className="flex items-center gap-1.5">
<Calendar size={14} />
<span>{formatDate(modpack.createdAt)}</span>
</div>
</div>
{/* 포함 콘텐츠 요약 */}
<div className="flex gap-3 mt-4">
{totalMods > 0 && (
<span className="px-2.5 py-1 bg-blue-500/20 text-blue-400 text-xs rounded-lg">
모드 {totalMods}
</span>
)}
{totalResourcepacks > 0 && (
<span className="px-2.5 py-1 bg-purple-500/20 text-purple-400 text-xs rounded-lg">
리소스팩 {totalResourcepacks}
</span>
)}
{totalShaderpacks > 0 && (
<span className="px-2.5 py-1 bg-orange-500/20 text-orange-400 text-xs rounded-lg">
쉐이더 {totalShaderpacks}
</span>
)}
</div>
</div>
{/* 변경 로그 토글 */}
<button
onClick={() => setShowChangelog(!showChangelog)}
className="w-full flex items-center justify-between px-5 py-3 bg-zinc-800/50 hover:bg-zinc-800 text-zinc-300 text-sm transition-colors border-t border-zinc-800"
>
<span>체인지 로그</span>
{showChangelog ? <ChevronUp size={16} /> : <ChevronDown size={16} />}
</button>
{/* 변경 로그 내용 */}
<AnimatePresence>
{showChangelog && (
<motion.div
initial={{ height: 0, opacity: 0 }}
animate={{ height: 'auto', opacity: 1 }}
exit={{ height: 0, opacity: 0 }}
transition={{ duration: 0.2, ease: 'easeInOut' }}
className="overflow-hidden"
>
<div className="px-5 py-4 bg-zinc-800/30 border-t border-zinc-800">
<div className="prose prose-invert max-w-none">
{modpack.changelog.split('\n').map((line, i) => {
if (line.startsWith('###')) {
return <h4 key={i} className="text-white font-semibold mt-2 mb-2">{line.replace('### ', '')}</h4>;
}
if (line.startsWith('- ')) {
return <p key={i} className="text-zinc-400 my-1.5 pl-4"> {line.replace('- ', '')}</p>;
}
return null;
})}
</div>
</div>
</motion.div>
)}
</AnimatePresence>
{/* 포함 콘텐츠 토글 */}
<button
onClick={() => setShowContents(!showContents)}
className="w-full flex items-center justify-between px-5 py-3 bg-zinc-800/50 hover:bg-zinc-800 text-zinc-300 text-sm transition-colors border-t border-zinc-800"
>
<span>포함된 콘텐츠</span>
{showContents ? <ChevronUp size={16} /> : <ChevronDown size={16} />}
</button>
{/* 포함 콘텐츠 내용 */}
<AnimatePresence>
{showContents && (
<motion.div
initial={{ height: 0, opacity: 0 }}
animate={{ height: 'auto', opacity: 1 }}
exit={{ height: 0, opacity: 0 }}
transition={{ duration: 0.2, ease: 'easeInOut' }}
className="overflow-hidden"
>
<div className="px-5 py-4 bg-zinc-800/30 border-t border-zinc-800">
{/* 모드 */}
{modpack.contents.mods.length > 0 && (
<div className="mb-6">
<h4 className="text-white font-medium text-sm mb-3 flex items-center gap-2">
<Box size={14} className="text-blue-400" />
모드 ({modpack.contents.mods.length})
</h4>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-2">
{[...modpack.contents.mods].sort((a, b) => {
const titleA = (typeof a === 'object' ? a.title : a).toLowerCase();
const titleB = (typeof b === 'object' ? b.title : b).toLowerCase();
return titleA.localeCompare(titleB);
}).map((mod, i) => {
// mod (Modrinth ) vs ()
const isObject = typeof mod === 'object';
const title = isObject ? mod.title : mod;
const version = isObject ? mod.version : null;
const iconUrl = isObject ? mod.icon_url : null;
const slug = isObject ? mod.slug : null;
const content = (
<div className={`flex items-center gap-3 p-3 bg-zinc-800/80 rounded-xl border border-zinc-700/50 transition-all h-[68px] ${slug ? 'hover:bg-blue-500/10 hover:border-blue-500/30 cursor-pointer' : ''}`}>
{/* 아이콘 */}
<div className="w-12 h-12 rounded-lg bg-zinc-700 flex items-center justify-center overflow-hidden flex-shrink-0">
{iconUrl ? (
<img src={iconUrl} alt="" className="w-full h-full object-cover" />
) : (
<Box size={20} className="text-blue-400" />
)}
</div>
{/* 정보 */}
<div className="min-w-0 flex-1">
<p className="text-white font-medium truncate">{title}</p>
<p className="text-zinc-500 text-sm truncate h-5">{version || ''}</p>
</div>
</div>
);
return slug ? (
<a key={i} href={`https://modrinth.com/mod/${slug}`} target="_blank" rel="noopener noreferrer">
{content}
</a>
) : (
<div key={i}>{content}</div>
);
})}
</div>
</div>
)}
{/* 리소스팩 */}
{modpack.contents.resourcepacks.length > 0 && (
<div className="mb-6">
<h4 className="text-white font-medium text-sm mb-3 flex items-center gap-2">
<Image size={14} className="text-purple-400" />
리소스팩 ({modpack.contents.resourcepacks.length})
</h4>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-2">
{[...modpack.contents.resourcepacks].sort((a, b) => {
const titleA = (typeof a === 'object' ? a.title : a).toLowerCase();
const titleB = (typeof b === 'object' ? b.title : b).toLowerCase();
return titleA.localeCompare(titleB);
}).map((pack, i) => {
const isObject = typeof pack === 'object';
const title = isObject ? pack.title : pack;
const version = isObject ? pack.version : null;
const iconUrl = isObject ? pack.icon_url : null;
const slug = isObject ? pack.slug : null;
const content = (
<div className={`flex items-center gap-3 p-3 bg-zinc-800/80 rounded-xl border border-zinc-700/50 transition-all h-[68px] ${slug ? 'hover:bg-purple-500/10 hover:border-purple-500/30 cursor-pointer' : ''}`}>
<div className="w-12 h-12 rounded-lg bg-zinc-700 flex items-center justify-center overflow-hidden flex-shrink-0">
{iconUrl ? (
<img src={iconUrl} alt="" className="w-full h-full object-cover" />
) : (
<Image size={20} className="text-purple-400" />
)}
</div>
<div className="min-w-0 flex-1">
<p className="text-white font-medium truncate">{title}</p>
<p className="text-zinc-500 text-sm truncate h-5">{version || ''}</p>
</div>
</div>
);
return slug ? (
<a key={i} href={`https://modrinth.com/resourcepack/${slug}`} target="_blank" rel="noopener noreferrer">
{content}
</a>
) : (
<div key={i}>{content}</div>
);
})}
</div>
</div>
)}
{/* 쉐이더 */}
{modpack.contents.shaderpacks.length > 0 && (
<div>
<h4 className="text-white font-medium text-sm mb-3 flex items-center gap-2">
<span className="text-orange-400"></span>
쉐이더 ({modpack.contents.shaderpacks.length})
</h4>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-2">
{[...modpack.contents.shaderpacks].sort((a, b) => {
const titleA = (typeof a === 'object' ? a.title : a).toLowerCase();
const titleB = (typeof b === 'object' ? b.title : b).toLowerCase();
return titleA.localeCompare(titleB);
}).map((shader, i) => {
const isObject = typeof shader === 'object';
const title = isObject ? shader.title : shader;
const version = isObject ? shader.version : null;
const iconUrl = isObject ? shader.icon_url : null;
const slug = isObject ? shader.slug : null;
const content = (
<div className={`flex items-center gap-3 p-3 bg-zinc-800/80 rounded-xl border border-zinc-700/50 transition-all ${slug ? 'hover:bg-orange-500/10 hover:border-orange-500/30 cursor-pointer' : ''}`}>
<div className="w-12 h-12 rounded-lg bg-zinc-700 flex items-center justify-center overflow-hidden flex-shrink-0">
{iconUrl ? (
<img src={iconUrl} alt="" className="w-full h-full object-cover" />
) : (
<span className="text-xl"></span>
)}
</div>
<div className="min-w-0 flex-1">
<p className="text-white font-medium truncate">{title}</p>
{version && <p className="text-zinc-500 text-sm truncate">{version}</p>}
</div>
</div>
);
return slug ? (
<a key={i} href={`https://modrinth.com/shader/${slug}`} target="_blank" rel="noopener noreferrer">
{content}
</a>
) : (
<div key={i}>{content}</div>
);
})}
</div>
</div>
)}
</div>
</motion.div>
)}
</AnimatePresence>
</div>
);
};
export default function Modpack() {
const [modpacks, setModpacks] = useState([]);
const [loading, setLoading] = useState(true);
// API
React.useEffect(() => {
const fetchModpacks = async () => {
try {
const res = await fetch('/api/modpacks');
const data = await res.json();
// API UI
const formatted = data.map(mp => ({
id: mp.id,
version: mp.version,
name: mp.name,
minecraftVersion: mp.minecraft_version,
modLoader: mp.mod_loader,
changelog: mp.changelog || '',
fileSize: mp.file_size,
createdAt: mp.created_at,
contents: mp.contents || { mods: [], resourcepacks: [], shaderpacks: [] },
}));
setModpacks(formatted);
} catch (error) {
console.error('모드팩 목록 로드 실패:', error);
} finally {
setLoading(false);
}
};
fetchModpacks();
}, []);
if (loading) {
return (
<div className="min-h-screen bg-mc-dark p-4 sm:p-6 flex items-center justify-center">
<div className="text-zinc-400">로딩 ...</div>
</div>
);
}
return (
<div className="min-h-screen bg-mc-dark p-4 sm:p-6">
<div className="max-w-4xl mx-auto">
{/* 헤더 */}
<div className="mb-6">
<h1 className="text-2xl font-bold text-white flex items-center gap-3">
<Package className="text-mc-green" />
모드팩
</h1>
<p className="text-zinc-400 mt-1">서버 접속에 필요한 모드팩을 다운로드하세요</p>
</div>
{/* 모드팩 목록 */}
<div className="space-y-4">
{modpacks.map((modpack, index) => (
<ModpackCard key={modpack.id} modpack={modpack} isLatest={index === 0} />
))}
</div>
{/* 빈 상태 (모드팩이 없을 때) */}
{modpacks.length === 0 && (
<div className="text-center py-16">
<Package className="mx-auto text-zinc-600 mb-4" size={48} />
<p className="text-zinc-400">등록된 모드팩이 없습니다</p>
</div>
)}
</div>
</div>
);
}

View file

@ -183,20 +183,18 @@ const PlayerStatsPage = ({ isMobile = false }) => {
</h2>
<div className={`grid gap-4 ${isMobile ? 'grid-cols-1' : 'grid-cols-4'}`}>
{/* 현재 세션 플레이타임 (접속 중일 때만) */}
{playerDetail.isOnline && (
<div className="glow-card rounded-xl p-5 border border-mc-green/30">
{/* 현재 세션 플레이타임 (항상 표시) */}
<div className={`glow-card rounded-xl p-5 ${playerDetail.isOnline ? 'border border-mc-green/30' : ''}`}>
<div className="text-gray-400 text-xs md:text-sm mb-2 font-medium flex items-center gap-2">
<div className="p-1.5 rounded-md bg-mc-green/10">
<Clock size={14} className="text-mc-green icon-glow" />
</div>
현재 세션 플레이타임
</div>
<div className="text-white font-bold text-2xl md:text-3xl text-gradient">
{formatPlayTimeMs(playerDetail.currentSessionMs)}
<div className={`font-bold text-2xl md:text-3xl ${playerDetail.isOnline ? 'text-white text-gradient' : 'text-zinc-500'}`}>
{playerDetail.isOnline ? formatPlayTimeMs(playerDetail.currentSessionMs) : '0분'}
</div>
</div>
)}
{/* 누적 플레이타임 */}
<div className="glow-card rounded-xl p-5">

View file

@ -51,6 +51,10 @@ export default {
"0%": { opacity: "0", transform: "scale(0.95)" },
"100%": { opacity: "1", transform: "scale(1)" },
},
shake: {
"0%, 100%": { transform: "scale(1)" },
"50%": { transform: "scale(1.02)" },
},
},
animation: {
shimmer: "shimmer 2s infinite linear",