diff --git a/backend/lib/db.js b/backend/lib/db.js index 5c39101..61fb192 100644 --- a/backend/lib/db.js +++ b/backend/lib/db.js @@ -26,14 +26,15 @@ let gamerulesCache = {}; */ async function loadTranslations() { try { + // 모든 모드의 번역 로드 (minecraft 포함) const [blocks] = await dbPool.query( - "SELECT name, name_ko, icon FROM blocks WHERE mod_id = 'minecraft'" + "SELECT name, name_ko, mod_id, icon FROM blocks" ); const [items] = await dbPool.query( - "SELECT name, name_ko, icon FROM items WHERE mod_id = 'minecraft'" + "SELECT name, name_ko, mod_id, icon FROM items" ); const [entities] = await dbPool.query( - "SELECT name, name_ko, icon FROM entities WHERE mod_id = 'minecraft'" + "SELECT name, name_ko, mod_id, icon FROM entities" ); const [gamerules] = await dbPool.query( "SELECT name, name_ko, description_ko FROM gamerules WHERE mod_id = 'minecraft'" diff --git a/backend/routes/admin.js b/backend/routes/admin.js index 779d877..2c1462d 100644 --- a/backend/routes/admin.js +++ b/backend/routes/admin.js @@ -6,7 +6,7 @@ import express from "express"; import jwt from "jsonwebtoken"; -import { pool } from "../lib/db.js"; +import { pool, loadTranslations } from "../lib/db.js"; const router = express.Router(); @@ -657,4 +657,219 @@ router.delete("/modpacks/:id", async (req, res) => { } }); +// ======================== +// 모드 번역 API +// ======================== + +/** + * GET /api/admin/modtranslations - 업로드된 모드 번역 목록 + */ +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) + ); + res.json({ mods }); + } catch (error) { + console.error("[Admin] 모드 번역 목록 조회 오류:", error); + res.status(500).json({ error: "조회 실패" }); + } +}); + +/** + * POST /api/admin/modtranslations - JAR 파일에서 번역 추출 + */ +router.post("/modtranslations", requireAdmin, async (req, res) => { + try { + // multipart 파싱 (기존 모드팩 업로드와 동일한 방식) + const contentType = req.headers["content-type"] || ""; + if (!contentType.includes("multipart/form-data")) { + return res + .status(400) + .json({ error: "multipart/form-data 형식으로 전송해주세요" }); + } + + const boundary = contentType.split("boundary=")[1]; + if (!boundary) { + return res.status(400).json({ error: "boundary가 없습니다" }); + } + + const chunks = []; + for await (const chunk of req) { + chunks.push(chunk); + } + const body = Buffer.concat(chunks); + + // 파일 데이터 추출 + const parts = body.toString("binary").split("--" + boundary); + let fileData = null; + let fileName = ""; + + for (const part of parts) { + if (part.includes('name="file"')) { + const match = part.match(/filename="([^"]+)"/); + if (match) { + fileName = match[1]; + } + const headerEnd = part.indexOf("\r\n\r\n"); + if (headerEnd !== -1) { + const dataStart = headerEnd + 4; + const dataEnd = part.lastIndexOf("\r\n"); + fileData = Buffer.from(part.substring(dataStart, dataEnd), "binary"); + } + } + } + + if (!fileData || !fileName.endsWith(".jar")) { + return res.status(400).json({ error: "JAR 파일이 필요합니다" }); + } + + // 임시 파일로 저장 + const fs = await import("fs/promises"); + const path = await import("path"); + const { execSync } = await import("child_process"); + const os = await import("os"); + + const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "mod-")); + const jarPath = path.join(tempDir, fileName); + await fs.writeFile(jarPath, fileData); + + // ko_kr.json 추출 + let koKrJson = null; + let modId = null; + + try { + // JAR 내 ko_kr.json 찾기 + const listOutput = execSync( + `unzip -l "${jarPath}" 2>/dev/null | grep "ko_kr.json"`, + { encoding: "utf-8" } + ); + const koKrPath = listOutput.trim().split(/\s+/).pop(); + + if (koKrPath) { + // mod_id 추출 (assets//lang/ko_kr.json) + const pathParts = koKrPath.split("/"); + if (pathParts.length >= 3 && pathParts[0] === "assets") { + modId = pathParts[1]; + } + + // JSON 추출 + const jsonContent = execSync(`unzip -p "${jarPath}" "${koKrPath}"`, { + encoding: "utf-8", + }); + koKrJson = JSON.parse(jsonContent); + } + } catch (e) { + console.error("[Admin] ko_kr.json 추출 실패:", e.message); + } + + // 임시 파일 정리 + await fs.rm(tempDir, { recursive: true, force: true }); + + if (!koKrJson || !modId) { + return res.status(400).json({ error: "ko_kr.json을 찾을 수 없습니다" }); + } + + // block.*, item.* 키 추출 및 DB 저장 + let blockCount = 0; + let itemCount = 0; + + for (const [key, value] of Object.entries(koKrJson)) { + const parts = key.split("."); + if (parts.length >= 3) { + const type = parts[0]; // block 또는 item + const keyModId = parts[1]; // 모드 ID + const name = parts.slice(2).join("."); // 나머지는 이름 + + if (keyModId !== modId) continue; // 다른 모드 키는 무시 + + if (type === "block") { + await pool.query( + `INSERT INTO blocks (name, name_ko, mod_id) VALUES (?, ?, ?) + 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 (?, ?, ?) + ON DUPLICATE KEY UPDATE name_ko = VALUES(name_ko)`, + [name, value, modId] + ); + itemCount++; + } + } + } + + console.log( + `[Admin] 모드 번역 추출 완료: ${modId} (blocks: ${blockCount}, items: ${itemCount})` + ); + + // 번역 캐시 새로고침 + await loadTranslations(); + + res.json({ + success: true, + mod_id: modId, + count: blockCount + itemCount, + block_count: blockCount, + item_count: itemCount, + }); + } catch (error) { + console.error("[Admin] 모드 번역 업로드 오류:", error); + res.status(500).json({ error: "업로드 실패: " + error.message }); + } +}); + +/** + * DELETE /api/admin/modtranslations/:modId - 모드 번역 삭제 + */ +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}`); + + // 번역 캐시 새로고침 + await loadTranslations(); + + res.json({ success: true }); + } 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 03236af..d53d4f6 100644 --- a/frontend/src/pages/Admin.jsx +++ b/frontend/src/pages/Admin.jsx @@ -56,8 +56,12 @@ export default function Admin({ isMobile = false }) { const navigate = useNavigate(); const location = useLocation(); const [toast, setToastState] = useState(null); - // 토스트 헬퍼 함수 (isError: true면 에러 스타일) - const setToast = (message, isError = false) => setToastState({ message, isError }); + // 토스트 헬퍼 함수 (type: 'success' | 'warning' | 'error' | boolean) + const setToast = (message, type = 'success') => { + // 이전 호환성: true면 error, false면 success + const colorType = type === true ? 'error' : (type === false ? 'success' : type); + setToastState({ message, type: colorType }); + }; // 탭 상태 (URL 해시에서 초기값 로드) const getInitialTab = () => { @@ -135,6 +139,16 @@ export default function Admin({ isMobile = false }) { const [modpackLoading, setModpackLoading] = useState(false); // 업로드/삭제 로딩 const [isDragging, setIsDragging] = useState(false); // 드래그 상태 + // 모드 번역 상태 + const [modTranslations, setModTranslations] = useState([]); // 업로드된 모드 목록 + const [translationLoading, setTranslationLoading] = useState(false); + const [isTranslationDragging, setIsTranslationDragging] = useState(false); + // 파일 상태: { name, status: 'pending' | 'processing' | 'success' | 'error', error?: string, progress?: number } + const [pendingFiles, setPendingFiles] = useState([]); + const [isModListExpanded, setIsModListExpanded] = useState(false); // 등록된 모드 목록 펼치기/접기 + const [deleteModDialog, setDeleteModDialog] = useState({ show: false, modId: null }); // 모드 삭제 확인 다이얼로그 + const [clearingFiles, setClearingFiles] = useState(false); // 완료 항목 삭제 중 (애니메이션용) + // 권한 확인 useEffect(() => { if (!loading) { @@ -271,6 +285,136 @@ export default function Admin({ isMobile = false }) { } }; + // 모드 번역 목록 조회 + const fetchModTranslations = useCallback(async () => { + try { + const token = localStorage.getItem('token'); + const res = await fetch('/api/admin/modtranslations', { + headers: { 'Authorization': `Bearer ${token}` } + }); + const data = await res.json(); + if (data.mods) { + setModTranslations(data.mods); + } + } catch (error) { + console.error('모드 번역 목록 로드 실패:', error); + } + }, []); + + // 모드팩 탭 활성화 시 번역 목록 로드 + useEffect(() => { + if (activeTab === 'modpack') { + fetchModTranslations(); + } + }, [activeTab, fetchModTranslations]); + + // 모드 번역 - 파일 추가 (대기열에 추가) + const addPendingFiles = (files) => { + const jarFiles = Array.from(files).filter(f => f.name.endsWith('.jar')); + if (jarFiles.length === 0) { + setToast('JAR 파일만 업로드 가능합니다', true); + return; + } + // 중복 제거 후 상태 객체로 추가 + setPendingFiles(prev => { + const existingNames = new Set(prev.map(f => f.name)); + const newFiles = jarFiles + .filter(f => !existingNames.has(f.name)) + .map(f => ({ file: f, name: f.name, status: 'pending', error: null })); + return [...prev, ...newFiles]; + }); + }; + + // 모드 번역 - 대기열에서 파일 제거 + const removePendingFile = (fileName) => { + setPendingFiles(prev => prev.filter(f => f.name !== fileName)); + }; + + // 모드 번역 - 파일 상태 업데이트 + const updateFileStatus = (fileName, status, error = null) => { + setPendingFiles(prev => prev.map(f => + f.name === fileName ? { ...f, status, error } : f + )); + }; + + // 모드 번역 - 일괄 업로드 시작 + const startTranslationUpload = async () => { + const filesToUpload = pendingFiles.filter(f => f.status === 'pending'); + if (filesToUpload.length === 0) return; + + setTranslationLoading(true); + const token = localStorage.getItem('token'); + + // 로컬 카운터 사용 (상태는 비동기로 업데이트되므로) + let successCount = 0; + let failCount = 0; + + for (const fileObj of filesToUpload) { + updateFileStatus(fileObj.name, 'processing'); + + try { + const formData = new FormData(); + formData.append('file', fileObj.file); + + const res = await fetch('/api/admin/modtranslations', { + method: 'POST', + headers: { 'Authorization': `Bearer ${token}` }, + body: formData + }); + + const result = await res.json(); + if (result.success) { + updateFileStatus(fileObj.name, 'success'); + successCount++; + } else { + updateFileStatus(fileObj.name, 'error', result.error || '추출 실패'); + failCount++; + } + } catch (error) { + updateFileStatus(fileObj.name, 'error', error.message); + failCount++; + } + } + + setTranslationLoading(false); + fetchModTranslations(); + + // 결과 토스트 (색상: 성공만=초록, 혼합=노랑, 실패만=빨강) + if (successCount > 0 && failCount === 0) { + setToast(`${successCount}개 모드 번역 추가 완료!`, 'success'); + } else if (successCount > 0 && failCount > 0) { + setToast(`완료: ${successCount}개 성공, ${failCount}개 실패`, 'warning'); + } else { + setToast(`${failCount}개 모드 번역 추출 실패`, 'error'); + } + }; + + // 모드 번역 - 완료된 항목 지우기 + const clearCompletedFiles = () => { + setPendingFiles(prev => prev.filter(f => f.status === 'pending' || f.status === 'processing')); + }; + + // 모드 번역 삭제 + const handleDeleteTranslation = async (modId) => { + try { + const token = localStorage.getItem('token'); + const res = await fetch(`/api/admin/modtranslations/${modId}`, { + method: 'DELETE', + headers: { 'Authorization': `Bearer ${token}` } + }); + + const result = await res.json(); + if (result.success) { + setToast(`${modId} 번역 삭제됨`); + fetchModTranslations(); + } else { + setToast(result.error || '삭제 실패', true); + } + } catch (error) { + setToast('삭제 실패: ' + error.message, true); + } + }; + // 플레이어 목록 fetch (안정적인 참조) const fetchPlayers = useCallback(async () => { try { @@ -905,7 +1049,7 @@ export default function Admin({ isMobile = false }) { initial={{ opacity: 0, y: 50 }} animate={{ opacity: 1, y: 0 }} exit={{ opacity: 0, y: 50 }} - className={`fixed bottom-8 inset-x-0 mx-auto w-fit z-[200] backdrop-blur-sm text-white px-6 py-3 rounded-xl text-center font-medium shadow-lg ${toast.isError ? 'bg-red-500/90' : 'bg-mc-green/90'}`} + className={`fixed bottom-8 inset-x-0 mx-auto w-fit z-[200] backdrop-blur-sm text-white px-6 py-3 rounded-xl text-center font-medium shadow-lg ${toast.type === 'error' ? 'bg-red-500/90' : toast.type === 'warning' ? 'bg-amber-500/90' : 'bg-mc-green/90'}`} > {toast.message} @@ -935,7 +1079,7 @@ export default function Admin({ isMobile = false }) { initial={{ opacity: 0, y: 50 }} animate={{ opacity: 1, y: 0 }} exit={{ opacity: 0, y: 50 }} - className={`fixed bottom-8 inset-x-0 mx-auto w-fit z-[200] backdrop-blur-sm text-white px-6 py-3 rounded-xl text-center font-medium shadow-lg ${toast.isError ? 'bg-red-500/90' : 'bg-mc-green/90'}`} + className={`fixed bottom-8 inset-x-0 mx-auto w-fit z-[200] backdrop-blur-sm text-white px-6 py-3 rounded-xl text-center font-medium shadow-lg ${toast.type === 'error' ? 'bg-red-500/90' : toast.type === 'warning' ? 'bg-amber-500/90' : 'bg-mc-green/90'}`} > {toast.message} @@ -1691,6 +1835,201 @@ export default function Admin({ isMobile = false }) { exit={{ opacity: 0, y: -10 }} className="space-y-4" > + {/* 모드 번역 */} +
+

+ 🌐 모드 번역 + {modTranslations.length > 0 && ( + + ({modTranslations.length}개 모드) + + )} +

+ + {/* 업로드 영역 */} + + + {/* 대기열 목록 */} + {pendingFiles.length > 0 && ( +
+
+ + 파일 목록 ({pendingFiles.length}개) + + +
+
+ {pendingFiles.map((fileObj) => { + // 상태별 스타일 + const statusStyles = { + pending: 'bg-zinc-800/50', + processing: 'bg-purple-600/30 border border-purple-500', + success: 'bg-green-600/20 border border-green-500/50', + error: 'bg-red-600/20 border border-red-500/50' + }; + const textStyles = { + pending: 'text-white', + processing: 'text-purple-300', + success: 'text-green-400', + error: 'text-red-400' + }; + const statusIcons = { + pending: null, + processing: , + success: , + error: + }; + + return ( +
+ {/* 처리 중 애니메이션 배경 */} + {fileObj.status === 'processing' && ( +
+ )} +
+
+ {statusIcons[fileObj.status]} + + {fileObj.name} + +
+ {fileObj.status === 'pending' && ( + + )} +
+ {fileObj.status === 'error' && fileObj.error && ( +
+ {fileObj.error} +
+ )} +
+ ); + })} +
+ + {/* 업로드 버튼 */} + {pendingFiles.some(f => f.status === 'pending') && ( + + )} +
+ )} + + {/* 업로드된 모드 목록 */} + {modTranslations.length > 0 && ( +
+ + + {isModListExpanded && ( + +
+ {modTranslations.map((mod) => ( +
+
+
+ +
+
+ {mod.mod_id} + + 블록 {mod.block_count} · 아이템 {mod.item_count} + +
+
+ +
+ ))} +
+
+ )} +
+
+ )} +
+ + {/* 모드팩 관리 */}

📦 모드팩 관리

@@ -2040,6 +2379,49 @@ export default function Admin({ isMobile = false }) { )} + {/* 모드 번역 삭제 확인 다이얼로그 */} + + {deleteModDialog.show && ( + setDeleteModDialog({ show: false, modId: null })} + > + e.stopPropagation()} + > +

모드 번역 삭제

+

+ {deleteModDialog.modId} 모드의 번역 데이터를 삭제하시겠습니까? +

+
+ + +
+
+
+ )} +
+ {/* 모드팩 업로드/수정 다이얼로그 */} {showModpackDialog && ( diff --git a/frontend/src/pages/PlayerStatsPage.jsx b/frontend/src/pages/PlayerStatsPage.jsx index 99a04c5..7c79349 100644 --- a/frontend/src/pages/PlayerStatsPage.jsx +++ b/frontend/src/pages/PlayerStatsPage.jsx @@ -1,6 +1,6 @@ import React, { useState, useEffect, useRef } from 'react'; import { useParams } from 'react-router-dom'; -import { Activity, Skull, Heart, LocateFixed, Box, Sword, Clock, Calendar, RefreshCw } from 'lucide-react'; +import { Activity, Skull, Heart, LocateFixed, Box, Sword, Clock, Calendar, RefreshCw, ImageOff } from 'lucide-react'; import { motion } from 'framer-motion'; import { io } from 'socket.io-client'; import { formatDate, formatPlayTimeMs } from '../utils/formatters'; @@ -368,8 +368,9 @@ const StatCard = ({ icon: Icon, label, value, color }) => ( // 아이템 통계 행 컴포넌트 (온디맨드 아이콘 지원) const ItemStatRow = ({ item, translate, icons }) => { - const [iconSrc, setIconSrc] = useState(icons[item.id] || DEFAULT_ICON); + const [iconSrc, setIconSrc] = useState(icons[item.id] || null); const [loading, setLoading] = useState(!icons[item.id]); + const [hasIcon, setHasIcon] = useState(!!icons[item.id]); // 아이콘이 없으면 온디맨드로 가져오기 useEffect(() => { @@ -379,21 +380,30 @@ const ItemStatRow = ({ item, translate, icons }) => { .then(data => { if (data.icon) { setIconSrc(data.icon); + setHasIcon(true); + } else { + setHasIcon(false); } }) - .catch(() => {}) + .catch(() => { setHasIcon(false); }) .finally(() => setLoading(false)); } }, [item.id, icons]); return (
- {item.id} { e.target.src = DEFAULT_ICON; }} - /> + {hasIcon && iconSrc ? ( + {item.id} { setHasIcon(false); }} + /> + ) : ( +
+ +
+ )}

{translate(item.id)} @@ -411,8 +421,9 @@ const ItemStatRow = ({ item, translate, icons }) => { // 몹 통계 행 컴포넌트 (온디맨드 아이콘 지원) const MobStatRow = ({ mob, translate, icons }) => { - const [iconSrc, setIconSrc] = useState(icons[mob.id] || DEFAULT_ICON); + const [iconSrc, setIconSrc] = useState(icons[mob.id] || null); const [loading, setLoading] = useState(!icons[mob.id]); + const [hasIcon, setHasIcon] = useState(!!icons[mob.id]); // 아이콘이 없으면 온디맨드로 가져오기 useEffect(() => { @@ -422,21 +433,30 @@ const MobStatRow = ({ mob, translate, icons }) => { .then(data => { if (data.icon) { setIconSrc(data.icon); + setHasIcon(true); + } else { + setHasIcon(false); } }) - .catch(() => {}) + .catch(() => { setHasIcon(false); }) .finally(() => setLoading(false)); } }, [mob.id, icons]); return (

- {mob.id} { e.target.src = DEFAULT_ICON; }} - /> + {hasIcon && iconSrc ? ( + {mob.id} { setHasIcon(false); }} + /> + ) : ( +
+ +
+ )}

{translate(mob.id)}