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

@@ -0,0 +1,7 @@
{
"permissions": {
"allow": [
"Bash(cd E:/projects/a301_launcher && \"C:/Users/98kim/sdk/go1.25.1/bin/go.exe\" build -ldflags=\"-H windowsgui -s -w\" -o launcher.exe . 2>&1)"
]
}
}

View File

@@ -33,11 +33,11 @@ export default function DownloadSection() {
setTimeout(() => {
window.removeEventListener('blur', onBlur);
setLaunching(false);
if (!launched && info?.url) {
// 런처 미설치 → 자동 다운로드
if (!launched && info?.launcherUrl) {
// 런처 미설치 → launcher.exe 다운로드
const a = document.createElement('a');
a.href = info.url;
a.download = '';
a.href = info.launcherUrl;
a.download = 'launcher.exe';
a.click();
}
}, 2000);
@@ -58,7 +58,7 @@ export default function DownloadSection() {
{launching ? '실행 중...' : '게임 시작'}
</button>
<p className="launch-hint">
설치하는 경우 자동으로 다운로드됩니다
설치 자동으로 다운로드됩니다. 설치 다시 클릭하세요.
</p>
</>
) : (

View File

@@ -187,6 +187,28 @@
background: rgba(229, 115, 115, 0.08);
}
/* Deploy block */
.admin-deploy-block {
display: flex;
flex-direction: column;
gap: 8px;
}
.admin-deploy-header {
display: flex;
align-items: center;
gap: 12px;
flex-wrap: wrap;
}
.admin-deploy-label {
font-size: 0.8rem;
font-weight: 600;
color: rgba(255, 255, 255, 0.5);
text-transform: uppercase;
letter-spacing: 0.06em;
}
/* File input */
.admin-input-file {
padding: 8px 0;

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>
);
}