feat: 모드팩 배포 시스템 UI/UX 개선
백엔드: - 중복 모드팩 업로드 시 409 에러 반환 - changelog UTF-8 인코딩 수정 - S3 경로에서 한글 제거 (ASCII만 사용) 프론트엔드: - 업로드 중 로딩 인디케이터 추가 - 에러 토스트 빨간색/성공 초록색 구분 - 다이얼로그 배경 클릭 시 닫히지 않음 + 스케일 바운스 효과 - 취소 버튼 로딩 중 비활성화
This commit is contained in:
parent
778a9597bd
commit
00be44fc33
3 changed files with 70 additions and 29 deletions
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
|
@ -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}
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
|
@ -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);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<motion.div
|
||||
id="modpack-dialog"
|
||||
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 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()}
|
||||
>
|
||||
<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 }))}
|
||||
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"
|
||||
/>
|
||||
</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">
|
||||
<button
|
||||
|
|
@ -2114,7 +2138,8 @@ export default function Admin({ isMobile = false }) {
|
|||
setModpackFile(null);
|
||||
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>
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue