From eb579ded5c54376884e253fb798c192d91cf36db Mon Sep 17 00:00:00 2001 From: tolelom <98kimsungmin@naver.com> Date: Tue, 24 Feb 2026 23:25:14 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20=EA=B2=8C=EC=9E=84=20=EB=B0=B0=ED=8F=AC?= =?UTF-8?q?=20=EA=B4=80=EB=A6=AC=20UI=EB=A5=BC=20=ED=8C=8C=EC=9D=BC=20?= =?UTF-8?q?=EC=A7=81=EC=A0=91=20=EC=97=85=EB=A1=9C=EB=93=9C=20=EB=B0=A9?= =?UTF-8?q?=EC=8B=9D=EC=9C=BC=EB=A1=9C=20=EC=A0=84=ED=99=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - zip 파일 업로드 → XHR progress bar로 진행률 표시 - 업로드 후 버전·파일명·크기·해시 자동 표시 - 현재 배포 중인 빌드 정보 상단에 표시 Co-Authored-By: Claude Sonnet 4.6 --- src/components/admin/AdminCommon.css | 72 +++++++++++++ src/components/admin/DownloadAdmin.jsx | 139 ++++++++++++++++--------- 2 files changed, 163 insertions(+), 48 deletions(-) diff --git a/src/components/admin/AdminCommon.css b/src/components/admin/AdminCommon.css index 8a0bf0d..d2e1c6e 100644 --- a/src/components/admin/AdminCommon.css +++ b/src/components/admin/AdminCommon.css @@ -187,6 +187,78 @@ background: rgba(229, 115, 115, 0.08); } +/* File input */ +.admin-input-file { + padding: 8px 0; + color: rgba(255, 255, 255, 0.7); + font-size: 0.875rem; + cursor: pointer; +} + +.admin-input-file:disabled { + opacity: 0.4; + cursor: not-allowed; +} + +/* Field hint */ +.admin-field-hint { + font-size: 0.75rem; + color: rgba(255, 255, 255, 0.3); +} + +/* Upload progress */ +.admin-upload-progress { + display: flex; + align-items: center; + gap: 10px; +} + +.admin-upload-bar { + flex: 1; + height: 6px; + background: #BACDB0; + border-radius: 3px; + transition: width 0.2s; +} + +.admin-upload-pct { + font-size: 0.8rem; + color: rgba(255, 255, 255, 0.5); + min-width: 36px; + text-align: right; +} + +/* Current build info */ +.admin-current-build { + display: flex; + flex-direction: column; + gap: 8px; + padding: 14px 16px; + background: rgba(186, 205, 176, 0.05); + border: 1px solid rgba(186, 205, 176, 0.12); + border-radius: 8px; +} + +.admin-meta-row { + display: flex; + flex-wrap: wrap; + gap: 6px; +} + +.admin-meta-item { + font-size: 0.78rem; + color: rgba(186, 205, 176, 0.8); + background: rgba(186, 205, 176, 0.08); + padding: 3px 10px; + border-radius: 4px; + border: 1px solid rgba(186, 205, 176, 0.15); +} + +.admin-meta-hash { + font-family: monospace; + cursor: help; +} + /* Role badge */ .admin-role-badge { font-size: 0.7rem; diff --git a/src/components/admin/DownloadAdmin.jsx b/src/components/admin/DownloadAdmin.jsx index 103169d..87a8f73 100644 --- a/src/components/admin/DownloadAdmin.jsx +++ b/src/components/admin/DownloadAdmin.jsx @@ -1,69 +1,112 @@ import { useState, useEffect } from 'react'; import { getDownloadInfo } from '../../api/download'; -import { apiFetch } from '../../api/client'; import './AdminCommon.css'; +const BASE = import.meta.env.VITE_API_BASE_URL || ''; + export default function DownloadAdmin() { - const [form, setForm] = useState({ url: '', version: '', fileName: '', fileSize: '', fileHash: '' }); - const [loading, setLoading] = useState(false); - const [saved, setSaved] = useState(false); + 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((data) => setForm({ - url: data.url, - version: data.version, - fileName: data.fileName, - fileSize: data.fileSize, - fileHash: data.fileHash ?? '', - })) - .catch(() => {}); + getDownloadInfo().then(setInfo).catch(() => {}); }, []); - const handleSubmit = async (e) => { - e.preventDefault(); - setLoading(true); + const handleFileChange = (e) => { + setFile(e.target.files[0] || null); setError(''); - try { - await apiFetch('/api/download/info', { - method: 'PUT', - body: JSON.stringify(form), - }); - setSaved(true); - setTimeout(() => setSaved(false), 2000); - } catch (err) { - setError(err.message || '저장에 실패했습니다.'); - } finally { - setLoading(false); - } + setProgress(0); }; - const field = (label, key, placeholder) => ( -
- - setForm({ ...form, [key]: e.target.value })} - /> -
- ); + const handleUpload = (e) => { + e.preventDefault(); + if (!file) return; + + const token = localStorage.getItem('token'); + const xhr = new XMLHttpRequest(); + + xhr.upload.onprogress = (event) => { + if (event.lengthComputable) { + setProgress(Math.round((event.loaded / event.total) * 100)); + } + }; + + xhr.onload = () => { + setUploading(false); + if (xhr.status >= 200 && xhr.status < 300) { + setInfo(JSON.parse(xhr.responseText)); + setFile(null); + setProgress(0); + } else { + const res = JSON.parse(xhr.responseText || '{}'); + setError(res.error || '업로드에 실패했습니다.'); + setProgress(0); + } + }; + + xhr.onerror = () => { + setUploading(false); + setError('네트워크 오류가 발생했습니다.'); + setProgress(0); + }; + + xhr.open('POST', `${BASE}/api/download/upload?filename=${encodeURIComponent(file.name)}`); + xhr.setRequestHeader('Authorization', `Bearer ${token}`); + setUploading(true); + setError(''); + xhr.send(file); + }; return (
-

다운로드 정보 관리

-
- {field('다운로드 URL', 'url', 'https://...')} - {field('버전', 'version', 'v1.0.0')} - {field('파일명', 'fileName', 'A301_Launcher.zip')} - {field('파일 크기', 'fileSize', '1.2 GB')} - {field('A301.exe SHA256 해시', 'fileHash', 'sha256 해시값 (certutil -hashfile A301.exe SHA256)')} +

게임 배포 관리

+ + {info && ( +
+ 현재 배포 중 +
+ {info.version && {info.version}} + {info.fileName && {info.fileName}} + {info.fileSize && {info.fileSize}} + {info.fileHash && ( + + SHA256: {info.fileHash.slice(0, 12)}... + + )} +
+
+ )} + + +
+ + + + A301.exe가 포함된 zip 파일을 선택하세요. 버전·파일명·크기·해시가 자동으로 추출됩니다. + +
+ + {uploading && ( +
+
+ {progress}% +
+ )} + {error &&

{error}

} +
-