Files
a301_client/src/components/admin/DownloadAdmin.jsx
tolelom f93d81b6d9
All checks were successful
Client CI/CD / deploy (push) Successful in 35s
fix: 입력 검증 일관성·UX 개선
- 회원가입 username 검증을 서버와 동일하게 맞춤
- 비밀번호 maxLength를 bcrypt 제한(72)에 맞춤
- 공지사항 줄바꿈 CSS 처리 (pre→white-space)
- 어드민 페이지 에러 로깅 추가
- 다운로드 섹션 로딩 스켈레톤 추가

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-13 21:40:09 +09:00

202 lines
6.1 KiB
JavaScript

import { useState, useEffect } from 'react';
import { getDownloadInfo } from '../../api/download';
import { tryRefresh } from '../../api/client';
import './AdminCommon.css';
const BASE = import.meta.env.VITE_API_BASE_URL || '';
function sendXhr(url, token, file, { onProgress, onDone, onError }) {
const xhr = new XMLHttpRequest();
xhr.upload.onprogress = (event) => {
if (event.lengthComputable) {
onProgress(Math.round((event.loaded / event.total) * 100));
}
};
xhr.onload = () => onDone(xhr);
xhr.onerror = () => onError();
xhr.open('POST', url);
xhr.setRequestHeader('Authorization', `Bearer ${token}`);
xhr.setRequestHeader('Content-Type', 'application/octet-stream');
xhr.send(file);
}
function UploadForm({ title, hint, accept, endpoint, onSuccess }) {
const [file, setFile] = useState(null);
const [uploading, setUploading] = useState(false);
const [progress, setProgress] = useState(0);
const [error, setError] = useState('');
const handleFileChange = (e) => {
setFile(e.target.files[0] || null);
setError('');
setProgress(0);
};
const handleUpload = (e) => {
e.preventDefault();
if (!file) return;
const token = localStorage.getItem('token');
const url = `${BASE}${endpoint}?filename=${encodeURIComponent(file.name)}`;
setUploading(true);
setError('');
const handleDone = (xhr) => {
// 401 시 토큰 갱신 후 재시도
if (xhr.status === 401) {
tryRefresh()
.then((newToken) => {
sendXhr(url, newToken, file, {
onProgress: (p) => setProgress(p),
onDone: (retryXhr) => {
setUploading(false);
if (retryXhr.status === 401) {
window.dispatchEvent(new Event('auth:unauthorized'));
return;
}
parseXhrResponse(retryXhr);
},
onError: handleError,
});
})
.catch(() => {
setUploading(false);
window.dispatchEvent(new Event('auth:unauthorized'));
});
return;
}
setUploading(false);
parseXhrResponse(xhr);
};
const parseXhrResponse = (xhr) => {
try {
const body = JSON.parse(xhr.responseText || '{}');
if (xhr.status >= 200 && xhr.status < 300) {
onSuccess(body);
setFile(null);
setProgress(0);
} else {
setError(body.error || '업로드에 실패했습니다.');
setProgress(0);
}
} catch {
if (xhr.status >= 200 && xhr.status < 300) {
setError('응답을 처리할 수 없습니다.');
} else {
setError('업로드에 실패했습니다.');
}
setProgress(0);
}
};
const handleError = () => {
setUploading(false);
setError('네트워크 오류가 발생했습니다.');
setProgress(0);
};
sendXhr(url, token, file, {
onProgress: (p) => setProgress(p),
onDone: handleDone,
onError: handleError,
});
};
return (
<form className="admin-form" onSubmit={handleUpload}>
<div className="admin-field">
<label className="admin-label">{title}</label>
<input
type="file"
accept={accept}
className="admin-input-file"
onChange={handleFileChange}
disabled={uploading}
/>
<span className="admin-field-hint">{hint}</span>
</div>
{uploading && (
<div className="admin-upload-progress">
<div className="admin-upload-bar" style={{ width: `${progress}%` }} />
<span className="admin-upload-pct">{progress}%</span>
</div>
)}
{error && <p className="admin-error">{error}</p>}
<div className="admin-form-actions">
<button className="btn-admin-primary" type="submit" disabled={uploading || !file}>
{uploading ? `업로드 중... (${progress}%)` : '업로드'}
</button>
</div>
</form>
);
}
export default function DownloadAdmin() {
const [info, setInfo] = useState(null);
useEffect(() => {
getDownloadInfo().then(setInfo).catch((err) => {
console.error('다운로드 정보 로드 실패:', err);
});
}, []);
return (
<div className="admin-section">
<h2 className="admin-section-title">게임 배포 관리</h2>
{/* 런처 섹션 */}
<div className="admin-deploy-block">
<div className="admin-deploy-header">
<span className="admin-deploy-label">런처</span>
{info?.launcherUrl && (
<div className="admin-meta-row">
{info.launcherSize && <span className="admin-meta-item">{info.launcherSize}</span>}
</div>
)}
</div>
<UploadForm
title="launcher.exe"
hint="빌드된 launcher.exe 파일을 업로드하세요."
accept=".exe"
endpoint="/api/download/upload/launcher"
onSuccess={setInfo}
/>
</div>
{/* 게임 섹션 */}
<div className="admin-deploy-block">
<div className="admin-deploy-header">
<span className="admin-deploy-label">게임</span>
{info?.url && (
<div className="admin-meta-row">
{info.version && <span className="admin-meta-item">{info.version}</span>}
{info.fileName && <span className="admin-meta-item">{info.fileName}</span>}
{info.fileSize && <span className="admin-meta-item">{info.fileSize}</span>}
{info.fileHash && (
<span className="admin-meta-item admin-meta-hash" title={info.fileHash}>
SHA256: {info.fileHash.slice(0, 12)}...
</span>
)}
</div>
)}
</div>
<UploadForm
title="게임 파일 (zip)"
hint="A301.exe가 포함된 zip 파일을 업로드하세요. 버전·크기·해시가 자동으로 추출됩니다."
accept=".zip"
endpoint="/api/download/upload/game"
onSuccess={setInfo}
/>
</div>
</div>
);
}