feat: 런처/게임 분리 배포 구조 적용
All checks were successful
Client CI/CD / deploy (push) Successful in 12s

- 런처 미설치 시 launcher.exe만 다운로드 (게임 전체 zip 아님)
- 관리자 페이지에 런처/게임 별도 업로드 섹션 분리
- 힌트 문구 업데이트

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-24 23:34:17 +09:00
parent eb579ded5c
commit 7e4e5a1801
4 changed files with 116 additions and 55 deletions

View File

@@ -4,17 +4,12 @@ import './AdminCommon.css';
const BASE = import.meta.env.VITE_API_BASE_URL || '';
export default function DownloadAdmin() {
function UploadForm({ title, hint, accept, endpoint, onSuccess }) {
const [file, setFile] = useState(null);
const [info, setInfo] = useState(null);
const [uploading, setUploading] = useState(false);
const [progress, setProgress] = useState(0);
const [error, setError] = useState('');
useEffect(() => {
getDownloadInfo().then(setInfo).catch(() => {});
}, []);
const handleFileChange = (e) => {
setFile(e.target.files[0] || null);
setError('');
@@ -37,7 +32,7 @@ export default function DownloadAdmin() {
xhr.onload = () => {
setUploading(false);
if (xhr.status >= 200 && xhr.status < 300) {
setInfo(JSON.parse(xhr.responseText));
onSuccess(JSON.parse(xhr.responseText));
setFile(null);
setProgress(0);
} else {
@@ -53,7 +48,7 @@ export default function DownloadAdmin() {
setProgress(0);
};
xhr.open('POST', `${BASE}/api/download/upload?filename=${encodeURIComponent(file.name)}`);
xhr.open('POST', `${BASE}${endpoint}?filename=${encodeURIComponent(file.name)}`);
xhr.setRequestHeader('Authorization', `Bearer ${token}`);
setUploading(true);
setError('');
@@ -61,55 +56,92 @@ export default function DownloadAdmin() {
};
return (
<div className="admin-section">
<h2 className="admin-section-title">게임 배포 관리</h2>
<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>
{info && (
<div className="admin-current-build">
<span className="admin-label">현재 배포 </span>
<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>
{uploading && (
<div className="admin-upload-progress">
<div className="admin-upload-bar" style={{ width: `${progress}%` }} />
<span className="admin-upload-pct">{progress}%</span>
</div>
)}
<form className="admin-form" onSubmit={handleUpload}>
<div className="admin-field">
<label className="admin-label"> 배포 파일 (zip)</label>
<input
type="file"
accept=".zip"
className="admin-input-file"
onChange={handleFileChange}
disabled={uploading}
/>
<span className="admin-field-hint">
A301.exe가 포함된 zip 파일을 선택하세요. 버전·파일명·크기·해시가 자동으로 추출됩니다.
</span>
{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(() => {});
}, []);
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>
{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 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>
</form>
<UploadForm
title="게임 파일 (zip)"
hint="A301.exe가 포함된 zip 파일을 업로드하세요. 버전·크기·해시가 자동으로 추출됩니다."
accept=".zip"
endpoint="/api/download/upload/game"
onSuccess={setInfo}
/>
</div>
</div>
);
}