feat: blocks/items 테이블 통합 및 아이콘 시스템 개선
- blocks 테이블을 items 테이블로 통합 (type 컬럼 추가) - 아이콘 조회 시 DB에서 먼저 검색하여 모드 아이템 지원 - S3 경로 통일: icons/items/<modid>_<name>.png - 번역 업로드/삭제 API 수정 - 번역 캐시 로드 로직 수정
This commit is contained in:
parent
9ff2dd957c
commit
10c27eecba
4 changed files with 479 additions and 79 deletions
|
|
@ -22,16 +22,13 @@ let iconsCache = {}; // { "type:name": iconUrl } - 타입별로 구분
|
||||||
let gamerulesCache = {};
|
let gamerulesCache = {};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 번역 및 아이콘 데이터 로드 (blocks, items, entities 테이블에서)
|
* 번역 및 아이콘 데이터 로드 (items, entities 테이블에서)
|
||||||
*/
|
*/
|
||||||
async function loadTranslations() {
|
async function loadTranslations() {
|
||||||
try {
|
try {
|
||||||
// 모든 모드의 번역 로드 (minecraft 포함)
|
// items 테이블에서 모든 항목 로드 (type 컬럼으로 block/item 구분)
|
||||||
const [blocks] = await dbPool.query(
|
|
||||||
"SELECT name, name_ko, mod_id, icon FROM blocks"
|
|
||||||
);
|
|
||||||
const [items] = await dbPool.query(
|
const [items] = await dbPool.query(
|
||||||
"SELECT name, name_ko, mod_id, icon FROM items"
|
"SELECT name, name_ko, mod_id, icon, type FROM items"
|
||||||
);
|
);
|
||||||
const [entities] = await dbPool.query(
|
const [entities] = await dbPool.query(
|
||||||
"SELECT name, name_ko, mod_id, icon FROM entities"
|
"SELECT name, name_ko, mod_id, icon FROM entities"
|
||||||
|
|
@ -41,25 +38,36 @@ async function loadTranslations() {
|
||||||
);
|
);
|
||||||
|
|
||||||
// 캐시 초기화
|
// 캐시 초기화
|
||||||
translationsCache = { blocks: {}, items: {}, entities: {}, all: {} };
|
translationsCache = {
|
||||||
|
blocks: {},
|
||||||
|
items: {},
|
||||||
|
entities: {},
|
||||||
|
itemsAndBlocks: {},
|
||||||
|
};
|
||||||
iconsCache = {};
|
iconsCache = {};
|
||||||
|
|
||||||
// 타입별로 분리하여 저장
|
let blockCount = 0;
|
||||||
blocks.forEach((row) => {
|
let itemCount = 0;
|
||||||
translationsCache.blocks[row.name] = row.name_ko;
|
|
||||||
if (row.icon) iconsCache[`block:${row.name}`] = row.icon;
|
// items 테이블에서 type에 따라 분리
|
||||||
});
|
|
||||||
items.forEach((row) => {
|
items.forEach((row) => {
|
||||||
translationsCache.items[row.name] = row.name_ko;
|
if (row.type === "block") {
|
||||||
if (row.icon) iconsCache[`item:${row.name}`] = row.icon;
|
translationsCache.blocks[row.name] = row.name_ko;
|
||||||
|
if (row.icon) iconsCache[`block:${row.name}`] = row.icon;
|
||||||
|
blockCount++;
|
||||||
|
} else {
|
||||||
|
translationsCache.items[row.name] = row.name_ko;
|
||||||
|
if (row.icon) iconsCache[`item:${row.name}`] = row.icon;
|
||||||
|
itemCount++;
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
entities.forEach((row) => {
|
entities.forEach((row) => {
|
||||||
translationsCache.entities[row.name] = row.name_ko;
|
translationsCache.entities[row.name] = row.name_ko;
|
||||||
if (row.icon) iconsCache[`entity:${row.name}`] = row.icon;
|
if (row.icon) iconsCache[`entity:${row.name}`] = row.icon;
|
||||||
});
|
});
|
||||||
|
|
||||||
// 아이템 통계용 캐시 (items + blocks, 아이템 우선)
|
// 아이템 통계용 캐시 (blocks + items, 아이템 우선)
|
||||||
translationsCache.itemsAndBlocks = {};
|
|
||||||
Object.assign(translationsCache.itemsAndBlocks, translationsCache.blocks);
|
Object.assign(translationsCache.itemsAndBlocks, translationsCache.blocks);
|
||||||
Object.assign(translationsCache.itemsAndBlocks, translationsCache.items);
|
Object.assign(translationsCache.itemsAndBlocks, translationsCache.items);
|
||||||
|
|
||||||
|
|
@ -74,7 +82,7 @@ async function loadTranslations() {
|
||||||
|
|
||||||
const iconCount = Object.keys(iconsCache).length;
|
const iconCount = Object.keys(iconsCache).length;
|
||||||
console.log(
|
console.log(
|
||||||
`[DB] 번역 데이터 로드 완료: blocks ${blocks.length}개, items ${items.length}개, entities ${entities.length}개, icons ${iconCount}개`
|
`[DB] 번역 데이터 로드 완료: blocks ${blockCount}개, items ${itemCount}개, entities ${entities.length}개, icons ${iconCount}개`
|
||||||
);
|
);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("[DB] 번역 데이터 로드 실패:", error.message);
|
console.error("[DB] 번역 데이터 로드 실패:", error.message);
|
||||||
|
|
|
||||||
|
|
@ -42,49 +42,54 @@ function downloadImage(url) {
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 온디맨드 아이콘 가져오기 (없으면 다운로드/저장)
|
* 온디맨드 아이콘 가져오기 (없으면 다운로드/저장)
|
||||||
|
* 모드 아이템도 지원 - DB에서 먼저 검색
|
||||||
*/
|
*/
|
||||||
async function getIconUrl(name, type, modId = "minecraft") {
|
async function getIconUrl(name, type) {
|
||||||
const iconsCache = getIcons();
|
const iconsCache = getIcons();
|
||||||
|
|
||||||
// item 타입일 때 실제로 blocks인지 items인지 DB에서 확인
|
|
||||||
let actualType = type;
|
|
||||||
if (type === "item") {
|
|
||||||
const [itemRows] = await dbPool.query(
|
|
||||||
"SELECT 1 FROM items WHERE name = ? AND mod_id = ? LIMIT 1",
|
|
||||||
[name, modId]
|
|
||||||
);
|
|
||||||
if (itemRows.length === 0) {
|
|
||||||
const [blockRows] = await dbPool.query(
|
|
||||||
"SELECT 1 FROM blocks WHERE name = ? AND mod_id = ? LIMIT 1",
|
|
||||||
[name, modId]
|
|
||||||
);
|
|
||||||
if (blockRows.length > 0) {
|
|
||||||
actualType = "block";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 캐시 키: type:name 형식
|
// 캐시 키: type:name 형식
|
||||||
const cacheKey = `${actualType}:${name}`;
|
const cacheKey = `${type}:${name}`;
|
||||||
|
|
||||||
// 캐시에 있으면 반환
|
// 캐시에 있으면 반환
|
||||||
if (iconsCache[cacheKey]) return iconsCache[cacheKey];
|
if (iconsCache[cacheKey]) return iconsCache[cacheKey];
|
||||||
|
|
||||||
|
// DB에서 해당 아이템 검색 (icon이 있으면 바로 반환)
|
||||||
|
let table = type === "entity" ? "entities" : "items";
|
||||||
|
const [rows] = await dbPool.query(
|
||||||
|
`SELECT icon, mod_id FROM ${table} WHERE name = ? LIMIT 1`,
|
||||||
|
[name]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (rows.length > 0 && rows[0].icon) {
|
||||||
|
// DB에 icon이 이미 있으면 캐시에 저장하고 반환
|
||||||
|
setIconCache(cacheKey, rows[0].icon);
|
||||||
|
return rows[0].icon;
|
||||||
|
}
|
||||||
|
|
||||||
|
// DB에 없거나 icon이 없으면 온디맨드 다운로드 (minecraft 아이템만)
|
||||||
|
const modId = rows.length > 0 ? rows[0].mod_id : "minecraft";
|
||||||
|
|
||||||
|
// minecraft가 아닌 모드 아이템은 외부에서 다운로드 불가
|
||||||
|
if (modId !== "minecraft") {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
// 소스 URL 결정
|
// 소스 URL 결정
|
||||||
let sourceUrl;
|
let sourceUrl;
|
||||||
let s3Key;
|
let s3Key;
|
||||||
|
|
||||||
if (actualType === "entity") {
|
if (type === "entity") {
|
||||||
const mhfUuid = entityToMHF[name];
|
const mhfUuid = entityToMHF[name];
|
||||||
if (mhfUuid) {
|
if (mhfUuid) {
|
||||||
sourceUrl = `https://mc-heads.net/avatar/${mhfUuid}/64`;
|
sourceUrl = `https://mc-heads.net/avatar/${mhfUuid}/64`;
|
||||||
} else {
|
} else {
|
||||||
sourceUrl = `https://mc.nerothe.com/img/1.21/minecraft_${name}_spawn_egg.png`;
|
sourceUrl = `https://mc.nerothe.com/img/1.21/minecraft_${name}_spawn_egg.png`;
|
||||||
}
|
}
|
||||||
s3Key = `icon/entities/${modId}_entity_${name}.png`;
|
s3Key = `icons/entities/${modId}_${name}.png`;
|
||||||
} else {
|
} else {
|
||||||
|
// item과 block 모두 icons/items에 저장
|
||||||
sourceUrl = `https://mc.nerothe.com/img/1.21/minecraft_${name}.png`;
|
sourceUrl = `https://mc.nerothe.com/img/1.21/minecraft_${name}.png`;
|
||||||
s3Key = `icon/${actualType}s/${modId}_${actualType}_${name}.png`;
|
s3Key = `icons/items/${modId}_${name}.png`;
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
|
@ -92,7 +97,6 @@ async function getIconUrl(name, type, modId = "minecraft") {
|
||||||
const publicUrl = await uploadToS3(s3Config.bucket, s3Key, imageData);
|
const publicUrl = await uploadToS3(s3Config.bucket, s3Key, imageData);
|
||||||
|
|
||||||
// DB 업데이트
|
// DB 업데이트
|
||||||
const table = actualType === "entity" ? "entities" : `${actualType}s`;
|
|
||||||
await dbPool.query(
|
await dbPool.query(
|
||||||
`UPDATE ${table} SET icon = ? WHERE name = ? AND mod_id = ?`,
|
`UPDATE ${table} SET icon = ? WHERE name = ? AND mod_id = ?`,
|
||||||
[publicUrl, name, modId]
|
[publicUrl, name, modId]
|
||||||
|
|
|
||||||
|
|
@ -666,38 +666,16 @@ router.delete("/modpacks/:id", async (req, res) => {
|
||||||
*/
|
*/
|
||||||
router.get("/modtranslations", requireAdmin, async (req, res) => {
|
router.get("/modtranslations", requireAdmin, async (req, res) => {
|
||||||
try {
|
try {
|
||||||
// blocks와 items 테이블에서 minecraft가 아닌 mod_id 그룹화
|
// items 테이블에서 minecraft가 아닌 mod_id별 type 집계
|
||||||
const [blockMods] = await pool.query(
|
const [mods] = await pool.query(`
|
||||||
`SELECT mod_id, COUNT(*) as count FROM blocks WHERE mod_id != 'minecraft' GROUP BY mod_id`
|
SELECT mod_id,
|
||||||
);
|
SUM(CASE WHEN type = 'block' THEN 1 ELSE 0 END) as block_count,
|
||||||
const [itemMods] = await pool.query(
|
SUM(CASE WHEN type = 'item' THEN 1 ELSE 0 END) as item_count
|
||||||
`SELECT mod_id, COUNT(*) as count FROM items WHERE mod_id != 'minecraft' GROUP BY mod_id`
|
FROM items
|
||||||
);
|
WHERE mod_id != 'minecraft'
|
||||||
|
GROUP BY mod_id
|
||||||
// 모드별 블록/아이템 수 집계
|
ORDER BY mod_id
|
||||||
const modMap = new Map();
|
`);
|
||||||
for (const row of blockMods) {
|
|
||||||
modMap.set(row.mod_id, {
|
|
||||||
mod_id: row.mod_id,
|
|
||||||
block_count: row.count,
|
|
||||||
item_count: 0,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
for (const row of itemMods) {
|
|
||||||
if (modMap.has(row.mod_id)) {
|
|
||||||
modMap.get(row.mod_id).item_count = row.count;
|
|
||||||
} else {
|
|
||||||
modMap.set(row.mod_id, {
|
|
||||||
mod_id: row.mod_id,
|
|
||||||
block_count: 0,
|
|
||||||
item_count: row.count,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const mods = Array.from(modMap.values()).sort((a, b) =>
|
|
||||||
a.mod_id.localeCompare(b.mod_id)
|
|
||||||
);
|
|
||||||
res.json({ mods });
|
res.json({ mods });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("[Admin] 모드 번역 목록 조회 오류:", error);
|
console.error("[Admin] 모드 번역 목록 조회 오류:", error);
|
||||||
|
|
@ -817,14 +795,14 @@ router.post("/modtranslations", requireAdmin, async (req, res) => {
|
||||||
|
|
||||||
if (type === "block") {
|
if (type === "block") {
|
||||||
await pool.query(
|
await pool.query(
|
||||||
`INSERT INTO blocks (name, name_ko, mod_id) VALUES (?, ?, ?)
|
`INSERT INTO items (name, name_ko, mod_id, type) VALUES (?, ?, ?, 'block')
|
||||||
ON DUPLICATE KEY UPDATE name_ko = VALUES(name_ko)`,
|
ON DUPLICATE KEY UPDATE name_ko = VALUES(name_ko)`,
|
||||||
[name, value, modId]
|
[name, value, modId]
|
||||||
);
|
);
|
||||||
blockCount++;
|
blockCount++;
|
||||||
} else if (type === "item") {
|
} else if (type === "item") {
|
||||||
await pool.query(
|
await pool.query(
|
||||||
`INSERT INTO items (name, name_ko, mod_id) VALUES (?, ?, ?)
|
`INSERT INTO items (name, name_ko, mod_id, type) VALUES (?, ?, ?, 'item')
|
||||||
ON DUPLICATE KEY UPDATE name_ko = VALUES(name_ko)`,
|
ON DUPLICATE KEY UPDATE name_ko = VALUES(name_ko)`,
|
||||||
[name, value, modId]
|
[name, value, modId]
|
||||||
);
|
);
|
||||||
|
|
@ -860,7 +838,6 @@ router.delete("/modtranslations/:modId", requireAdmin, async (req, res) => {
|
||||||
const { modId } = req.params;
|
const { modId } = req.params;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await pool.query(`DELETE FROM blocks WHERE mod_id = ?`, [modId]);
|
|
||||||
await pool.query(`DELETE FROM items WHERE mod_id = ?`, [modId]);
|
await pool.query(`DELETE FROM items WHERE mod_id = ?`, [modId]);
|
||||||
|
|
||||||
console.log(`[Admin] 모드 번역 삭제: ${modId}`);
|
console.log(`[Admin] 모드 번역 삭제: ${modId}`);
|
||||||
|
|
@ -875,4 +852,190 @@ router.delete("/modtranslations/:modId", requireAdmin, async (req, res) => {
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ========================
|
||||||
|
// 아이콘 관리 API
|
||||||
|
// ========================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/admin/icons - 등록된 아이콘 목록 (모드별 집계)
|
||||||
|
* blocks/items 테이블에서 icon이 있는 항목을 모드별로 집계
|
||||||
|
*/
|
||||||
|
router.get("/icons", requireAdmin, async (req, res) => {
|
||||||
|
try {
|
||||||
|
// items 테이블에서 icon이 있는 모드를 type별로 집계
|
||||||
|
const [mods] = await pool.query(`
|
||||||
|
SELECT mod_id,
|
||||||
|
SUM(CASE WHEN type = 'block' THEN 1 ELSE 0 END) as block_count,
|
||||||
|
SUM(CASE WHEN type = 'item' THEN 1 ELSE 0 END) as item_count
|
||||||
|
FROM items
|
||||||
|
WHERE icon IS NOT NULL AND mod_id != 'minecraft'
|
||||||
|
GROUP BY mod_id
|
||||||
|
ORDER BY mod_id
|
||||||
|
`);
|
||||||
|
|
||||||
|
res.json({ mods });
|
||||||
|
} catch (error) {
|
||||||
|
console.error("[Admin] 아이콘 목록 조회 오류:", error);
|
||||||
|
res.status(500).json({ error: "조회 실패" });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /api/admin/icons/upload - 아이콘 ZIP 업로드
|
||||||
|
* IconExporter로 생성된 ZIP 파일을 업로드하여 RustFS에 저장
|
||||||
|
*/
|
||||||
|
router.post(
|
||||||
|
"/icons/upload",
|
||||||
|
express.raw({ type: "multipart/form-data", limit: "100mb" }),
|
||||||
|
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 = "";
|
||||||
|
|
||||||
|
// multipart 파싱
|
||||||
|
for (const part of parts) {
|
||||||
|
if (part.includes('name="file"')) {
|
||||||
|
const headerEnd = part.indexOf("\r\n\r\n");
|
||||||
|
const header = Buffer.from(
|
||||||
|
part.slice(0, headerEnd),
|
||||||
|
"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");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!fileData || !fileName.endsWith(".zip")) {
|
||||||
|
return res.status(400).json({ error: "ZIP 파일이 필요합니다" });
|
||||||
|
}
|
||||||
|
|
||||||
|
// ZIP 파일 처리
|
||||||
|
const AdmZip = (await import("adm-zip")).default;
|
||||||
|
const zip = new AdmZip(fileData);
|
||||||
|
const entries = zip.getEntries();
|
||||||
|
|
||||||
|
const { uploadToS3 } = await import("../lib/s3.js");
|
||||||
|
|
||||||
|
// metadata.json에서 mod_id 읽기
|
||||||
|
let modId = null;
|
||||||
|
const metadataEntry = entries.find(
|
||||||
|
(e) => e.entryName === "metadata.json"
|
||||||
|
);
|
||||||
|
if (metadataEntry) {
|
||||||
|
try {
|
||||||
|
const metadata = JSON.parse(metadataEntry.getData().toString("utf8"));
|
||||||
|
modId = metadata.mod_id;
|
||||||
|
console.log(
|
||||||
|
`[Admin] ZIP metadata: mod_id=${modId}, icon_size=${metadata.icon_size}`
|
||||||
|
);
|
||||||
|
} catch (e) {
|
||||||
|
console.warn("[Admin] metadata.json 파싱 실패:", e.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!modId) {
|
||||||
|
return res
|
||||||
|
.status(400)
|
||||||
|
.json({ error: "metadata.json에 mod_id가 없습니다" });
|
||||||
|
}
|
||||||
|
|
||||||
|
let uploadedCount = 0;
|
||||||
|
let skippedCount = 0;
|
||||||
|
let updatedCount = 0;
|
||||||
|
|
||||||
|
for (const entry of entries) {
|
||||||
|
// 디렉토리 스킵
|
||||||
|
if (entry.isDirectory) continue;
|
||||||
|
|
||||||
|
// PNG 파일만 처리
|
||||||
|
if (!entry.entryName.endsWith(".png")) continue;
|
||||||
|
|
||||||
|
// 새 ZIP 구조: items/<name>.png
|
||||||
|
const pathParts = entry.entryName.split("/");
|
||||||
|
// items/brass_casing.png -> brass_casing
|
||||||
|
const itemId = pathParts[pathParts.length - 1].replace(".png", "");
|
||||||
|
|
||||||
|
// icons/items에 저장: modid_name.png
|
||||||
|
const s3Key = `icons/items/${modId}_${itemId}.png`;
|
||||||
|
|
||||||
|
// 파일 업로드
|
||||||
|
const imageData = entry.getData();
|
||||||
|
try {
|
||||||
|
await uploadToS3("minecraft", s3Key, imageData, "image/png");
|
||||||
|
} catch (s3Error) {
|
||||||
|
console.error(
|
||||||
|
`[Admin] S3 업로드 실패: ${s3Key} - ${s3Error.message}`
|
||||||
|
);
|
||||||
|
skippedCount++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// items 테이블의 icon 컬럼 업데이트 (block과 item 모두)
|
||||||
|
const iconUrl = `https://s3.caadiq.co.kr/minecraft/${s3Key}`;
|
||||||
|
|
||||||
|
const [result] = await pool.query(
|
||||||
|
`UPDATE items SET icon = ? WHERE mod_id = ? AND name = ?`,
|
||||||
|
[iconUrl, modId, itemId]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (result.affectedRows > 0) {
|
||||||
|
updatedCount++;
|
||||||
|
}
|
||||||
|
|
||||||
|
uploadedCount++;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(
|
||||||
|
`[Admin] 아이콘 업로드 완료 [${modId}]: ${uploadedCount}개 업로드, ${updatedCount}개 DB 업데이트, ${skippedCount}개 스킵`
|
||||||
|
);
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
uploaded: uploadedCount,
|
||||||
|
updated: updatedCount,
|
||||||
|
skipped: skippedCount,
|
||||||
|
modId: modId,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error("[Admin] 아이콘 업로드 오류:", error);
|
||||||
|
res.status(500).json({ error: "업로드 실패: " + error.message });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DELETE /api/admin/icons/:modId - 특정 모드 아이콘 초기화
|
||||||
|
* blocks/items 테이블의 icon 컬럼을 NULL로 설정
|
||||||
|
*/
|
||||||
|
router.delete("/icons/:modId", requireAdmin, async (req, res) => {
|
||||||
|
const { modId } = req.params;
|
||||||
|
|
||||||
|
try {
|
||||||
|
// items 테이블의 icon 컬럼 NULL로 업데이트
|
||||||
|
const [result] = await pool.query(
|
||||||
|
`UPDATE items SET icon = NULL WHERE mod_id = ? AND icon IS NOT NULL`,
|
||||||
|
[modId]
|
||||||
|
);
|
||||||
|
|
||||||
|
console.log(`[Admin] 아이콘 초기화: ${modId} (${result.affectedRows}개)`);
|
||||||
|
|
||||||
|
res.json({ success: true, deleted: result.affectedRows });
|
||||||
|
} catch (error) {
|
||||||
|
console.error("[Admin] 아이콘 초기화 오류:", error);
|
||||||
|
res.status(500).json({ error: "삭제 실패" });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
export default router;
|
export default router;
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,7 @@ import { useAuth } from '../contexts/AuthContext';
|
||||||
import {
|
import {
|
||||||
Shield, ArrowLeft, ArrowDown, Loader2, Terminal, Users, Settings,
|
Shield, ArrowLeft, ArrowDown, Loader2, Terminal, Users, Settings,
|
||||||
Send, Ban, UserX, Crown, Sun, Moon, Cloud, CloudRain, CloudLightning,
|
Send, Ban, UserX, Crown, Sun, Moon, Cloud, CloudRain, CloudLightning,
|
||||||
ChevronDown, FileText, Download, Trash2, Check, RefreshCw, Eye, X, Package, Upload, Plus, Pencil
|
ChevronDown, FileText, Download, Trash2, Check, RefreshCw, Eye, X, Package, Upload, Plus, Pencil, Image
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { motion, AnimatePresence } from 'framer-motion';
|
import { motion, AnimatePresence } from 'framer-motion';
|
||||||
import { io } from 'socket.io-client';
|
import { io } from 'socket.io-client';
|
||||||
|
|
@ -149,6 +149,13 @@ export default function Admin({ isMobile = false }) {
|
||||||
const [deleteModDialog, setDeleteModDialog] = useState({ show: false, modId: null }); // 모드 삭제 확인 다이얼로그
|
const [deleteModDialog, setDeleteModDialog] = useState({ show: false, modId: null }); // 모드 삭제 확인 다이얼로그
|
||||||
const [clearingFiles, setClearingFiles] = useState(false); // 완료 항목 삭제 중 (애니메이션용)
|
const [clearingFiles, setClearingFiles] = useState(false); // 완료 항목 삭제 중 (애니메이션용)
|
||||||
|
|
||||||
|
// 아이콘 관리 상태
|
||||||
|
const [iconMods, setIconMods] = useState([]); // 등록된 아이콘 모드 목록
|
||||||
|
const [iconUploading, setIconUploading] = useState(false);
|
||||||
|
const [isIconDragging, setIsIconDragging] = useState(false);
|
||||||
|
const [deleteIconDialog, setDeleteIconDialog] = useState({ show: false, modId: null });
|
||||||
|
const [isIconListExpanded, setIsIconListExpanded] = useState(false);
|
||||||
|
|
||||||
// 권한 확인
|
// 권한 확인
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!loading) {
|
if (!loading) {
|
||||||
|
|
@ -301,12 +308,85 @@ export default function Admin({ isMobile = false }) {
|
||||||
}
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// 모드팩 탭 활성화 시 번역 목록 로드
|
// 아이콘 모드 목록 조회
|
||||||
|
const fetchIconMods = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
const token = localStorage.getItem('token');
|
||||||
|
const res = await fetch('/api/admin/icons', {
|
||||||
|
headers: { 'Authorization': `Bearer ${token}` }
|
||||||
|
});
|
||||||
|
const data = await res.json();
|
||||||
|
if (data.mods) {
|
||||||
|
setIconMods(data.mods);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('아이콘 목록 로드 실패:', error);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// 아이콘 ZIP 업로드
|
||||||
|
const handleIconZipUpload = async (file) => {
|
||||||
|
if (!file || !file.name.endsWith('.zip')) {
|
||||||
|
setToast('ZIP 파일만 업로드 가능합니다', true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setIconUploading(true);
|
||||||
|
try {
|
||||||
|
const token = localStorage.getItem('token');
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append('file', file);
|
||||||
|
|
||||||
|
const res = await fetch('/api/admin/icons/upload', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Authorization': `Bearer ${token}` },
|
||||||
|
body: formData
|
||||||
|
});
|
||||||
|
|
||||||
|
const data = await res.json();
|
||||||
|
if (data.success) {
|
||||||
|
setToast(`아이콘 업로드 완료: ${data.uploaded}개 업로드, ${data.updated}개 DB 업데이트`);
|
||||||
|
fetchIconMods();
|
||||||
|
} else {
|
||||||
|
setToast(data.error || '업로드 실패', true);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('아이콘 업로드 실패:', error);
|
||||||
|
setToast('업로드 중 오류 발생', true);
|
||||||
|
} finally {
|
||||||
|
setIconUploading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 아이콘 모드 삭제
|
||||||
|
const deleteIconMod = async (modId) => {
|
||||||
|
try {
|
||||||
|
const token = localStorage.getItem('token');
|
||||||
|
const res = await fetch(`/api/admin/icons/${modId}`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
headers: { 'Authorization': `Bearer ${token}` }
|
||||||
|
});
|
||||||
|
|
||||||
|
const data = await res.json();
|
||||||
|
if (data.success) {
|
||||||
|
setToast(`${modId} 모드 아이콘 ${data.deleted}개 초기화`);
|
||||||
|
fetchIconMods();
|
||||||
|
} else {
|
||||||
|
setToast(data.error || '삭제 실패', true);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('아이콘 삭제 실패:', error);
|
||||||
|
setToast('삭제 중 오류 발생', true);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 모드팩 탭 활성화 시 번역 목록 + 아이콘 목록 로드
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (activeTab === 'modpack') {
|
if (activeTab === 'modpack') {
|
||||||
fetchModTranslations();
|
fetchModTranslations();
|
||||||
|
fetchIconMods();
|
||||||
}
|
}
|
||||||
}, [activeTab, fetchModTranslations]);
|
}, [activeTab, fetchModTranslations, fetchIconMods]);
|
||||||
|
|
||||||
// 모드 번역 - 파일 추가 (대기열에 추가)
|
// 모드 번역 - 파일 추가 (대기열에 추가)
|
||||||
const addPendingFiles = (files) => {
|
const addPendingFiles = (files) => {
|
||||||
|
|
@ -2032,6 +2112,108 @@ export default function Admin({ isMobile = false }) {
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* 아이콘 관리 */}
|
||||||
|
<div className="bg-zinc-900 border border-zinc-800 rounded-2xl p-4">
|
||||||
|
<h3 className="text-white font-medium mb-4 flex items-center gap-2">
|
||||||
|
🖼️ 아이콘 관리
|
||||||
|
</h3>
|
||||||
|
|
||||||
|
{/* 업로드 영역 */}
|
||||||
|
<label
|
||||||
|
className={`flex flex-col items-center justify-center p-6 border-2 border-dashed rounded-xl cursor-pointer transition-colors ${
|
||||||
|
isIconDragging
|
||||||
|
? 'border-emerald-500 bg-emerald-500/10'
|
||||||
|
: 'border-zinc-700 hover:border-emerald-500 hover:bg-zinc-800/50'
|
||||||
|
} ${iconUploading ? 'pointer-events-none opacity-50' : ''}`}
|
||||||
|
onDragOver={(e) => { e.preventDefault(); setIsIconDragging(true); }}
|
||||||
|
onDragLeave={() => setIsIconDragging(false)}
|
||||||
|
onDrop={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setIsIconDragging(false);
|
||||||
|
const file = e.dataTransfer.files[0];
|
||||||
|
if (file) handleIconZipUpload(file);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="file"
|
||||||
|
accept=".zip"
|
||||||
|
className="hidden"
|
||||||
|
onChange={(e) => {
|
||||||
|
const file = e.target.files[0];
|
||||||
|
if (file) handleIconZipUpload(file);
|
||||||
|
e.target.value = '';
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
{iconUploading ? (
|
||||||
|
<Loader2 className="w-8 h-8 mb-2 text-emerald-400 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<Upload className={`w-8 h-8 mb-2 ${isIconDragging ? 'text-emerald-400' : 'text-zinc-500'}`} />
|
||||||
|
)}
|
||||||
|
<span className="text-zinc-400 text-sm text-center">
|
||||||
|
{iconUploading ? '업로드 중...' : 'IconExporter ZIP 파일을 드래그하거나 클릭하여 추가'}
|
||||||
|
</span>
|
||||||
|
<span className="text-zinc-600 text-xs mt-1">
|
||||||
|
metadata.json이 포함된 ZIP 파일
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
{/* 업로드된 모드 목록 */}
|
||||||
|
{iconMods.length > 0 && (
|
||||||
|
<div className="mt-4 border border-zinc-800 rounded-xl overflow-hidden">
|
||||||
|
<button
|
||||||
|
onClick={() => setIsIconListExpanded(!isIconListExpanded)}
|
||||||
|
className="flex items-center justify-between w-full px-4 py-3 bg-zinc-800/50 hover:bg-zinc-800 transition-colors"
|
||||||
|
>
|
||||||
|
<span className="text-zinc-300 text-sm font-medium">등록된 모드 ({iconMods.length}개)</span>
|
||||||
|
<ChevronDown
|
||||||
|
size={16}
|
||||||
|
className={`text-zinc-400 transition-transform duration-200 ${isIconListExpanded ? 'rotate-180' : ''}`}
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
<AnimatePresence>
|
||||||
|
{isIconListExpanded && (
|
||||||
|
<motion.div
|
||||||
|
initial={{ height: 0, opacity: 0 }}
|
||||||
|
animate={{ height: 'auto', opacity: 1 }}
|
||||||
|
exit={{ height: 0, opacity: 0 }}
|
||||||
|
transition={{ duration: 0.2 }}
|
||||||
|
className="overflow-hidden"
|
||||||
|
>
|
||||||
|
<div className="p-2 space-y-1 bg-zinc-900/50">
|
||||||
|
{iconMods.map((mod) => (
|
||||||
|
<div
|
||||||
|
key={mod.mod_id}
|
||||||
|
className="flex items-center justify-between p-2.5 bg-zinc-800/30 hover:bg-zinc-800/50 rounded-lg transition-colors"
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="p-1.5 bg-emerald-500/20 rounded-md">
|
||||||
|
<Image size={14} className="text-emerald-400" />
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-white text-sm">{mod.mod_id}</span>
|
||||||
|
<span className="text-zinc-500 text-xs">
|
||||||
|
블록 {mod.block_count} · 아이템 {mod.item_count}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => setDeleteIconDialog({ show: true, modId: mod.mod_id })}
|
||||||
|
className="p-1 text-zinc-500 hover:text-red-400 transition-colors"
|
||||||
|
>
|
||||||
|
<Trash2 size={14} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 아이콘 삭제 확인 다이얼로그 - 모드 번역 삭제와 동일한 위치에 배치 */}
|
||||||
|
|
||||||
{/* 모드팩 관리 */}
|
{/* 모드팩 관리 */}
|
||||||
<div className="bg-zinc-900 border border-zinc-800 rounded-2xl p-4">
|
<div className="bg-zinc-900 border border-zinc-800 rounded-2xl p-4">
|
||||||
<div className="flex items-center justify-between mb-4">
|
<div className="flex items-center justify-between mb-4">
|
||||||
|
|
@ -2427,6 +2609,49 @@ export default function Admin({ isMobile = false }) {
|
||||||
)}
|
)}
|
||||||
</AnimatePresence>
|
</AnimatePresence>
|
||||||
|
|
||||||
|
{/* 아이콘 삭제 확인 다이얼로그 */}
|
||||||
|
<AnimatePresence>
|
||||||
|
{deleteIconDialog.show && (
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0 }}
|
||||||
|
animate={{ opacity: 1 }}
|
||||||
|
exit={{ opacity: 0 }}
|
||||||
|
className="fixed inset-0 bg-black/60 backdrop-blur-sm flex items-center justify-center z-50"
|
||||||
|
onClick={() => setDeleteIconDialog({ show: false, modId: null })}
|
||||||
|
>
|
||||||
|
<motion.div
|
||||||
|
initial={{ scale: 0.9, opacity: 0 }}
|
||||||
|
animate={{ scale: 1, opacity: 1 }}
|
||||||
|
exit={{ scale: 0.9, opacity: 0 }}
|
||||||
|
className="bg-zinc-900 border border-zinc-800 rounded-2xl p-6 w-full max-w-sm mx-4"
|
||||||
|
onClick={e => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
<h3 className="text-white text-lg font-medium mb-2">아이콘 초기화</h3>
|
||||||
|
<p className="text-zinc-400 text-sm mb-6">
|
||||||
|
<span className="text-emerald-400 font-medium">{deleteIconDialog.modId}</span> 모드의 아이콘을 초기화하시겠습니까?
|
||||||
|
</p>
|
||||||
|
<div className="flex gap-3">
|
||||||
|
<button
|
||||||
|
onClick={() => setDeleteIconDialog({ show: false, modId: null })}
|
||||||
|
className="flex-1 py-2.5 bg-zinc-800 hover:bg-zinc-700 text-white font-medium rounded-xl transition-colors"
|
||||||
|
>
|
||||||
|
취소
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={async () => {
|
||||||
|
await deleteIconMod(deleteIconDialog.modId);
|
||||||
|
setDeleteIconDialog({ show: false, modId: null });
|
||||||
|
}}
|
||||||
|
className="flex-1 py-2.5 bg-red-600 hover:bg-red-500 text-white font-medium rounded-xl transition-colors"
|
||||||
|
>
|
||||||
|
초기화
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
|
|
||||||
{/* 모드팩 업로드/수정 다이얼로그 */}
|
{/* 모드팩 업로드/수정 다이얼로그 */}
|
||||||
<AnimatePresence>
|
<AnimatePresence>
|
||||||
{showModpackDialog && (
|
{showModpackDialog && (
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue