All checks were successful
Client CI/CD / deploy (push) Successful in 35s
- 회원가입 username 검증을 서버와 동일하게 맞춤 - 비밀번호 maxLength를 bcrypt 제한(72)에 맞춤 - 공지사항 줄바꿈 CSS 처리 (pre→white-space) - 어드민 페이지 에러 로깅 추가 - 다운로드 섹션 로딩 스켈레톤 추가 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
202 lines
6.1 KiB
JavaScript
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>
|
|
);
|
|
}
|