From b511f374b536ada3dcbf8dc50771762f20abc52c Mon Sep 17 00:00:00 2001 From: caadiq Date: Fri, 26 Dec 2025 20:05:19 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20=EC=95=84=EC=9D=B4=EC=BD=98=20=EC=97=85?= =?UTF-8?q?=EB=A1=9C=EB=93=9C=20UI=20=EA=B0=9C=EC=84=A0=20-=20=EB=B2=88?= =?UTF-8?q?=EC=97=AD=EA=B3=BC=20=EB=8F=99=EC=9D=BC=ED=95=9C=20=ED=8C=A8?= =?UTF-8?q?=ED=84=B4=20=EC=A0=81=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 파일 추가 → 대기열 목록 → 시작 버튼 패턴 - 개별 파일 상태 표시 (pending/processing/success/error) - 파일 제거 및 완료 항목 지우기 기능 - 에러 메시지 표시 --- frontend/src/pages/Admin.jsx | 221 ++++++++++++++++++++++++++++------- 1 file changed, 176 insertions(+), 45 deletions(-) diff --git a/frontend/src/pages/Admin.jsx b/frontend/src/pages/Admin.jsx index a588d58..0ee1669 100644 --- a/frontend/src/pages/Admin.jsx +++ b/frontend/src/pages/Admin.jsx @@ -155,6 +155,8 @@ export default function Admin({ isMobile = false }) { const [isIconDragging, setIsIconDragging] = useState(false); const [deleteIconDialog, setDeleteIconDialog] = useState({ show: false, modId: null }); const [isIconListExpanded, setIsIconListExpanded] = useState(false); + const [pendingIconFiles, setPendingIconFiles] = useState([]); // 아이콘 파일 대기열 + const [clearingIconFiles, setClearingIconFiles] = useState(false); // 권한 확인 useEffect(() => { @@ -324,52 +326,91 @@ export default function Admin({ isMobile = false }) { } }, []); - // 아이콘 ZIP 업로드 (여러 파일 지원) - const handleIconZipUpload = async (files) => { + // 아이콘 ZIP 파일 추가 (대기열에만 추가) + const addIconFiles = (files) => { const fileArray = Array.isArray(files) ? files : [files]; const zipFiles = fileArray.filter(f => f && f.name.endsWith('.zip')); if (zipFiles.length === 0) { - setToast('ZIP 파일만 업로드 가능합니다', true); + setToast('ZIP 파일만 추가 가능합니다', true); return; } - setIconUploading(true); - try { - const token = localStorage.getItem('token'); - const formData = new FormData(); - zipFiles.forEach(file => formData.append('files', 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) { - const totalUploaded = data.results?.reduce((sum, r) => sum + r.uploaded, 0) || 0; - const totalUpdated = data.results?.reduce((sum, r) => sum + r.updated, 0) || 0; - const modIds = data.results?.map(r => r.modId).join(', ') || ''; - setToast(`아이콘 업로드 완료: ${modIds} (${totalUploaded}개 업로드, ${totalUpdated}개 DB 업데이트)`); - - // 에러가 있는 경우 추가 알림 - if (data.errors?.length > 0) { - console.warn('아이콘 업로드 일부 실패:', data.errors); - } - fetchIconMods(); - } else { - const errorMsg = data.errors?.map(e => `${e.file}: ${e.error}`).join('\n') || data.error || '업로드 실패'; - setToast(errorMsg, true); - } - } catch (error) { - console.error('아이콘 업로드 실패:', error); - setToast('업로드 중 오류 발생', true); - } finally { - setIconUploading(false); + // 중복 체크 후 대기열에 추가 + const newFiles = zipFiles + .filter(f => !pendingIconFiles.some(pf => pf.name === f.name)) + .map(f => ({ name: f.name, file: f, status: 'pending' })); + + if (newFiles.length > 0) { + setPendingIconFiles(prev => [...prev, ...newFiles]); } }; + // 아이콘 파일 제거 + const removeIconFile = (fileName) => { + setPendingIconFiles(prev => prev.filter(f => f.name !== fileName)); + }; + + // 완료된 아이콘 파일 지우기 + const clearCompletedIconFiles = () => { + setClearingIconFiles(true); + setTimeout(() => { + setPendingIconFiles(prev => prev.filter(f => f.status === 'pending')); + setClearingIconFiles(false); + }, 300); + }; + + // 아이콘 업로드 시작 + const startIconUpload = async () => { + const filesToUpload = pendingIconFiles.filter(f => f.status === 'pending'); + if (filesToUpload.length === 0) return; + + setIconUploading(true); + + for (const fileObj of filesToUpload) { + // 상태를 'processing'으로 변경 + setPendingIconFiles(prev => + prev.map(f => f.name === fileObj.name ? { ...f, status: 'processing' } : f) + ); + + try { + const token = localStorage.getItem('token'); + const formData = new FormData(); + formData.append('files', fileObj.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 && data.results?.length > 0) { + const result = data.results[0]; + setPendingIconFiles(prev => + prev.map(f => f.name === fileObj.name ? { + ...f, + status: 'success', + result: `${result.uploaded}개 업로드, ${result.updated}개 DB 업데이트` + } : f) + ); + } else { + const errorMsg = data.errors?.[0]?.error || data.error || '업로드 실패'; + setPendingIconFiles(prev => + prev.map(f => f.name === fileObj.name ? { ...f, status: 'error', error: errorMsg } : f) + ); + } + } catch (error) { + setPendingIconFiles(prev => + prev.map(f => f.name === fileObj.name ? { ...f, status: 'error', error: error.message } : f) + ); + } + } + + setIconUploading(false); + fetchIconMods(); + }; + // 아이콘 모드 삭제 const deleteIconMod = async (modId) => { try { @@ -2136,14 +2177,14 @@ export default function Admin({ isMobile = false }) { 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 files = Array.from(e.dataTransfer.files); - if (files.length > 0) handleIconZipUpload(files); + if (files.length > 0) addIconFiles(files); }} > { const files = Array.from(e.target.files); - if (files.length > 0) handleIconZipUpload(files); + if (files.length > 0) addIconFiles(files); e.target.value = ''; }} /> - {iconUploading ? ( - - ) : ( - - )} + - {iconUploading ? '업로드 중...' : 'IconExporter ZIP 파일을 드래그하거나 클릭하여 추가'} + IconExporter ZIP 파일을 드래그하거나 클릭하여 추가 metadata.json이 포함된 ZIP 파일 + {/* 아이콘 파일 대기열 */} + {pendingIconFiles.length > 0 && ( +
+
+ + 파일 목록 ({pendingIconFiles.length}개) + + +
+
+ {pendingIconFiles.map((fileObj) => { + const statusStyles = { + pending: 'bg-zinc-800/50', + processing: 'bg-emerald-600/30 border border-emerald-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-emerald-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} + + )} +
+
+ ); + })} +
+ + {/* 업로드 시작 버튼 */} + {pendingIconFiles.some(f => f.status === 'pending') && ( + + {iconUploading ? ( + <> + + 업로드 중... + + ) : ( + <> + + 아이콘 업로드 시작 ({pendingIconFiles.filter(f => f.status === 'pending').length}개) + + )} + + )} +
+ )} + {/* 업로드된 모드 목록 */} {iconMods.length > 0 && (