Compare commits
18 commits
ba907ec8eb
...
e5823d140e
| Author | SHA1 | Date | |
|---|---|---|---|
| e5823d140e | |||
| 259cd1449c | |||
| 00be44fc33 | |||
| 778a9597bd | |||
| 83820c3951 | |||
| 3ab156cd56 | |||
| e586520b90 | |||
| b9dd596652 | |||
| e25faa498e | |||
| 7532bff8aa | |||
| 81ed6ebf9c | |||
| b952e73a6c | |||
| f6e7a8922a | |||
| dd17cb5c5e | |||
| 6fb441dc80 | |||
| 6fe6d0dda0 | |||
| c4d148810e | |||
| 1bb52f58d5 |
15 changed files with 3480 additions and 247 deletions
|
|
@ -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,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
||||
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();
|
||||
async function uploadToS3(
|
||||
bucket,
|
||||
key,
|
||||
data,
|
||||
contentType = "application/octet-stream"
|
||||
) {
|
||||
const command = new PutObjectCommand({
|
||||
Bucket: bucket,
|
||||
Key: key,
|
||||
Body: data,
|
||||
ContentType: contentType,
|
||||
});
|
||||
|
||||
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 };
|
||||
|
|
|
|||
|
|
@ -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
660
backend/routes/admin.js
Normal 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;
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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}`
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
isSyncing = true;
|
||||
try {
|
||||
const token = localStorage.getItem('token');
|
||||
const res = await fetch('/link/status', {
|
||||
headers: { 'Authorization': `Bearer ${token}` }
|
||||
});
|
||||
const data = await res.json();
|
||||
if (data.linked) {
|
||||
setMinecraftLink(data);
|
||||
await checkAuth();
|
||||
}
|
||||
} catch (error) {
|
||||
// 무시
|
||||
} finally {
|
||||
isSyncing = false;
|
||||
}
|
||||
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');
|
||||
await fetch('/link/status', {
|
||||
headers: { 'Authorization': `Bearer ${token}` }
|
||||
});
|
||||
// user 상태 갱신
|
||||
await checkAuthRef.current();
|
||||
} catch (error) {
|
||||
// 에러 무시
|
||||
} finally {
|
||||
isSyncing = false;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return () => socket.disconnect();
|
||||
}, [isLoggedIn, minecraftLink?.minecraftUuid, user?.name, checkAuth]);
|
||||
}, []); // 의존성 없음 - ref로 최신 값 참조
|
||||
|
||||
// 토스트 자동 숨기기
|
||||
useEffect(() => {
|
||||
|
|
|
|||
|
|
@ -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
384
frontend/src/pages/Modpack.jsx
Normal file
384
frontend/src/pages/Modpack.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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="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={`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={`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">
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue