diff --git a/index.html b/index.html index baaaf83..82d547a 100644 --- a/index.html +++ b/index.html @@ -3,6 +3,9 @@ + + + One of the plans diff --git a/src/api/client.js b/src/api/client.js index 6cad6f9..8c1479f 100644 --- a/src/api/client.js +++ b/src/api/client.js @@ -1,5 +1,14 @@ const BASE = import.meta.env.VITE_API_BASE_URL || ''; +/** 네트워크 에러 메시지를 한국어로 변환 */ +function localizeError(message) { + if (typeof message !== 'string') return message; + if (message.includes('Failed to fetch')) return '서버에 연결할 수 없습니다'; + if (message.includes('NetworkError')) return '네트워크에 연결할 수 없습니다'; + if (message.includes('AbortError') || message === 'The user aborted a request.') return '요청 시간이 초과되었습니다'; + return message; +} + // 동시 401 발생 시 refresh를 한 번만 실행하기 위한 Promise 공유 let refreshingPromise = null; @@ -26,7 +35,7 @@ export async function tryRefresh() { } async function doFetch(path, options, token) { - const headers = { 'Content-Type': 'application/json', ...options.headers }; + const headers = { 'Content-Type': 'application/json', 'X-Requested-With': 'XMLHttpRequest', ...options.headers }; if (token) headers['Authorization'] = `Bearer ${token}`; return fetch(BASE + path, { ...options, headers, credentials: 'include' }); } @@ -60,6 +69,7 @@ export async function apiFetch(path, options = {}, _retryCount = 0) { await delay(1000 * (_retryCount + 1)); return apiFetch(path, options, _retryCount + 1); } + e.message = localizeError(e.message); throw e; } @@ -115,6 +125,7 @@ export function apiUpload(path, file, onProgress) { xhr.withCredentials = true; if (token) xhr.setRequestHeader('Authorization', `Bearer ${token}`); xhr.setRequestHeader('Content-Type', 'application/octet-stream'); + xhr.setRequestHeader('X-Requested-With', 'XMLHttpRequest'); xhr.upload.onprogress = (event) => { if (event.lengthComputable && onProgress) { @@ -123,7 +134,7 @@ export function apiUpload(path, file, onProgress) { }; xhr.onload = () => resolve(xhr); - xhr.onerror = () => reject(new Error('네트워크 오류가 발생했습니다.')); + xhr.onerror = () => reject(new Error('서버에 연결할 수 없습니다')); xhr.send(file); }); } diff --git a/src/components/admin/DownloadAdmin.jsx b/src/components/admin/DownloadAdmin.jsx index 2eb22a9..727c85d 100644 --- a/src/components/admin/DownloadAdmin.jsx +++ b/src/components/admin/DownloadAdmin.jsx @@ -1,10 +1,12 @@ // TODO: Add tests for CRUD operations (load download info, upload launcher, upload game) import { useState, useEffect } from 'react'; import { getDownloadInfo } from '../../api/download'; +import { useToast } from '../toast/useToast'; import UploadForm from './UploadForm'; import './AdminCommon.css'; export default function DownloadAdmin() { + const toast = useToast(); const [info, setInfo] = useState(null); const [loading, setLoading] = useState(true); const [loadError, setLoadError] = useState(''); @@ -20,6 +22,7 @@ export default function DownloadAdmin() { .catch((err) => { console.error('다운로드 정보 로드 실패:', err); setLoadError('배포 정보를 불러올 수 없습니다.'); + toast.error('배포 정보를 불러올 수 없습니다.'); }) .finally(() => setLoading(false)); }; diff --git a/src/components/admin/UploadForm.jsx b/src/components/admin/UploadForm.jsx index 7b9a0d4..29e5874 100644 --- a/src/components/admin/UploadForm.jsx +++ b/src/components/admin/UploadForm.jsx @@ -1,7 +1,9 @@ import { useState, useRef } from 'react'; import { apiUpload } from '../../api/client'; +import { useToast } from '../toast/useToast'; export default function UploadForm({ title, hint, accept, endpoint, onSuccess }) { + const toast = useToast(); const [file, setFile] = useState(null); const [uploading, setUploading] = useState(false); const [progress, setProgress] = useState(0); @@ -27,15 +29,32 @@ export default function UploadForm({ title, hint, accept, endpoint, onSuccess }) const { status, body } = await apiUpload(path, file, (p) => setProgress(p)); if (status >= 200 && status < 300) { onSuccess(body); + toast.success('업로드가 완료되었습니다.'); setFile(null); if (fileInputRef.current) fileInputRef.current.value = ''; setProgress(0); + } else if (status === 413) { + const msg = '파일 크기가 너무 큽니다. 더 작은 파일을 선택해주세요.'; + setError(msg); + toast.error(msg); + } else if (status === 409) { + const msg = '동일한 파일이 이미 존재합니다.'; + setError(msg); + toast.error(msg); + } else if (status >= 500) { + const msg = '서버 오류가 발생했습니다. 잠시 후 다시 시도해주세요.'; + setError(msg); + toast.error(msg); } else { - setError(body.error || '업로드에 실패했습니다.'); + const msg = body.error || '업로드에 실패했습니다.'; + setError(msg); + toast.error(msg); setProgress(0); } } catch { - setError('네트워크 오류가 발생했습니다.'); + const msg = '네트워크 오류가 발생했습니다.'; + setError(msg); + toast.error(msg); setProgress(0); } finally { setUploading(false); diff --git a/src/pages/LoginPage.jsx b/src/pages/LoginPage.jsx index 06985a3..de17398 100644 --- a/src/pages/LoginPage.jsx +++ b/src/pages/LoginPage.jsx @@ -85,6 +85,9 @@ export default function LoginPage() { onClick={async () => { try { const data = await getSSAFYLoginURL(); + if (!data.url || !data.url.startsWith('https://')) { + throw new Error('유효하지 않은 로그인 URL입니다.'); + } window.location.href = data.url; } catch { setError('SSAFY 로그인 URL을 가져올 수 없습니다.');