feat: 모드팩 파일명 기능 개선

- AWS SDK 사용으로 UTF-8 파일명 지원 (한글/특수문자)
- 원본 파일명으로 S3 저장 및 다운로드
- multipart 헤더 UTF-8 디코딩 (파일명 깨짐 수정)
- 드래그앤드롭 시각적 피드백 추가
- 모드/리소스팩/쉐이더 ABC순 정렬
This commit is contained in:
caadiq 2025-12-23 21:58:45 +09:00
parent 00be44fc33
commit 259cd1449c
6 changed files with 370 additions and 184 deletions

View file

@ -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,

View file

@ -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 };

View file

@ -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");
// 중복 체크 // 중복 체크

View file

@ -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: "다운로드 실패" });

View file

@ -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"

View file

@ -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>
)} )}