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,
|
||||
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,
|
||||
|
|
|
|||
|
|
@ -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,144 +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(
|
||||
async function uploadToS3(
|
||||
bucket,
|
||||
key,
|
||||
data,
|
||||
contentType = "application/octet-stream"
|
||||
) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const url = new URL(s3Config.endpoint);
|
||||
const path = `/${bucket}/${key}`;
|
||||
const headers = {
|
||||
Host: url.host,
|
||||
"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();
|
||||
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}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* S3(RustFS)에서 파일 다운로드
|
||||
*/
|
||||
function downloadFromS3(bucket, key) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const url = new URL(s3Config.endpoint);
|
||||
const path = `/${bucket}/${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();
|
||||
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);
|
||||
}
|
||||
|
||||
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 파싱
|
||||
for (const part of parts) {
|
||||
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];
|
||||
const dataStart = part.indexOf("\r\n\r\n") + 4;
|
||||
|
||||
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");
|
||||
// UTF-8로 디코딩
|
||||
changelog = Buffer.from(
|
||||
part.slice(dataStart, dataEnd),
|
||||
"binary"
|
||||
|
|
@ -408,37 +412,147 @@ router.post(
|
|||
(k) => k !== "minecraft"
|
||||
) || null;
|
||||
|
||||
// contents 파싱 (mods, resourcepacks, shaderpacks)
|
||||
// contents 파싱 - Modrinth API로 모드 정보 가져오기
|
||||
const contents = { mods: [], resourcepacks: [], shaderpacks: [] };
|
||||
|
||||
// 각 카테고리별 파일 정보 수집 (sha1 해시 포함)
|
||||
const filesByCategory = { mods: [], resourcepacks: [], shaderpacks: [] };
|
||||
for (const file of indexJson.files || []) {
|
||||
const path = file.path;
|
||||
// 파일명에서 이름과 버전 추출 시도 (예: Create-0.5.1.jar)
|
||||
const sha1 = file.hashes?.sha1;
|
||||
const fileName = path
|
||||
.split("/")
|
||||
.pop()
|
||||
.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/")) {
|
||||
contents.mods.push({ name, version });
|
||||
filesByCategory.mods.push({ fileName, sha1 });
|
||||
} else if (path.startsWith("resourcepacks/")) {
|
||||
contents.resourcepacks.push({ name, version });
|
||||
filesByCategory.resourcepacks.push({ fileName, sha1 });
|
||||
} else if (path.startsWith("shaderpacks/")) {
|
||||
contents.shaderpacks.push({ name, version });
|
||||
filesByCategory.shaderpacks.push({ fileName, sha1 });
|
||||
}
|
||||
}
|
||||
|
||||
// S3에 업로드 (경로는 ASCII만 사용)
|
||||
const safeName = modpackName
|
||||
.replace(/[^a-zA-Z0-9-]/g, "_") // 영문, 숫자, 하이픈만 허용
|
||||
.replace(/_+/g, "_") // 연속 언더스코어 정리
|
||||
.replace(/^_|_$/g, ""); // 앞뒤 언더스코어 제거
|
||||
const s3Key = `modpacks/${
|
||||
safeName || "modpack"
|
||||
}/${modpackVersion}.mrpack`;
|
||||
const { uploadToS3 } = await import("../lib/s3.js");
|
||||
// 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");
|
||||
|
||||
// 중복 체크
|
||||
|
|
|
|||
|
|
@ -147,10 +147,33 @@ router.get("/modpacks/:id/download", async (req, res) => {
|
|||
}
|
||||
|
||||
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 헤더로 파일명 지정하여 리디렉션
|
||||
res.redirect(downloadUrl);
|
||||
// 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: "다운로드 실패" });
|
||||
|
|
|
|||
|
|
@ -133,6 +133,7 @@ export default function Admin({ isMobile = false }) {
|
|||
const [modpacks, setModpacks] = useState([]);
|
||||
const [modpackDeleteTarget, setModpackDeleteTarget] = useState(null); // 삭제 확인 다이얼로그용
|
||||
const [modpackLoading, setModpackLoading] = useState(false); // 업로드/삭제 로딩
|
||||
const [isDragging, setIsDragging] = useState(false); // 드래그 상태
|
||||
|
||||
// 권한 확인
|
||||
useEffect(() => {
|
||||
|
|
@ -2071,7 +2072,27 @@ export default function Admin({ isMobile = false }) {
|
|||
{modpackDialogMode === 'upload' && (
|
||||
<div className="mb-4">
|
||||
<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
|
||||
type="file"
|
||||
accept=".mrpack"
|
||||
|
|
|
|||
|
|
@ -26,7 +26,7 @@ const formatDate = (dateString) => {
|
|||
|
||||
// 모드팩 카드 컴포넌트
|
||||
const ModpackCard = ({ modpack, isLatest }) => {
|
||||
const [showChangelog, setShowChangelog] = useState(isLatest);
|
||||
const [showChangelog, setShowChangelog] = useState(false);
|
||||
const [showContents, setShowContents] = useState(false);
|
||||
|
||||
const totalMods = modpack.contents.mods.length;
|
||||
|
|
@ -99,7 +99,7 @@ const ModpackCard = ({ modpack, isLatest }) => {
|
|||
)}
|
||||
{totalShaderpacks > 0 && (
|
||||
<span className="px-2.5 py-1 bg-orange-500/20 text-orange-400 text-xs rounded-lg">
|
||||
셰이더 {totalShaderpacks}개
|
||||
쉐이더 {totalShaderpacks}개
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
|
@ -108,9 +108,9 @@ const ModpackCard = ({ modpack, isLatest }) => {
|
|||
{/* 변경 로그 토글 */}
|
||||
<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-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} />}
|
||||
</button>
|
||||
|
||||
|
|
@ -125,13 +125,13 @@ const ModpackCard = ({ modpack, isLatest }) => {
|
|||
className="overflow-hidden"
|
||||
>
|
||||
<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) => {
|
||||
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('- ')) {
|
||||
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;
|
||||
})}
|
||||
|
|
@ -144,7 +144,7 @@ const ModpackCard = ({ modpack, isLatest }) => {
|
|||
{/* 포함 콘텐츠 토글 */}
|
||||
<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-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>
|
||||
{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">
|
||||
{/* 모드 */}
|
||||
{modpack.contents.mods.length > 0 && (
|
||||
<div className="mb-4">
|
||||
<h4 className="text-white font-medium text-sm mb-2 flex items-center gap-2">
|
||||
<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.map((mod, i) => (
|
||||
<div key={i} className="flex items-center justify-between px-3 py-2 bg-zinc-800 rounded-lg">
|
||||
<span className="text-zinc-300 text-sm">{mod.name}</span>
|
||||
<span className="text-zinc-500 text-xs">{mod.version}</span>
|
||||
</div>
|
||||
))}
|
||||
{[...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-4">
|
||||
<h4 className="text-white font-medium text-sm mb-2 flex items-center gap-2">
|
||||
<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.map((pack, i) => (
|
||||
<div key={i} className="flex items-center justify-between px-3 py-2 bg-zinc-800 rounded-lg">
|
||||
<span className="text-zinc-300 text-sm">{pack.name}</span>
|
||||
<span className="text-zinc-500 text-xs">{pack.version}</span>
|
||||
</div>
|
||||
))}
|
||||
{[...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-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>
|
||||
셰이더
|
||||
쉐이더 ({modpack.contents.shaderpacks.length}개)
|
||||
</h4>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-2">
|
||||
{modpack.contents.shaderpacks.map((shader, i) => (
|
||||
<div key={i} className="flex items-center justify-between px-3 py-2 bg-zinc-800 rounded-lg">
|
||||
<span className="text-zinc-300 text-sm">{shader.name}</span>
|
||||
<span className="text-zinc-500 text-xs">{shader.version}</span>
|
||||
</div>
|
||||
))}
|
||||
{[...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>
|
||||
)}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue