Compare commits
3 commits
527ac1e51b
...
01aa85f041
| Author | SHA1 | Date | |
|---|---|---|---|
| 01aa85f041 | |||
| c6a99ce4ce | |||
| 74cf074d7e |
6 changed files with 719 additions and 29 deletions
|
|
@ -26,14 +26,15 @@ let gamerulesCache = {};
|
||||||
*/
|
*/
|
||||||
async function loadTranslations() {
|
async function loadTranslations() {
|
||||||
try {
|
try {
|
||||||
|
// 모든 모드의 번역 로드 (minecraft 포함)
|
||||||
const [blocks] = await dbPool.query(
|
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(
|
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(
|
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(
|
const [gamerules] = await dbPool.query(
|
||||||
"SELECT name, name_ko, description_ko FROM gamerules WHERE mod_id = 'minecraft'"
|
"SELECT name, name_ko, description_ko FROM gamerules WHERE mod_id = 'minecraft'"
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,7 @@
|
||||||
|
|
||||||
import express from "express";
|
import express from "express";
|
||||||
import jwt from "jsonwebtoken";
|
import jwt from "jsonwebtoken";
|
||||||
import { pool } from "../lib/db.js";
|
import { pool, loadTranslations } from "../lib/db.js";
|
||||||
|
|
||||||
const router = express.Router();
|
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/<mod_id>/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;
|
export default router;
|
||||||
|
|
|
||||||
|
|
@ -290,11 +290,11 @@ router.post("/login", async (req, res) => {
|
||||||
// 로그인 성공
|
// 로그인 성공
|
||||||
clearLoginAttempts(ip);
|
clearLoginAttempts(ip);
|
||||||
|
|
||||||
// JWT 토큰 생성
|
// JWT 토큰 생성 (30일 유효)
|
||||||
const token = jwt.sign(
|
const token = jwt.sign(
|
||||||
{ id: user.id, email: user.email, isAdmin: user.is_admin },
|
{ id: user.id, email: user.email, isAdmin: user.is_admin },
|
||||||
JWT_SECRET,
|
JWT_SECRET,
|
||||||
{ expiresIn: "24h" }
|
{ expiresIn: "30d" }
|
||||||
);
|
);
|
||||||
|
|
||||||
console.log(`[Auth] 로그인 성공: ${user.email} - IP: ${ip}`);
|
console.log(`[Auth] 로그인 성공: ${user.email} - IP: ${ip}`);
|
||||||
|
|
@ -356,6 +356,44 @@ router.get("/me", async (req, res) => {
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /auth/refresh - 토큰 갱신 (슬라이딩 세션)
|
||||||
|
* 만료 7일 전이면 새 토큰 발급
|
||||||
|
*/
|
||||||
|
router.post("/refresh", async (req, res) => {
|
||||||
|
const authHeader = req.headers.authorization;
|
||||||
|
if (!authHeader || !authHeader.startsWith("Bearer ")) {
|
||||||
|
return res.status(401).json({ error: "토큰이 없습니다." });
|
||||||
|
}
|
||||||
|
|
||||||
|
const token = authHeader.split(" ")[1];
|
||||||
|
|
||||||
|
try {
|
||||||
|
const decoded = jwt.verify(token, JWT_SECRET);
|
||||||
|
|
||||||
|
// 만료까지 남은 시간 계산 (초)
|
||||||
|
const expiresIn = decoded.exp - Math.floor(Date.now() / 1000);
|
||||||
|
const sevenDaysInSeconds = 7 * 24 * 60 * 60;
|
||||||
|
|
||||||
|
// 만료 7일 전이면 새 토큰 발급
|
||||||
|
if (expiresIn < sevenDaysInSeconds) {
|
||||||
|
const newToken = jwt.sign(
|
||||||
|
{ id: decoded.id, email: decoded.email, isAdmin: decoded.isAdmin },
|
||||||
|
JWT_SECRET,
|
||||||
|
{ expiresIn: "30d" }
|
||||||
|
);
|
||||||
|
console.log(`[Auth] 토큰 갱신: ${decoded.email}`);
|
||||||
|
return res.json({ refreshed: true, token: newToken });
|
||||||
|
}
|
||||||
|
|
||||||
|
// 아직 갱신 불필요
|
||||||
|
res.json({ refreshed: false });
|
||||||
|
} catch (error) {
|
||||||
|
// 토큰이 만료된 경우
|
||||||
|
return res.status(401).json({ error: "토큰이 만료되었습니다." });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* POST /auth/logout - 로그아웃 (클라이언트에서 토큰 삭제)
|
* POST /auth/logout - 로그아웃 (클라이언트에서 토큰 삭제)
|
||||||
*/
|
*/
|
||||||
|
|
|
||||||
|
|
@ -45,6 +45,39 @@ export function AuthProvider({ children }) {
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// 토큰 자동 갱신 (슬라이딩 세션)
|
||||||
|
const refreshToken = async () => {
|
||||||
|
const token = localStorage.getItem('token');
|
||||||
|
if (!token) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch('/auth/refresh', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Authorization': `Bearer ${token}` }
|
||||||
|
});
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
if (data.refreshed && data.token) {
|
||||||
|
localStorage.setItem('token', data.token);
|
||||||
|
console.log('[Auth] 토큰 자동 갱신됨');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
// 갱신 실패 시 무시 (다음 주기에 재시도)
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 1시간마다 토큰 갱신 체크
|
||||||
|
useEffect(() => {
|
||||||
|
if (!user) return;
|
||||||
|
|
||||||
|
// 초기 갱신 체크
|
||||||
|
refreshToken();
|
||||||
|
|
||||||
|
// 1시간마다 갱신 체크
|
||||||
|
const interval = setInterval(refreshToken, 60 * 60 * 1000);
|
||||||
|
return () => clearInterval(interval);
|
||||||
|
}, [user]);
|
||||||
|
|
||||||
// 로그인
|
// 로그인
|
||||||
const login = async (email, password) => {
|
const login = async (email, password) => {
|
||||||
try {
|
try {
|
||||||
|
|
|
||||||
|
|
@ -56,8 +56,12 @@ export default function Admin({ isMobile = false }) {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
const [toast, setToastState] = useState(null);
|
const [toast, setToastState] = useState(null);
|
||||||
// 토스트 헬퍼 함수 (isError: true면 에러 스타일)
|
// 토스트 헬퍼 함수 (type: 'success' | 'warning' | 'error' | boolean)
|
||||||
const setToast = (message, isError = false) => setToastState({ message, isError });
|
const setToast = (message, type = 'success') => {
|
||||||
|
// 이전 호환성: true면 error, false면 success
|
||||||
|
const colorType = type === true ? 'error' : (type === false ? 'success' : type);
|
||||||
|
setToastState({ message, type: colorType });
|
||||||
|
};
|
||||||
|
|
||||||
// 탭 상태 (URL 해시에서 초기값 로드)
|
// 탭 상태 (URL 해시에서 초기값 로드)
|
||||||
const getInitialTab = () => {
|
const getInitialTab = () => {
|
||||||
|
|
@ -135,6 +139,16 @@ export default function Admin({ isMobile = false }) {
|
||||||
const [modpackLoading, setModpackLoading] = useState(false); // 업로드/삭제 로딩
|
const [modpackLoading, setModpackLoading] = useState(false); // 업로드/삭제 로딩
|
||||||
const [isDragging, setIsDragging] = 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(() => {
|
useEffect(() => {
|
||||||
if (!loading) {
|
if (!loading) {
|
||||||
|
|
@ -165,6 +179,7 @@ export default function Admin({ isMobile = false }) {
|
||||||
id: mp.id,
|
id: mp.id,
|
||||||
version: mp.version,
|
version: mp.version,
|
||||||
name: mp.name,
|
name: mp.name,
|
||||||
|
changelog: mp.changelog || '',
|
||||||
date: new Date(mp.created_at).toISOString().split('T')[0],
|
date: new Date(mp.created_at).toISOString().split('T')[0],
|
||||||
size: (mp.file_size / (1024 * 1024)).toFixed(1) + ' MB',
|
size: (mp.file_size / (1024 * 1024)).toFixed(1) + ' MB',
|
||||||
}));
|
}));
|
||||||
|
|
@ -270,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 (안정적인 참조)
|
// 플레이어 목록 fetch (안정적인 참조)
|
||||||
const fetchPlayers = useCallback(async () => {
|
const fetchPlayers = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
|
|
@ -904,7 +1049,7 @@ export default function Admin({ isMobile = false }) {
|
||||||
initial={{ opacity: 0, y: 50 }}
|
initial={{ opacity: 0, y: 50 }}
|
||||||
animate={{ opacity: 1, y: 0 }}
|
animate={{ opacity: 1, y: 0 }}
|
||||||
exit={{ opacity: 0, y: 50 }}
|
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}
|
{toast.message}
|
||||||
</motion.div>
|
</motion.div>
|
||||||
|
|
@ -934,7 +1079,7 @@ export default function Admin({ isMobile = false }) {
|
||||||
initial={{ opacity: 0, y: 50 }}
|
initial={{ opacity: 0, y: 50 }}
|
||||||
animate={{ opacity: 1, y: 0 }}
|
animate={{ opacity: 1, y: 0 }}
|
||||||
exit={{ opacity: 0, y: 50 }}
|
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}
|
{toast.message}
|
||||||
</motion.div>
|
</motion.div>
|
||||||
|
|
@ -1690,6 +1835,201 @@ export default function Admin({ isMobile = false }) {
|
||||||
exit={{ opacity: 0, y: -10 }}
|
exit={{ opacity: 0, y: -10 }}
|
||||||
className="space-y-4"
|
className="space-y-4"
|
||||||
>
|
>
|
||||||
|
{/* 모드 번역 */}
|
||||||
|
<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">
|
||||||
|
🌐 모드 번역
|
||||||
|
{modTranslations.length > 0 && (
|
||||||
|
<span className="text-sm font-normal text-zinc-500">
|
||||||
|
({modTranslations.length}개 모드)
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</h3>
|
||||||
|
|
||||||
|
{/* 업로드 영역 */}
|
||||||
|
<label
|
||||||
|
className={`flex flex-col items-center justify-center p-6 border-2 border-dashed rounded-xl cursor-pointer transition-colors ${
|
||||||
|
isTranslationDragging
|
||||||
|
? 'border-purple-500 bg-purple-500/10'
|
||||||
|
: 'border-zinc-700 hover:border-purple-500 hover:bg-zinc-800/50'
|
||||||
|
} ${translationLoading ? 'pointer-events-none opacity-50' : ''}`}
|
||||||
|
onDragOver={(e) => { e.preventDefault(); setIsTranslationDragging(true); }}
|
||||||
|
onDragLeave={() => setIsTranslationDragging(false)}
|
||||||
|
onDrop={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setIsTranslationDragging(false);
|
||||||
|
addPendingFiles(e.dataTransfer.files);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="file"
|
||||||
|
accept=".jar"
|
||||||
|
multiple
|
||||||
|
className="hidden"
|
||||||
|
onChange={(e) => {
|
||||||
|
addPendingFiles(e.target.files);
|
||||||
|
e.target.value = '';
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Upload className={`w-8 h-8 mb-2 ${isTranslationDragging ? 'text-purple-400' : 'text-zinc-500'}`} />
|
||||||
|
<span className="text-zinc-400 text-sm text-center">
|
||||||
|
모드 JAR 파일을 드래그하거나 클릭하여 추가
|
||||||
|
</span>
|
||||||
|
<span className="text-zinc-600 text-xs mt-1">
|
||||||
|
여러 파일을 한 번에 선택할 수 있습니다
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
{/* 대기열 목록 */}
|
||||||
|
{pendingFiles.length > 0 && (
|
||||||
|
<div className="mt-4 space-y-2">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span className="text-zinc-400 text-sm">
|
||||||
|
파일 목록 ({pendingFiles.length}개)
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
onClick={clearCompletedFiles}
|
||||||
|
className="text-zinc-500 hover:text-red-400 text-xs"
|
||||||
|
>
|
||||||
|
완료된 항목 지우기
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="max-h-48 overflow-y-auto custom-scrollbar space-y-1">
|
||||||
|
{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: <Loader2 size={14} className="animate-spin text-purple-400" />,
|
||||||
|
success: <Check size={14} className="text-green-400" />,
|
||||||
|
error: <X size={14} className="text-red-400" />
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={fileObj.name}
|
||||||
|
className={`relative p-2 rounded-lg overflow-hidden ${statusStyles[fileObj.status] || 'bg-zinc-800/50'}`}
|
||||||
|
>
|
||||||
|
{/* 처리 중 애니메이션 배경 */}
|
||||||
|
{fileObj.status === 'processing' && (
|
||||||
|
<div className="absolute inset-0 bg-gradient-to-r from-purple-600/20 via-purple-500/30 to-purple-600/20 animate-pulse" />
|
||||||
|
)}
|
||||||
|
<div className="flex items-center justify-between relative z-10">
|
||||||
|
<div className="flex items-center gap-2 flex-1 min-w-0">
|
||||||
|
{statusIcons[fileObj.status]}
|
||||||
|
<span className={`text-sm truncate ${textStyles[fileObj.status] || 'text-zinc-400'}`}>
|
||||||
|
{fileObj.name}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{fileObj.status === 'pending' && (
|
||||||
|
<button
|
||||||
|
onClick={() => removePendingFile(fileObj.name)}
|
||||||
|
className="p-1 text-zinc-400 hover:text-red-400"
|
||||||
|
>
|
||||||
|
<X size={14} />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{fileObj.status === 'error' && fileObj.error && (
|
||||||
|
<div className="text-red-400 text-xs mt-1 pl-6">
|
||||||
|
{fileObj.error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 업로드 버튼 */}
|
||||||
|
{pendingFiles.some(f => f.status === 'pending') && (
|
||||||
|
<button
|
||||||
|
onClick={startTranslationUpload}
|
||||||
|
disabled={translationLoading}
|
||||||
|
className="w-full py-2.5 bg-purple-600 hover:bg-purple-500 disabled:bg-zinc-700 text-white font-medium rounded-xl transition-colors flex items-center justify-center gap-2"
|
||||||
|
>
|
||||||
|
{translationLoading ? (
|
||||||
|
<>
|
||||||
|
<Loader2 size={16} className="animate-spin" />
|
||||||
|
처리 중...
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Upload size={16} />
|
||||||
|
번역 추출 시작 ({pendingFiles.filter(f => f.status === 'pending').length}개)
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 업로드된 모드 목록 */}
|
||||||
|
{modTranslations.length > 0 && (
|
||||||
|
<div className="mt-4 border border-zinc-800 rounded-xl overflow-hidden">
|
||||||
|
<button
|
||||||
|
onClick={() => setIsModListExpanded(!isModListExpanded)}
|
||||||
|
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">등록된 모드 ({modTranslations.length}개)</span>
|
||||||
|
<ChevronDown
|
||||||
|
size={16}
|
||||||
|
className={`text-zinc-400 transition-transform duration-200 ${isModListExpanded ? 'rotate-180' : ''}`}
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
<AnimatePresence>
|
||||||
|
{isModListExpanded && (
|
||||||
|
<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">
|
||||||
|
{modTranslations.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-purple-500/20 rounded-md">
|
||||||
|
<FileText size={14} className="text-purple-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={() => setDeleteModDialog({ 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">
|
||||||
<h3 className="text-white font-medium">📦 모드팩 관리</h3>
|
<h3 className="text-white font-medium">📦 모드팩 관리</h3>
|
||||||
|
|
@ -1723,7 +2063,7 @@ export default function Admin({ isMobile = false }) {
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setModpackDialogMode('edit');
|
setModpackDialogMode('edit');
|
||||||
setEditingModpack(pack);
|
setEditingModpack(pack);
|
||||||
setModpackForm({ version: pack.version, changelog: '' });
|
setModpackForm({ version: pack.version, changelog: pack.changelog || '' });
|
||||||
setShowModpackDialog(true);
|
setShowModpackDialog(true);
|
||||||
}}
|
}}
|
||||||
className="p-1.5 text-zinc-400 hover:text-blue-400 transition-colors"
|
className="p-1.5 text-zinc-400 hover:text-blue-400 transition-colors"
|
||||||
|
|
@ -1762,7 +2102,7 @@ export default function Admin({ isMobile = false }) {
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setModpackDialogMode('edit');
|
setModpackDialogMode('edit');
|
||||||
setEditingModpack(pack);
|
setEditingModpack(pack);
|
||||||
setModpackForm({ version: pack.version, changelog: '' });
|
setModpackForm({ version: pack.version, changelog: pack.changelog || '' });
|
||||||
setShowModpackDialog(true);
|
setShowModpackDialog(true);
|
||||||
}}
|
}}
|
||||||
className="p-2 text-zinc-400 hover:text-blue-400 transition-colors"
|
className="p-2 text-zinc-400 hover:text-blue-400 transition-colors"
|
||||||
|
|
@ -2039,6 +2379,49 @@ export default function Admin({ isMobile = false }) {
|
||||||
)}
|
)}
|
||||||
</AnimatePresence>
|
</AnimatePresence>
|
||||||
|
|
||||||
|
{/* 모드 번역 삭제 확인 다이얼로그 */}
|
||||||
|
<AnimatePresence>
|
||||||
|
{deleteModDialog.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={() => setDeleteModDialog({ 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-purple-400 font-medium">{deleteModDialog.modId}</span> 모드의 번역 데이터를 삭제하시겠습니까?
|
||||||
|
</p>
|
||||||
|
<div className="flex gap-3">
|
||||||
|
<button
|
||||||
|
onClick={() => setDeleteModDialog({ 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 handleDeleteTranslation(deleteModDialog.modId);
|
||||||
|
setDeleteModDialog({ 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 && (
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
import React, { useState, useEffect, useRef } from 'react';
|
import React, { useState, useEffect, useRef } from 'react';
|
||||||
import { useParams } from 'react-router-dom';
|
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 { motion } from 'framer-motion';
|
||||||
import { io } from 'socket.io-client';
|
import { io } from 'socket.io-client';
|
||||||
import { formatDate, formatPlayTimeMs } from '../utils/formatters';
|
import { formatDate, formatPlayTimeMs } from '../utils/formatters';
|
||||||
|
|
@ -368,8 +368,9 @@ const StatCard = ({ icon: Icon, label, value, color }) => (
|
||||||
|
|
||||||
// 아이템 통계 행 컴포넌트 (온디맨드 아이콘 지원)
|
// 아이템 통계 행 컴포넌트 (온디맨드 아이콘 지원)
|
||||||
const ItemStatRow = ({ item, translate, icons }) => {
|
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 [loading, setLoading] = useState(!icons[item.id]);
|
||||||
|
const [hasIcon, setHasIcon] = useState(!!icons[item.id]);
|
||||||
|
|
||||||
// 아이콘이 없으면 온디맨드로 가져오기
|
// 아이콘이 없으면 온디맨드로 가져오기
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
@ -379,21 +380,30 @@ const ItemStatRow = ({ item, translate, icons }) => {
|
||||||
.then(data => {
|
.then(data => {
|
||||||
if (data.icon) {
|
if (data.icon) {
|
||||||
setIconSrc(data.icon);
|
setIconSrc(data.icon);
|
||||||
|
setHasIcon(true);
|
||||||
|
} else {
|
||||||
|
setHasIcon(false);
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.catch(() => {})
|
.catch(() => { setHasIcon(false); })
|
||||||
.finally(() => setLoading(false));
|
.finally(() => setLoading(false));
|
||||||
}
|
}
|
||||||
}, [item.id, icons]);
|
}, [item.id, icons]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="p-3 flex items-center gap-3 hover:bg-white/5 transition-colors border-b border-r border-white/5">
|
<div className="p-3 flex items-center gap-3 hover:bg-white/5 transition-colors border-b border-r border-white/5">
|
||||||
|
{hasIcon && iconSrc ? (
|
||||||
<img
|
<img
|
||||||
src={iconSrc}
|
src={iconSrc}
|
||||||
alt={item.id}
|
alt={item.id}
|
||||||
className="w-8 h-8 object-contain pixelated"
|
className="w-8 h-8 object-contain pixelated"
|
||||||
onError={(e) => { e.target.src = DEFAULT_ICON; }}
|
onError={() => { setHasIcon(false); }}
|
||||||
/>
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="w-8 h-8 flex items-center justify-center">
|
||||||
|
<ImageOff size={20} className="text-zinc-400" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
<p className="text-white text-sm font-medium truncate" title={translate(item.id)}>
|
<p className="text-white text-sm font-medium truncate" title={translate(item.id)}>
|
||||||
{translate(item.id)}
|
{translate(item.id)}
|
||||||
|
|
@ -411,8 +421,9 @@ const ItemStatRow = ({ item, translate, icons }) => {
|
||||||
|
|
||||||
// 몹 통계 행 컴포넌트 (온디맨드 아이콘 지원)
|
// 몹 통계 행 컴포넌트 (온디맨드 아이콘 지원)
|
||||||
const MobStatRow = ({ mob, 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 [loading, setLoading] = useState(!icons[mob.id]);
|
||||||
|
const [hasIcon, setHasIcon] = useState(!!icons[mob.id]);
|
||||||
|
|
||||||
// 아이콘이 없으면 온디맨드로 가져오기
|
// 아이콘이 없으면 온디맨드로 가져오기
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
@ -422,21 +433,30 @@ const MobStatRow = ({ mob, translate, icons }) => {
|
||||||
.then(data => {
|
.then(data => {
|
||||||
if (data.icon) {
|
if (data.icon) {
|
||||||
setIconSrc(data.icon);
|
setIconSrc(data.icon);
|
||||||
|
setHasIcon(true);
|
||||||
|
} else {
|
||||||
|
setHasIcon(false);
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.catch(() => {})
|
.catch(() => { setHasIcon(false); })
|
||||||
.finally(() => setLoading(false));
|
.finally(() => setLoading(false));
|
||||||
}
|
}
|
||||||
}, [mob.id, icons]);
|
}, [mob.id, icons]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="p-3 flex items-center gap-3 hover:bg-white/5 transition-colors border-b border-r border-white/5">
|
<div className="p-3 flex items-center gap-3 hover:bg-white/5 transition-colors border-b border-r border-white/5">
|
||||||
|
{hasIcon && iconSrc ? (
|
||||||
<img
|
<img
|
||||||
src={iconSrc}
|
src={iconSrc}
|
||||||
alt={mob.id}
|
alt={mob.id}
|
||||||
className="w-8 h-8 object-contain pixelated"
|
className="w-8 h-8 object-contain pixelated"
|
||||||
onError={(e) => { e.target.src = DEFAULT_ICON; }}
|
onError={() => { setHasIcon(false); }}
|
||||||
/>
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="w-8 h-8 flex items-center justify-center">
|
||||||
|
<ImageOff size={20} className="text-zinc-400" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
<p className="text-white text-sm font-medium truncate" title={translate(mob.id)}>
|
<p className="text-white text-sm font-medium truncate" title={translate(mob.id)}>
|
||||||
{translate(mob.id)}
|
{translate(mob.id)}
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue