feat: 모드팩 배포 시스템 UI/UX 개선

백엔드:
- 중복 모드팩 업로드 시 409 에러 반환
- changelog UTF-8 인코딩 수정
- S3 경로에서 한글 제거 (ASCII만 사용)

프론트엔드:
- 업로드 중 로딩 인디케이터 추가
- 에러 토스트 빨간색/성공 초록색 구분
- 다이얼로그 배경 클릭 시 닫히지 않음 + 스케일 바운스 효과
- 취소 버튼 로딩 중 비활성화
This commit is contained in:
caadiq 2025-12-23 17:15:32 +09:00
parent 778a9597bd
commit 00be44fc33
3 changed files with 70 additions and 29 deletions

View file

@ -375,7 +375,11 @@ router.post(
} else if (part.includes('name="changelog"')) { } else if (part.includes('name="changelog"')) {
const dataStart = part.indexOf("\r\n\r\n") + 4; const dataStart = part.indexOf("\r\n\r\n") + 4;
const dataEnd = part.lastIndexOf("\r\n"); 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에 업로드 // S3에 업로드 (경로는 ASCII만 사용)
const s3Key = `modpacks/${modpackName.replace( const safeName = modpackName
/[^a-zA-Z0-9가-힣]/g, .replace(/[^a-zA-Z0-9-]/g, "_") // 영문, 숫자, 하이픈만 허용
"_" .replace(/_+/g, "_") // 연속 언더스코어 정리
)}/${modpackVersion}.mrpack`; .replace(/^_|_$/g, ""); // 앞뒤 언더스코어 제거
const s3Key = `modpacks/${
safeName || "modpack"
}/${modpackVersion}.mrpack`;
const { uploadToS3 } = await import("../lib/s3.js"); const { uploadToS3 } = await import("../lib/s3.js");
await uploadToS3("minecraft", s3Key, fileData, "application/zip"); 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에 저장 // DB에 저장
const [result] = await pool.query( const [result] = await pool.query(
`INSERT INTO modpacks (name, version, minecraft_version, mod_loader, changelog, file_key, file_size, contents_json) `INSERT INTO modpacks (name, version, minecraft_version, mod_loader, changelog, file_key, file_size, contents_json)
VALUES (?, ?, ?, ?, ?, ?, ?, ?) 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`,
[ [
modpackName, modpackName,
modpackVersion, modpackVersion,

View file

@ -55,7 +55,9 @@ export default function Admin({ isMobile = false }) {
const { isLoggedIn, isAdmin, user, loading } = useAuth(); const { isLoggedIn, isAdmin, user, loading } = useAuth();
const navigate = useNavigate(); const navigate = useNavigate();
const location = useLocation(); const location = useLocation();
const [toast, setToast] = useState(null); const [toast, setToastState] = useState(null);
// (isError: true )
const setToast = (message, isError = false) => setToastState({ message, isError });
// (URL ) // (URL )
const getInitialTab = () => { const getInitialTab = () => {
@ -147,7 +149,7 @@ export default function Admin({ isMobile = false }) {
// //
useEffect(() => { useEffect(() => {
if (toast) { if (toast) {
const timer = setTimeout(() => setToast(null), 3000); const timer = setTimeout(() => setToastState(null), 3000);
return () => clearTimeout(timer); return () => clearTimeout(timer);
} }
}, [toast]); }, [toast]);
@ -204,10 +206,10 @@ export default function Admin({ isMobile = false }) {
setModpackForm({ changelog: '' }); setModpackForm({ changelog: '' });
fetchModpacks(); fetchModpacks();
} else { } else {
setToast(result.error || '업로드 실패'); setToast(result.error || '업로드 실패', true);
} }
} catch (error) { } catch (error) {
setToast('업로드 실패: ' + error.message); setToast('업로드 실패: ' + error.message, true);
} finally { } finally {
setModpackLoading(false); setModpackLoading(false);
} }
@ -233,10 +235,10 @@ export default function Admin({ isMobile = false }) {
setShowModpackDialog(false); setShowModpackDialog(false);
fetchModpacks(); fetchModpacks();
} else { } else {
setToast(result.error || '수정 실패'); setToast(result.error || '수정 실패', true);
} }
} catch (error) { } catch (error) {
setToast('수정 실패: ' + error.message); setToast('수정 실패: ' + error.message, true);
} finally { } finally {
setModpackLoading(false); setModpackLoading(false);
} }
@ -258,10 +260,10 @@ export default function Admin({ isMobile = false }) {
setModpackDeleteTarget(null); setModpackDeleteTarget(null);
fetchModpacks(); fetchModpacks();
} else { } else {
setToast(result.error || '삭제 실패'); setToast(result.error || '삭제 실패', true);
} }
} catch (error) { } catch (error) {
setToast('삭제 실패: ' + error.message); setToast('삭제 실패: ' + error.message, true);
} finally { } finally {
setModpackLoading(false); setModpackLoading(false);
} }
@ -901,9 +903,9 @@ 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] 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}
</motion.div> </motion.div>
)} )}
</AnimatePresence> </AnimatePresence>
@ -931,9 +933,9 @@ 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] 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}
</motion.div> </motion.div>
)} )}
</AnimatePresence> </AnimatePresence>
@ -2044,13 +2046,21 @@ export default function Admin({ isMobile = false }) {
animate={{ opacity: 1 }} animate={{ opacity: 1 }}
exit={{ opacity: 0 }} exit={{ opacity: 0 }}
className="fixed inset-0 bg-black/50 backdrop-blur-sm flex items-center justify-center z-[100] p-4" 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);
}
}}
> >
<motion.div <motion.div
id="modpack-dialog"
initial={{ scale: 0.9, opacity: 0 }} initial={{ scale: 0.9, opacity: 0 }}
animate={{ scale: 1, opacity: 1 }} animate={{ scale: 1, opacity: 1 }}
exit={{ scale: 0.9, opacity: 0 }} exit={{ scale: 0.9, opacity: 0 }}
className="bg-zinc-900 border border-zinc-800 rounded-2xl p-6 max-w-md w-full max-h-[80vh] overflow-y-auto" className="bg-zinc-900 border border-zinc-800 rounded-2xl p-6 max-w-md w-full max-h-[80vh] overflow-y-auto [&.animate-shake]:animate-[shake_0.15s_ease-in-out]"
onClick={e => e.stopPropagation()} onClick={e => e.stopPropagation()}
> >
<h3 className="text-white text-lg font-bold mb-4"> <h3 className="text-white text-lg font-bold mb-4">
@ -2102,10 +2112,24 @@ export default function Admin({ isMobile = false }) {
onChange={(e) => setModpackForm(prev => ({ ...prev, changelog: e.target.value }))} onChange={(e) => setModpackForm(prev => ({ ...prev, changelog: e.target.value }))}
placeholder="### 새로운 기능&#10;- 기능 1 추가&#10;- 기능 2 추가&#10;&#10;### 버그 수정&#10;- 버그 수정" placeholder="### 새로운 기능&#10;- 기능 1 추가&#10;- 기능 2 추가&#10;&#10;### 버그 수정&#10;- 버그 수정"
rows={6} 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"
/> />
</div> </div>
{/* 업로드 중 로딩 표시 */}
{modpackLoading && (
<div className="mb-4 p-4 bg-mc-green/10 border border-mc-green/30 rounded-xl">
<div className="flex items-center gap-3">
<div className="w-5 h-5 border-2 border-mc-green border-t-transparent rounded-full animate-spin" />
<span className="text-mc-green font-medium">
{modpackDialogMode === 'upload' ? '업로드 중...' : '저장 중...'}
</span>
</div>
<p className="text-zinc-400 text-sm mt-2">파일을 처리하고 있습니다. 잠시만 기다려주세요.</p>
</div>
)}
{/* 버튼 */} {/* 버튼 */}
<div className="flex gap-3"> <div className="flex gap-3">
<button <button
@ -2114,7 +2138,8 @@ export default function Admin({ isMobile = false }) {
setModpackFile(null); setModpackFile(null);
setModpackForm({ changelog: '' }); setModpackForm({ changelog: '' });
}} }}
className="flex-1 px-4 py-2.5 bg-zinc-800 hover:bg-zinc-700 text-white rounded-xl font-medium transition-colors" disabled={modpackLoading}
className="flex-1 px-4 py-2.5 bg-zinc-800 hover:bg-zinc-700 text-white rounded-xl font-medium transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
> >
취소 취소
</button> </button>

View file

@ -51,6 +51,10 @@ export default {
"0%": { opacity: "0", transform: "scale(0.95)" }, "0%": { opacity: "0", transform: "scale(0.95)" },
"100%": { opacity: "1", transform: "scale(1)" }, "100%": { opacity: "1", transform: "scale(1)" },
}, },
shake: {
"0%, 100%": { transform: "scale(1)" },
"50%": { transform: "scale(1.02)" },
},
}, },
animation: { animation: {
shimmer: "shimmer 2s infinite linear", shimmer: "shimmer 2s infinite linear",