feat: 모드팩 파일명 기능 개선
- AWS SDK 사용으로 UTF-8 파일명 지원 (한글/특수문자) - 원본 파일명으로 S3 저장 및 다운로드 - multipart 헤더 UTF-8 디코딩 (파일명 깨짐 수정) - 드래그앤드롭 시각적 피드백 추가 - 모드/리소스팩/쉐이더 ABC순 정렬
This commit is contained in:
parent
00be44fc33
commit
259cd1449c
6 changed files with 370 additions and 184 deletions
|
|
@ -93,6 +93,7 @@ async function initModpacksTable() {
|
||||||
changelog TEXT,
|
changelog TEXT,
|
||||||
file_key VARCHAR(500) NOT NULL,
|
file_key VARCHAR(500) NOT NULL,
|
||||||
file_size BIGINT DEFAULT 0,
|
file_size BIGINT DEFAULT 0,
|
||||||
|
original_filename VARCHAR(255),
|
||||||
contents_json LONGTEXT,
|
contents_json LONGTEXT,
|
||||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,9 @@
|
||||||
import crypto from "crypto";
|
import {
|
||||||
import http from "http";
|
S3Client,
|
||||||
|
PutObjectCommand,
|
||||||
|
GetObjectCommand,
|
||||||
|
HeadObjectCommand,
|
||||||
|
} from "@aws-sdk/client-s3";
|
||||||
|
|
||||||
// S3 설정 (RustFS) - 환경변수에서 로드
|
// S3 설정 (RustFS) - 환경변수에서 로드
|
||||||
const s3Config = {
|
const s3Config = {
|
||||||
|
|
@ -10,144 +14,77 @@ const s3Config = {
|
||||||
publicUrl: "https://s3.caadiq.co.kr",
|
publicUrl: "https://s3.caadiq.co.kr",
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
// S3 클라이언트 생성
|
||||||
* AWS Signature V4 서명
|
const s3Client = new S3Client({
|
||||||
*/
|
endpoint: s3Config.endpoint,
|
||||||
function signV4(method, path, headers, payload, accessKey, secretKey) {
|
region: "us-east-1",
|
||||||
const service = "s3";
|
credentials: {
|
||||||
const region = "us-east-1";
|
accessKeyId: s3Config.accessKey,
|
||||||
const now = new Date();
|
secretAccessKey: s3Config.secretKey,
|
||||||
const amzDate = now.toISOString().replace(/[:-]|\.\d{3}/g, "");
|
},
|
||||||
const dateStamp = amzDate.slice(0, 8);
|
forcePathStyle: true, // RustFS/MinIO용
|
||||||
|
});
|
||||||
headers["x-amz-date"] = amzDate;
|
|
||||||
headers["x-amz-content-sha256"] = crypto
|
|
||||||
.createHash("sha256")
|
|
||||||
.update(payload)
|
|
||||||
.digest("hex");
|
|
||||||
|
|
||||||
const signedHeaders = Object.keys(headers)
|
|
||||||
.map((k) => k.toLowerCase())
|
|
||||||
.sort()
|
|
||||||
.join(";");
|
|
||||||
const canonicalHeaders = Object.keys(headers)
|
|
||||||
.sort((a, b) => a.toLowerCase().localeCompare(b.toLowerCase()))
|
|
||||||
.map((k) => `${k.toLowerCase()}:${headers[k].trim()}`)
|
|
||||||
.join("\n");
|
|
||||||
|
|
||||||
const canonicalRequest = [
|
|
||||||
method,
|
|
||||||
path,
|
|
||||||
"",
|
|
||||||
canonicalHeaders + "\n",
|
|
||||||
signedHeaders,
|
|
||||||
headers["x-amz-content-sha256"],
|
|
||||||
].join("\n");
|
|
||||||
const credentialScope = `${dateStamp}/${region}/${service}/aws4_request`;
|
|
||||||
const stringToSign = [
|
|
||||||
"AWS4-HMAC-SHA256",
|
|
||||||
amzDate,
|
|
||||||
credentialScope,
|
|
||||||
crypto.createHash("sha256").update(canonicalRequest).digest("hex"),
|
|
||||||
].join("\n");
|
|
||||||
|
|
||||||
const getSignatureKey = (key, ds, rg, sv) => {
|
|
||||||
const kDate = crypto.createHmac("sha256", `AWS4${key}`).update(ds).digest();
|
|
||||||
const kRegion = crypto.createHmac("sha256", kDate).update(rg).digest();
|
|
||||||
const kService = crypto.createHmac("sha256", kRegion).update(sv).digest();
|
|
||||||
return crypto
|
|
||||||
.createHmac("sha256", kService)
|
|
||||||
.update("aws4_request")
|
|
||||||
.digest();
|
|
||||||
};
|
|
||||||
|
|
||||||
const signingKey = getSignatureKey(secretKey, dateStamp, region, service);
|
|
||||||
const signature = crypto
|
|
||||||
.createHmac("sha256", signingKey)
|
|
||||||
.update(stringToSign)
|
|
||||||
.digest("hex");
|
|
||||||
headers[
|
|
||||||
"Authorization"
|
|
||||||
] = `AWS4-HMAC-SHA256 Credential=${accessKey}/${credentialScope}, SignedHeaders=${signedHeaders}, Signature=${signature}`;
|
|
||||||
return headers;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* S3(RustFS)에 파일 업로드
|
* S3(RustFS)에 파일 업로드
|
||||||
*/
|
*/
|
||||||
function uploadToS3(
|
async function uploadToS3(
|
||||||
bucket,
|
bucket,
|
||||||
key,
|
key,
|
||||||
data,
|
data,
|
||||||
contentType = "application/octet-stream"
|
contentType = "application/octet-stream"
|
||||||
) {
|
) {
|
||||||
return new Promise((resolve, reject) => {
|
const command = new PutObjectCommand({
|
||||||
const url = new URL(s3Config.endpoint);
|
Bucket: bucket,
|
||||||
const path = `/${bucket}/${key}`;
|
Key: key,
|
||||||
const headers = {
|
Body: data,
|
||||||
Host: url.host,
|
ContentType: contentType,
|
||||||
"Content-Type": contentType,
|
|
||||||
"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();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
|
await s3Client.send(command);
|
||||||
|
// 공개 URL 반환 (key는 인코딩)
|
||||||
|
const encodedKey = key
|
||||||
|
.split("/")
|
||||||
|
.map((s) => encodeURIComponent(s))
|
||||||
|
.join("/");
|
||||||
|
return `${s3Config.publicUrl}/${bucket}/${encodedKey}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* S3(RustFS)에서 파일 다운로드
|
* S3(RustFS)에서 파일 다운로드
|
||||||
*/
|
*/
|
||||||
function downloadFromS3(bucket, key) {
|
async function downloadFromS3(bucket, key) {
|
||||||
return new Promise((resolve, reject) => {
|
const command = new GetObjectCommand({
|
||||||
const url = new URL(s3Config.endpoint);
|
Bucket: bucket,
|
||||||
const path = `/${bucket}/${key}`;
|
Key: key,
|
||||||
const headers = {
|
|
||||||
Host: url.host,
|
|
||||||
};
|
|
||||||
signV4("GET", path, headers, "", s3Config.accessKey, s3Config.secretKey);
|
|
||||||
|
|
||||||
const options = {
|
|
||||||
hostname: url.hostname,
|
|
||||||
port: url.port || 80,
|
|
||||||
path,
|
|
||||||
method: "GET",
|
|
||||||
headers,
|
|
||||||
};
|
|
||||||
const req = http.request(options, (res) => {
|
|
||||||
const chunks = [];
|
|
||||||
res.on("data", (chunk) => chunks.push(chunk));
|
|
||||||
res.on("end", () => {
|
|
||||||
if (res.statusCode >= 200 && res.statusCode < 300) {
|
|
||||||
resolve(Buffer.concat(chunks));
|
|
||||||
} else {
|
|
||||||
reject(new Error(`S3 Download failed: ${res.statusCode}`));
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
req.on("error", reject);
|
|
||||||
req.end();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const response = await s3Client.send(command);
|
||||||
|
// ReadableStream을 Buffer로 변환
|
||||||
|
const chunks = [];
|
||||||
|
for await (const chunk of response.Body) {
|
||||||
|
chunks.push(chunk);
|
||||||
|
}
|
||||||
|
return Buffer.concat(chunks);
|
||||||
}
|
}
|
||||||
|
|
||||||
export { s3Config, uploadToS3, downloadFromS3 };
|
/**
|
||||||
|
* 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 };
|
||||||
|
|
|
||||||
|
|
@ -367,15 +367,19 @@ router.post(
|
||||||
// multipart 파싱
|
// multipart 파싱
|
||||||
for (const part of parts) {
|
for (const part of parts) {
|
||||||
if (part.includes('name="file"')) {
|
if (part.includes('name="file"')) {
|
||||||
const match = part.match(/filename="([^"]+)"/);
|
// 헤더 부분을 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];
|
if (match) fileName = match[1];
|
||||||
const dataStart = part.indexOf("\r\n\r\n") + 4;
|
|
||||||
|
const dataStart = headerEnd + 4;
|
||||||
const dataEnd = part.lastIndexOf("\r\n");
|
const dataEnd = part.lastIndexOf("\r\n");
|
||||||
fileData = Buffer.from(part.slice(dataStart, dataEnd), "binary");
|
fileData = Buffer.from(part.slice(dataStart, dataEnd), "binary");
|
||||||
} else if (part.includes('name="changelog"')) {
|
} else if (part.includes('name="changelog"')) {
|
||||||
const dataStart = part.indexOf("\r\n\r\n") + 4;
|
const dataStart = part.indexOf("\r\n\r\n") + 4;
|
||||||
const dataEnd = part.lastIndexOf("\r\n");
|
const dataEnd = part.lastIndexOf("\r\n");
|
||||||
// UTF-8로 디코딩
|
|
||||||
changelog = Buffer.from(
|
changelog = Buffer.from(
|
||||||
part.slice(dataStart, dataEnd),
|
part.slice(dataStart, dataEnd),
|
||||||
"binary"
|
"binary"
|
||||||
|
|
@ -408,37 +412,147 @@ router.post(
|
||||||
(k) => k !== "minecraft"
|
(k) => k !== "minecraft"
|
||||||
) || null;
|
) || null;
|
||||||
|
|
||||||
// contents 파싱 (mods, resourcepacks, shaderpacks)
|
// contents 파싱 - Modrinth API로 모드 정보 가져오기
|
||||||
const contents = { mods: [], resourcepacks: [], shaderpacks: [] };
|
const contents = { mods: [], resourcepacks: [], shaderpacks: [] };
|
||||||
|
|
||||||
|
// 각 카테고리별 파일 정보 수집 (sha1 해시 포함)
|
||||||
|
const filesByCategory = { mods: [], resourcepacks: [], shaderpacks: [] };
|
||||||
for (const file of indexJson.files || []) {
|
for (const file of indexJson.files || []) {
|
||||||
const path = file.path;
|
const path = file.path;
|
||||||
// 파일명에서 이름과 버전 추출 시도 (예: Create-0.5.1.jar)
|
const sha1 = file.hashes?.sha1;
|
||||||
const fileName = path
|
const fileName = path
|
||||||
.split("/")
|
.split("/")
|
||||||
.pop()
|
.pop()
|
||||||
.replace(/\.(jar|zip)$/, "");
|
.replace(/\.(jar|zip)$/, "");
|
||||||
const match = fileName.match(/^(.+?)[-_](\d+[\d.]+\d*)$/);
|
|
||||||
const name = match ? match[1].replace(/[-_]/g, " ") : fileName;
|
|
||||||
const version = match ? match[2] : "";
|
|
||||||
|
|
||||||
if (path.startsWith("mods/")) {
|
if (path.startsWith("mods/")) {
|
||||||
contents.mods.push({ name, version });
|
filesByCategory.mods.push({ fileName, sha1 });
|
||||||
} else if (path.startsWith("resourcepacks/")) {
|
} else if (path.startsWith("resourcepacks/")) {
|
||||||
contents.resourcepacks.push({ name, version });
|
filesByCategory.resourcepacks.push({ fileName, sha1 });
|
||||||
} else if (path.startsWith("shaderpacks/")) {
|
} else if (path.startsWith("shaderpacks/")) {
|
||||||
contents.shaderpacks.push({ name, version });
|
filesByCategory.shaderpacks.push({ fileName, sha1 });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// S3에 업로드 (경로는 ASCII만 사용)
|
// Modrinth API로 모드 정보 가져오기
|
||||||
const safeName = modpackName
|
const allHashes = [
|
||||||
.replace(/[^a-zA-Z0-9-]/g, "_") // 영문, 숫자, 하이픈만 허용
|
...filesByCategory.mods,
|
||||||
.replace(/_+/g, "_") // 연속 언더스코어 정리
|
...filesByCategory.resourcepacks,
|
||||||
.replace(/^_|_$/g, ""); // 앞뒤 언더스코어 제거
|
...filesByCategory.shaderpacks,
|
||||||
const s3Key = `modpacks/${
|
]
|
||||||
safeName || "modpack"
|
.filter((f) => f.sha1)
|
||||||
}/${modpackVersion}.mrpack`;
|
.map((f) => f.sha1);
|
||||||
const { uploadToS3 } = await import("../lib/s3.js");
|
|
||||||
|
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");
|
await uploadToS3("minecraft", s3Key, fileData, "application/zip");
|
||||||
|
|
||||||
// 중복 체크
|
// 중복 체크
|
||||||
|
|
|
||||||
|
|
@ -147,10 +147,33 @@ router.get("/modpacks/:id/download", async (req, res) => {
|
||||||
}
|
}
|
||||||
|
|
||||||
const modpack = rows[0];
|
const modpack = rows[0];
|
||||||
const downloadUrl = `https://s3.caadiq.co.kr/minecraft/${modpack.file_key}`;
|
// file_key의 각 세그먼트를 인코딩
|
||||||
|
const encodedKey = modpack.file_key
|
||||||
|
.split("/")
|
||||||
|
.map((s) => encodeURIComponent(s))
|
||||||
|
.join("/");
|
||||||
|
const downloadUrl = `https://s3.caadiq.co.kr/minecraft/${encodedKey}`;
|
||||||
|
|
||||||
// Content-Disposition 헤더로 파일명 지정하여 리디렉션
|
// file_key에서 파일명 추출 (인코딩 안 된 원본)
|
||||||
res.redirect(downloadUrl);
|
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) {
|
} catch (error) {
|
||||||
console.error("[API] 모드팩 다운로드 실패:", error.message);
|
console.error("[API] 모드팩 다운로드 실패:", error.message);
|
||||||
res.status(500).json({ error: "다운로드 실패" });
|
res.status(500).json({ error: "다운로드 실패" });
|
||||||
|
|
|
||||||
|
|
@ -133,6 +133,7 @@ export default function Admin({ isMobile = false }) {
|
||||||
const [modpacks, setModpacks] = useState([]);
|
const [modpacks, setModpacks] = useState([]);
|
||||||
const [modpackDeleteTarget, setModpackDeleteTarget] = useState(null); // 삭제 확인 다이얼로그용
|
const [modpackDeleteTarget, setModpackDeleteTarget] = useState(null); // 삭제 확인 다이얼로그용
|
||||||
const [modpackLoading, setModpackLoading] = useState(false); // 업로드/삭제 로딩
|
const [modpackLoading, setModpackLoading] = useState(false); // 업로드/삭제 로딩
|
||||||
|
const [isDragging, setIsDragging] = useState(false); // 드래그 상태
|
||||||
|
|
||||||
// 권한 확인
|
// 권한 확인
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
@ -2071,7 +2072,27 @@ export default function Admin({ isMobile = false }) {
|
||||||
{modpackDialogMode === 'upload' && (
|
{modpackDialogMode === 'upload' && (
|
||||||
<div className="mb-4">
|
<div className="mb-4">
|
||||||
<label className="block text-zinc-400 text-sm mb-2">파일 선택 (.mrpack)</label>
|
<label className="block text-zinc-400 text-sm mb-2">파일 선택 (.mrpack)</label>
|
||||||
<label className="border-2 border-dashed border-zinc-700 rounded-xl p-6 text-center hover:border-mc-green/50 transition-colors cursor-pointer block">
|
<label
|
||||||
|
className={`border-2 border-dashed rounded-xl p-6 text-center transition-colors cursor-pointer block ${
|
||||||
|
isDragging ? 'border-mc-green bg-mc-green/10' : 'border-zinc-700 hover:border-mc-green/50'
|
||||||
|
}`}
|
||||||
|
onDragOver={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setIsDragging(true);
|
||||||
|
}}
|
||||||
|
onDragLeave={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setIsDragging(false);
|
||||||
|
}}
|
||||||
|
onDrop={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setIsDragging(false);
|
||||||
|
const file = e.dataTransfer.files?.[0];
|
||||||
|
if (file && file.name.endsWith('.mrpack')) {
|
||||||
|
setModpackFile(file);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
<input
|
<input
|
||||||
type="file"
|
type="file"
|
||||||
accept=".mrpack"
|
accept=".mrpack"
|
||||||
|
|
|
||||||
|
|
@ -26,7 +26,7 @@ const formatDate = (dateString) => {
|
||||||
|
|
||||||
// 모드팩 카드 컴포넌트
|
// 모드팩 카드 컴포넌트
|
||||||
const ModpackCard = ({ modpack, isLatest }) => {
|
const ModpackCard = ({ modpack, isLatest }) => {
|
||||||
const [showChangelog, setShowChangelog] = useState(isLatest);
|
const [showChangelog, setShowChangelog] = useState(false);
|
||||||
const [showContents, setShowContents] = useState(false);
|
const [showContents, setShowContents] = useState(false);
|
||||||
|
|
||||||
const totalMods = modpack.contents.mods.length;
|
const totalMods = modpack.contents.mods.length;
|
||||||
|
|
@ -99,7 +99,7 @@ const ModpackCard = ({ modpack, isLatest }) => {
|
||||||
)}
|
)}
|
||||||
{totalShaderpacks > 0 && (
|
{totalShaderpacks > 0 && (
|
||||||
<span className="px-2.5 py-1 bg-orange-500/20 text-orange-400 text-xs rounded-lg">
|
<span className="px-2.5 py-1 bg-orange-500/20 text-orange-400 text-xs rounded-lg">
|
||||||
셰이더 {totalShaderpacks}개
|
쉐이더 {totalShaderpacks}개
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -108,9 +108,9 @@ const ModpackCard = ({ modpack, isLatest }) => {
|
||||||
{/* 변경 로그 토글 */}
|
{/* 변경 로그 토글 */}
|
||||||
<button
|
<button
|
||||||
onClick={() => setShowChangelog(!showChangelog)}
|
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-400 text-sm transition-colors border-t border-zinc-800"
|
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>
|
<span>체인지 로그</span>
|
||||||
{showChangelog ? <ChevronUp size={16} /> : <ChevronDown size={16} />}
|
{showChangelog ? <ChevronUp size={16} /> : <ChevronDown size={16} />}
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
|
|
@ -125,13 +125,13 @@ const ModpackCard = ({ modpack, isLatest }) => {
|
||||||
className="overflow-hidden"
|
className="overflow-hidden"
|
||||||
>
|
>
|
||||||
<div className="px-5 py-4 bg-zinc-800/30 border-t border-zinc-800">
|
<div className="px-5 py-4 bg-zinc-800/30 border-t border-zinc-800">
|
||||||
<div className="prose prose-sm prose-invert max-w-none">
|
<div className="prose prose-invert max-w-none">
|
||||||
{modpack.changelog.split('\n').map((line, i) => {
|
{modpack.changelog.split('\n').map((line, i) => {
|
||||||
if (line.startsWith('###')) {
|
if (line.startsWith('###')) {
|
||||||
return <h4 key={i} className="text-white font-semibold mt-3 mb-2 text-sm">{line.replace('### ', '')}</h4>;
|
return <h4 key={i} className="text-white font-semibold mt-2 mb-2">{line.replace('### ', '')}</h4>;
|
||||||
}
|
}
|
||||||
if (line.startsWith('- ')) {
|
if (line.startsWith('- ')) {
|
||||||
return <p key={i} className="text-zinc-400 text-sm my-1 pl-3">• {line.replace('- ', '')}</p>;
|
return <p key={i} className="text-zinc-400 my-1.5 pl-4">• {line.replace('- ', '')}</p>;
|
||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
})}
|
})}
|
||||||
|
|
@ -144,7 +144,7 @@ const ModpackCard = ({ modpack, isLatest }) => {
|
||||||
{/* 포함 콘텐츠 토글 */}
|
{/* 포함 콘텐츠 토글 */}
|
||||||
<button
|
<button
|
||||||
onClick={() => setShowContents(!showContents)}
|
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-400 text-sm transition-colors border-t border-zinc-800"
|
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>
|
<span>포함된 콘텐츠</span>
|
||||||
{showContents ? <ChevronUp size={16} /> : <ChevronDown size={16} />}
|
{showContents ? <ChevronUp size={16} /> : <ChevronDown size={16} />}
|
||||||
|
|
@ -163,54 +163,144 @@ const ModpackCard = ({ modpack, isLatest }) => {
|
||||||
<div className="px-5 py-4 bg-zinc-800/30 border-t border-zinc-800">
|
<div className="px-5 py-4 bg-zinc-800/30 border-t border-zinc-800">
|
||||||
{/* 모드 */}
|
{/* 모드 */}
|
||||||
{modpack.contents.mods.length > 0 && (
|
{modpack.contents.mods.length > 0 && (
|
||||||
<div className="mb-4">
|
<div className="mb-6">
|
||||||
<h4 className="text-white font-medium text-sm mb-2 flex items-center gap-2">
|
<h4 className="text-white font-medium text-sm mb-3 flex items-center gap-2">
|
||||||
<Box size={14} className="text-blue-400" />
|
<Box size={14} className="text-blue-400" />
|
||||||
모드
|
모드 ({modpack.contents.mods.length}개)
|
||||||
</h4>
|
</h4>
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-2">
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-2">
|
||||||
{modpack.contents.mods.map((mod, i) => (
|
{[...modpack.contents.mods].sort((a, b) => {
|
||||||
<div key={i} className="flex items-center justify-between px-3 py-2 bg-zinc-800 rounded-lg">
|
const titleA = (typeof a === 'object' ? a.title : a).toLowerCase();
|
||||||
<span className="text-zinc-300 text-sm">{mod.name}</span>
|
const titleB = (typeof b === 'object' ? b.title : b).toLowerCase();
|
||||||
<span className="text-zinc-500 text-xs">{mod.version}</span>
|
return titleA.localeCompare(titleB);
|
||||||
</div>
|
}).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>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* 리소스팩 */}
|
{/* 리소스팩 */}
|
||||||
{modpack.contents.resourcepacks.length > 0 && (
|
{modpack.contents.resourcepacks.length > 0 && (
|
||||||
<div className="mb-4">
|
<div className="mb-6">
|
||||||
<h4 className="text-white font-medium text-sm mb-2 flex items-center gap-2">
|
<h4 className="text-white font-medium text-sm mb-3 flex items-center gap-2">
|
||||||
<Image size={14} className="text-purple-400" />
|
<Image size={14} className="text-purple-400" />
|
||||||
리소스팩
|
리소스팩 ({modpack.contents.resourcepacks.length}개)
|
||||||
</h4>
|
</h4>
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-2">
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-2">
|
||||||
{modpack.contents.resourcepacks.map((pack, i) => (
|
{[...modpack.contents.resourcepacks].sort((a, b) => {
|
||||||
<div key={i} className="flex items-center justify-between px-3 py-2 bg-zinc-800 rounded-lg">
|
const titleA = (typeof a === 'object' ? a.title : a).toLowerCase();
|
||||||
<span className="text-zinc-300 text-sm">{pack.name}</span>
|
const titleB = (typeof b === 'object' ? b.title : b).toLowerCase();
|
||||||
<span className="text-zinc-500 text-xs">{pack.version}</span>
|
return titleA.localeCompare(titleB);
|
||||||
</div>
|
}).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>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* 셰이더 */}
|
{/* 쉐이더 */}
|
||||||
{modpack.contents.shaderpacks.length > 0 && (
|
{modpack.contents.shaderpacks.length > 0 && (
|
||||||
<div>
|
<div>
|
||||||
<h4 className="text-white font-medium text-sm mb-2 flex items-center gap-2">
|
<h4 className="text-white font-medium text-sm mb-3 flex items-center gap-2">
|
||||||
<span className="text-orange-400">✨</span>
|
<span className="text-orange-400">✨</span>
|
||||||
셰이더
|
쉐이더 ({modpack.contents.shaderpacks.length}개)
|
||||||
</h4>
|
</h4>
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-2">
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-2">
|
||||||
{modpack.contents.shaderpacks.map((shader, i) => (
|
{[...modpack.contents.shaderpacks].sort((a, b) => {
|
||||||
<div key={i} className="flex items-center justify-between px-3 py-2 bg-zinc-800 rounded-lg">
|
const titleA = (typeof a === 'object' ? a.title : a).toLowerCase();
|
||||||
<span className="text-zinc-300 text-sm">{shader.name}</span>
|
const titleB = (typeof b === 'object' ? b.title : b).toLowerCase();
|
||||||
<span className="text-zinc-500 text-xs">{shader.version}</span>
|
return titleA.localeCompare(titleB);
|
||||||
</div>
|
}).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>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue