diff --git a/backend/routes/admin.js b/backend/routes/admin.js index 7ce72e4..f618c08 100644 --- a/backend/routes/admin.js +++ b/backend/routes/admin.js @@ -375,7 +375,11 @@ router.post( } else if (part.includes('name="changelog"')) { const dataStart = part.indexOf("\r\n\r\n") + 4; const dataEnd = part.lastIndexOf("\r\n"); - changelog = part.slice(dataStart, dataEnd); + // UTF-8로 디코딩 + changelog = Buffer.from( + part.slice(dataStart, dataEnd), + "binary" + ).toString("utf8"); } } @@ -426,24 +430,32 @@ router.post( } } - // S3에 업로드 - const s3Key = `modpacks/${modpackName.replace( - /[^a-zA-Z0-9가-힣]/g, - "_" - )}/${modpackVersion}.mrpack`; + // S3에 업로드 (경로는 ASCII만 사용) + const safeName = modpackName + .replace(/[^a-zA-Z0-9-]/g, "_") // 영문, 숫자, 하이픈만 허용 + .replace(/_+/g, "_") // 연속 언더스코어 정리 + .replace(/^_|_$/g, ""); // 앞뒤 언더스코어 제거 + const s3Key = `modpacks/${ + safeName || "modpack" + }/${modpackVersion}.mrpack`; const { uploadToS3 } = await import("../lib/s3.js"); await uploadToS3("minecraft", s3Key, fileData, "application/zip"); + // 중복 체크 + const [existing] = await pool.query( + `SELECT id FROM modpacks WHERE name = ? AND version = ?`, + [modpackName, modpackVersion] + ); + if (existing.length > 0) { + return res.status(409).json({ + error: `${modpackName} v${modpackVersion}은(는) 이미 존재합니다.`, + }); + } + // DB에 저장 const [result] = await pool.query( `INSERT INTO modpacks (name, version, minecraft_version, mod_loader, changelog, file_key, file_size, contents_json) - VALUES (?, ?, ?, ?, ?, ?, ?, ?) - ON DUPLICATE KEY UPDATE - changelog = VALUES(changelog), - file_key = VALUES(file_key), - file_size = VALUES(file_size), - contents_json = VALUES(contents_json), - updated_at = CURRENT_TIMESTAMP`, + VALUES (?, ?, ?, ?, ?, ?, ?, ?)`, [ modpackName, modpackVersion, diff --git a/frontend/src/pages/Admin.jsx b/frontend/src/pages/Admin.jsx index 02b0637..1846d66 100644 --- a/frontend/src/pages/Admin.jsx +++ b/frontend/src/pages/Admin.jsx @@ -55,7 +55,9 @@ export default function Admin({ isMobile = false }) { const { isLoggedIn, isAdmin, user, loading } = useAuth(); const navigate = useNavigate(); const location = useLocation(); - const [toast, setToast] = useState(null); + const [toast, setToastState] = useState(null); + // 토스트 헬퍼 함수 (isError: true면 에러 스타일) + const setToast = (message, isError = false) => setToastState({ message, isError }); // 탭 상태 (URL 해시에서 초기값 로드) const getInitialTab = () => { @@ -147,7 +149,7 @@ export default function Admin({ isMobile = false }) { // 토스트 자동 숨기기 useEffect(() => { if (toast) { - const timer = setTimeout(() => setToast(null), 3000); + const timer = setTimeout(() => setToastState(null), 3000); return () => clearTimeout(timer); } }, [toast]); @@ -204,10 +206,10 @@ export default function Admin({ isMobile = false }) { setModpackForm({ changelog: '' }); fetchModpacks(); } else { - setToast(result.error || '업로드 실패'); + setToast(result.error || '업로드 실패', true); } } catch (error) { - setToast('업로드 실패: ' + error.message); + setToast('업로드 실패: ' + error.message, true); } finally { setModpackLoading(false); } @@ -233,10 +235,10 @@ export default function Admin({ isMobile = false }) { setShowModpackDialog(false); fetchModpacks(); } else { - setToast(result.error || '수정 실패'); + setToast(result.error || '수정 실패', true); } } catch (error) { - setToast('수정 실패: ' + error.message); + setToast('수정 실패: ' + error.message, true); } finally { setModpackLoading(false); } @@ -258,10 +260,10 @@ export default function Admin({ isMobile = false }) { setModpackDeleteTarget(null); fetchModpacks(); } else { - setToast(result.error || '삭제 실패'); + setToast(result.error || '삭제 실패', true); } } catch (error) { - setToast('삭제 실패: ' + error.message); + setToast('삭제 실패: ' + error.message, true); } finally { setModpackLoading(false); } @@ -901,9 +903,9 @@ 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] bg-red-500/90 backdrop-blur-sm text-white px-6 py-3 rounded-xl text-center font-medium shadow-lg" + 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'}`} > - {toast} + {toast.message} )} @@ -931,9 +933,9 @@ 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] bg-mc-green/90 backdrop-blur-sm text-white px-6 py-3 rounded-xl text-center font-medium shadow-lg" + 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'}`} > - {toast} + {toast.message} )} @@ -2044,13 +2046,21 @@ export default function Admin({ isMobile = false }) { animate={{ opacity: 1 }} exit={{ opacity: 0 }} className="fixed inset-0 bg-black/50 backdrop-blur-sm flex items-center justify-center z-[100] p-4" - onClick={() => setShowModpackDialog(false)} + onClick={() => { + // 배경 클릭 시 바운스 효과 + const dialog = document.getElementById('modpack-dialog'); + if (dialog) { + dialog.classList.add('animate-shake'); + setTimeout(() => dialog.classList.remove('animate-shake'), 150); + } + }} > e.stopPropagation()} >

@@ -2102,10 +2112,24 @@ export default function Admin({ isMobile = false }) { onChange={(e) => setModpackForm(prev => ({ ...prev, changelog: e.target.value }))} placeholder="### 새로운 기능 - 기능 1 추가 - 기능 2 추가 ### 버그 수정 - 버그 수정" rows={6} - className="w-full bg-zinc-800 rounded-xl p-3 text-white placeholder-zinc-500 focus:outline-none focus:ring-2 focus:ring-mc-green/50 resize-none" + disabled={modpackLoading} + className="w-full bg-zinc-800 rounded-xl p-3 text-white placeholder-zinc-500 focus:outline-none focus:ring-2 focus:ring-mc-green/50 resize-none disabled:opacity-50" /> + {/* 업로드 중 로딩 표시 */} + {modpackLoading && ( +
+
+
+ + {modpackDialogMode === 'upload' ? '업로드 중...' : '저장 중...'} + +
+

파일을 처리하고 있습니다. 잠시만 기다려주세요.

+
+ )} + {/* 버튼 */}
diff --git a/frontend/tailwind.config.js b/frontend/tailwind.config.js index 049c6c3..9ccb6db 100644 --- a/frontend/tailwind.config.js +++ b/frontend/tailwind.config.js @@ -51,6 +51,10 @@ export default { "0%": { opacity: "0", transform: "scale(0.95)" }, "100%": { opacity: "1", transform: "scale(1)" }, }, + shake: { + "0%, 100%": { transform: "scale(1)" }, + "50%": { transform: "scale(1.02)" }, + }, }, animation: { shimmer: "shimmer 2s infinite linear",