From 10c27eecbac27e3c88a69500dd33fd50eaf16911 Mon Sep 17 00:00:00 2001 From: caadiq Date: Fri, 26 Dec 2025 19:52:35 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20blocks/items=20=ED=85=8C=EC=9D=B4?= =?UTF-8?q?=EB=B8=94=20=ED=86=B5=ED=95=A9=20=EB=B0=8F=20=EC=95=84=EC=9D=B4?= =?UTF-8?q?=EC=BD=98=20=EC=8B=9C=EC=8A=A4=ED=85=9C=20=EA=B0=9C=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - blocks 테이블을 items 테이블로 통합 (type 컬럼 추가) - 아이콘 조회 시 DB에서 먼저 검색하여 모드 아이템 지원 - S3 경로 통일: icons/items/_.png - 번역 업로드/삭제 API 수정 - 번역 캐시 로드 로직 수정 --- backend/lib/db.js | 42 ++++--- backend/lib/icons.js | 52 ++++---- backend/routes/admin.js | 233 +++++++++++++++++++++++++++++------ frontend/src/pages/Admin.jsx | 231 +++++++++++++++++++++++++++++++++- 4 files changed, 479 insertions(+), 79 deletions(-) diff --git a/backend/lib/db.js b/backend/lib/db.js index 61fb192..6e777a6 100644 --- a/backend/lib/db.js +++ b/backend/lib/db.js @@ -22,16 +22,13 @@ let iconsCache = {}; // { "type:name": iconUrl } - 타입별로 구분 let gamerulesCache = {}; /** - * 번역 및 아이콘 데이터 로드 (blocks, items, entities 테이블에서) + * 번역 및 아이콘 데이터 로드 (items, entities 테이블에서) */ async function loadTranslations() { try { - // 모든 모드의 번역 로드 (minecraft 포함) - const [blocks] = await dbPool.query( - "SELECT name, name_ko, mod_id, icon FROM blocks" - ); + // items 테이블에서 모든 항목 로드 (type 컬럼으로 block/item 구분) 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( "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 = {}; - // 타입별로 분리하여 저장 - blocks.forEach((row) => { - translationsCache.blocks[row.name] = row.name_ko; - if (row.icon) iconsCache[`block:${row.name}`] = row.icon; - }); + let blockCount = 0; + let itemCount = 0; + + // items 테이블에서 type에 따라 분리 items.forEach((row) => { - translationsCache.items[row.name] = row.name_ko; - if (row.icon) iconsCache[`item:${row.name}`] = row.icon; + if (row.type === "block") { + 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) => { translationsCache.entities[row.name] = row.name_ko; if (row.icon) iconsCache[`entity:${row.name}`] = row.icon; }); - // 아이템 통계용 캐시 (items + blocks, 아이템 우선) - translationsCache.itemsAndBlocks = {}; + // 아이템 통계용 캐시 (blocks + items, 아이템 우선) Object.assign(translationsCache.itemsAndBlocks, translationsCache.blocks); Object.assign(translationsCache.itemsAndBlocks, translationsCache.items); @@ -74,7 +82,7 @@ async function loadTranslations() { const iconCount = Object.keys(iconsCache).length; 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) { console.error("[DB] 번역 데이터 로드 실패:", error.message); diff --git a/backend/lib/icons.js b/backend/lib/icons.js index 4b48bdb..331e269 100644 --- a/backend/lib/icons.js +++ b/backend/lib/icons.js @@ -42,49 +42,54 @@ function downloadImage(url) { /** * 온디맨드 아이콘 가져오기 (없으면 다운로드/저장) + * 모드 아이템도 지원 - DB에서 먼저 검색 */ -async function getIconUrl(name, type, modId = "minecraft") { +async function getIconUrl(name, type) { 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 형식 - const cacheKey = `${actualType}:${name}`; + const cacheKey = `${type}:${name}`; // 캐시에 있으면 반환 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 결정 let sourceUrl; let s3Key; - if (actualType === "entity") { + if (type === "entity") { const mhfUuid = entityToMHF[name]; if (mhfUuid) { sourceUrl = `https://mc-heads.net/avatar/${mhfUuid}/64`; } else { 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 { + // item과 block 모두 icons/items에 저장 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 { @@ -92,7 +97,6 @@ async function getIconUrl(name, type, modId = "minecraft") { const publicUrl = await uploadToS3(s3Config.bucket, s3Key, imageData); // DB 업데이트 - const table = actualType === "entity" ? "entities" : `${actualType}s`; await dbPool.query( `UPDATE ${table} SET icon = ? WHERE name = ? AND mod_id = ?`, [publicUrl, name, modId] diff --git a/backend/routes/admin.js b/backend/routes/admin.js index 07baebe..97f11db 100644 --- a/backend/routes/admin.js +++ b/backend/routes/admin.js @@ -666,38 +666,16 @@ router.delete("/modpacks/:id", async (req, res) => { */ router.get("/modtranslations", requireAdmin, async (req, res) => { try { - // blocks와 items 테이블에서 minecraft가 아닌 mod_id 그룹화 - const [blockMods] = await pool.query( - `SELECT mod_id, COUNT(*) as count FROM blocks WHERE mod_id != 'minecraft' GROUP BY mod_id` - ); - const [itemMods] = await pool.query( - `SELECT mod_id, COUNT(*) as count FROM items WHERE mod_id != 'minecraft' GROUP 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) - ); + // items 테이블에서 minecraft가 아닌 mod_id별 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 mod_id != 'minecraft' + GROUP BY mod_id + ORDER BY mod_id + `); res.json({ mods }); } catch (error) { console.error("[Admin] 모드 번역 목록 조회 오류:", error); @@ -817,14 +795,14 @@ router.post("/modtranslations", requireAdmin, async (req, res) => { if (type === "block") { 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)`, [name, value, modId] ); blockCount++; } else if (type === "item") { 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)`, [name, value, modId] ); @@ -860,7 +838,6 @@ router.delete("/modtranslations/:modId", requireAdmin, async (req, res) => { const { modId } = req.params; try { - await pool.query(`DELETE FROM blocks WHERE mod_id = ?`, [modId]); await pool.query(`DELETE FROM items WHERE mod_id = ?`, [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/.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; diff --git a/frontend/src/pages/Admin.jsx b/frontend/src/pages/Admin.jsx index 0943f75..165be26 100644 --- a/frontend/src/pages/Admin.jsx +++ b/frontend/src/pages/Admin.jsx @@ -9,7 +9,7 @@ import { useAuth } from '../contexts/AuthContext'; import { Shield, ArrowLeft, ArrowDown, Loader2, Terminal, Users, Settings, 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'; import { motion, AnimatePresence } from 'framer-motion'; 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 [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(() => { 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(() => { if (activeTab === 'modpack') { fetchModTranslations(); + fetchIconMods(); } - }, [activeTab, fetchModTranslations]); + }, [activeTab, fetchModTranslations, fetchIconMods]); // 모드 번역 - 파일 추가 (대기열에 추가) const addPendingFiles = (files) => { @@ -2032,6 +2112,108 @@ export default function Admin({ isMobile = false }) { )} + {/* 아이콘 관리 */} +
+

+ 🖼️ 아이콘 관리 +

+ + {/* 업로드 영역 */} + + + {/* 업로드된 모드 목록 */} + {iconMods.length > 0 && ( +
+ + + {isIconListExpanded && ( + +
+ {iconMods.map((mod) => ( +
+
+
+ +
+
+ {mod.mod_id} + + 블록 {mod.block_count} · 아이템 {mod.item_count} + +
+
+ +
+ ))} +
+
+ )} +
+
+ )} +
+ + {/* 아이콘 삭제 확인 다이얼로그 - 모드 번역 삭제와 동일한 위치에 배치 */} + {/* 모드팩 관리 */}
@@ -2427,6 +2609,49 @@ export default function Admin({ isMobile = false }) { )} + {/* 아이콘 삭제 확인 다이얼로그 */} + + {deleteIconDialog.show && ( + setDeleteIconDialog({ show: false, modId: null })} + > + e.stopPropagation()} + > +

아이콘 초기화

+

+ {deleteIconDialog.modId} 모드의 아이콘을 초기화하시겠습니까? +

+
+ + +
+
+
+ )} +
+ {/* 모드팩 업로드/수정 다이얼로그 */} {showModpackDialog && (