diff --git a/src/App.jsx b/src/App.jsx index bc8e88c..d1cbae7 100644 --- a/src/App.jsx +++ b/src/App.jsx @@ -1,6 +1,7 @@ import { BrowserRouter, Routes, Route, Navigate, useNavigate, useLocation } from 'react-router-dom'; import { useEffect, useRef } from 'react'; import { AuthProvider } from './context/AuthContext'; +import { ToastProvider } from './components/toast/ToastProvider'; import { useAuth } from './context/useAuth'; import LoginPage from './pages/LoginPage'; import RegisterPage from './pages/RegisterPage'; @@ -62,7 +63,9 @@ export default function App() { return ( - + + + ); diff --git a/src/api/client.js b/src/api/client.js index 2b6073e..c7d9f84 100644 --- a/src/api/client.js +++ b/src/api/client.js @@ -52,9 +52,20 @@ async function parseResponse(res) { return res.json(); } -export async function apiFetch(path, options = {}) { +export async function apiFetch(path, options = {}, _retryCount = 0) { const token = localStorage.getItem('token'); - const res = await doFetch(path, options, token); + let res; + + try { + res = await doFetch(path, options, token); + } catch (e) { + // 네트워크 에러 (오프라인 등) — 재시도 + if (_retryCount < 2) { + await delay(1000 * (_retryCount + 1)); + return apiFetch(path, options, _retryCount + 1); + } + throw e; + } if (res.status === 401) { try { @@ -79,6 +90,16 @@ export async function apiFetch(path, options = {}) { } } + // 5xx 서버 에러 — 최대 2회 재시도 (exponential backoff) + if (res.status >= 500 && _retryCount < 2) { + await delay(1000 * (_retryCount + 1)); + return apiFetch(path, options, _retryCount + 1); + } + if (!res.ok) throw await parseError(res); return parseResponse(res); } + +function delay(ms) { + return new Promise(resolve => setTimeout(resolve, ms)); +} diff --git a/src/components/admin/AnnouncementAdmin.jsx b/src/components/admin/AnnouncementAdmin.jsx index 182c5f3..e5ad8d7 100644 --- a/src/components/admin/AnnouncementAdmin.jsx +++ b/src/components/admin/AnnouncementAdmin.jsx @@ -1,18 +1,27 @@ import { useState, useEffect, useCallback } from 'react'; import { getAnnouncements, createAnnouncement, updateAnnouncement, deleteAnnouncement } from '../../api/announcements'; +import { useToast } from '../toast/useToast'; import './AdminCommon.css'; export default function AnnouncementAdmin() { + const toast = useToast(); const [list, setList] = useState([]); const [form, setForm] = useState({ title: '', content: '' }); const [editingId, setEditingId] = useState(null); const [loading, setLoading] = useState(false); + const [fetchLoading, setFetchLoading] = useState(true); const [error, setError] = useState(''); + const [fetchError, setFetchError] = useState(''); const load = useCallback(() => { - getAnnouncements().then(setList).catch((err) => { - console.error('공지사항 로드 실패:', err); - }); + setFetchError(''); + getAnnouncements() + .then(setList) + .catch((err) => { + console.error('공지사항 로드 실패:', err); + setFetchError('공지사항을 불러오지 못했습니다.'); + }) + .finally(() => setFetchLoading(false)); }, []); useEffect(() => { load(); }, [load]); @@ -30,11 +39,12 @@ export default function AnnouncementAdmin() { } else { await createAnnouncement(form.title, form.content); } + toast.success(editingId ? '공지사항이 수정되었습니다.' : '공지사항이 등록되었습니다.'); setForm({ title: '', content: '' }); setEditingId(null); load(); } catch (err) { - setError(err.message || '처리에 실패했습니다.'); + toast.error(err.message || '처리에 실패했습니다.'); } finally { setLoading(false); } @@ -50,9 +60,10 @@ export default function AnnouncementAdmin() { if (!confirm('삭제하시겠습니까?')) return; try { await deleteAnnouncement(id); + toast.success('공지사항이 삭제되었습니다.'); load(); } catch (err) { - setError(err.message || '삭제에 실패했습니다.'); + toast.error(err.message || '삭제에 실패했습니다.'); } }; @@ -62,6 +73,25 @@ export default function AnnouncementAdmin() { setError(''); }; + if (fetchLoading) { + return ( +
+

공지사항 관리

+

불러오는 중...

+
+ ); + } + + if (fetchError) { + return ( +
+

공지사항 관리

+

{fetchError}

+ +
+ ); + } + return (

공지사항 관리

diff --git a/src/components/admin/DownloadAdmin.jsx b/src/components/admin/DownloadAdmin.jsx index b4746fd..0b8d883 100644 --- a/src/components/admin/DownloadAdmin.jsx +++ b/src/components/admin/DownloadAdmin.jsx @@ -141,13 +141,40 @@ function UploadForm({ title, hint, accept, endpoint, onSuccess }) { export default function DownloadAdmin() { const [info, setInfo] = useState(null); + const [loading, setLoading] = useState(true); + const [loadError, setLoadError] = useState(''); useEffect(() => { - getDownloadInfo().then(setInfo).catch((err) => { - console.error('다운로드 정보 로드 실패:', err); - }); + getDownloadInfo() + .then((data) => { + setInfo(data); + setLoadError(''); + }) + .catch((err) => { + console.error('다운로드 정보 로드 실패:', err); + setLoadError('배포 정보를 불러올 수 없습니다.'); + }) + .finally(() => setLoading(false)); }, []); + if (loading) { + return ( +
+

게임 배포 관리

+

불러오는 중...

+
+ ); + } + + if (loadError) { + return ( +
+

게임 배포 관리

+

{loadError}

+
+ ); + } + return (

게임 배포 관리

diff --git a/src/components/admin/UserAdmin.jsx b/src/components/admin/UserAdmin.jsx index 30a88cf..210a72c 100644 --- a/src/components/admin/UserAdmin.jsx +++ b/src/components/admin/UserAdmin.jsx @@ -1,6 +1,7 @@ import { useState, useEffect, useCallback } from 'react'; import { getUsers, updateUserRole, deleteUser } from '../../api/users'; import { useAuth } from '../../context/useAuth'; +import { useToast } from '../toast/useToast'; import './AdminCommon.css'; export default function UserAdmin() { @@ -8,6 +9,7 @@ export default function UserAdmin() { const [loading, setLoading] = useState(true); const [error, setError] = useState(''); const { user: me } = useAuth(); + const toast = useToast(); const load = useCallback(() => { getUsers() @@ -23,9 +25,10 @@ export default function UserAdmin() { setError(''); try { await updateUserRole(u.id, newRole); + toast.success(`${u.username}의 권한이 ${newRole}로 변경되었습니다.`); load(); } catch (err) { - setError(err.message || '권한 변경에 실패했습니다.'); + toast.error(err.message || '권한 변경에 실패했습니다.'); } }; @@ -34,9 +37,10 @@ export default function UserAdmin() { setError(''); try { await deleteUser(u.id); + toast.success(`${u.username} 계정이 삭제되었습니다.`); load(); } catch (err) { - setError(err.message || '삭제에 실패했습니다.'); + toast.error(err.message || '삭제에 실패했습니다.'); } }; diff --git a/src/components/toast/ToastProvider.jsx b/src/components/toast/ToastProvider.jsx new file mode 100644 index 0000000..dfa2e70 --- /dev/null +++ b/src/components/toast/ToastProvider.jsx @@ -0,0 +1,70 @@ +import { useState, useCallback, useRef } from 'react'; +import { ToastContext } from './toastContextValue'; + +let toastId = 0; + +export function ToastProvider({ children }) { + const [toasts, setToasts] = useState([]); + const timersRef = useRef({}); + + const removeToast = useCallback((id) => { + clearTimeout(timersRef.current[id]); + delete timersRef.current[id]; + setToasts((prev) => prev.filter((t) => t.id !== id)); + }, []); + + const addToast = useCallback((message, type = 'info', duration = 3000) => { + const id = ++toastId; + setToasts((prev) => [...prev, { id, message, type }]); + timersRef.current[id] = setTimeout(() => removeToast(id), duration); + return id; + }, [removeToast]); + + const toast = useCallback((message) => addToast(message, 'info'), [addToast]); + toast.success = useCallback((message) => addToast(message, 'success'), [addToast]); + toast.error = useCallback((message) => addToast(message, 'error'), [addToast]); + toast.warn = useCallback((message) => addToast(message, 'warn'), [addToast]); + + return ( + + {children} +
+ {toasts.map((t) => ( +
removeToast(t.id)}> + {t.message} +
+ ))} +
+
+ ); +} + +const containerStyle = { + position: 'fixed', + top: 16, + right: 16, + zIndex: 10000, + display: 'flex', + flexDirection: 'column', + gap: 8, + pointerEvents: 'none', +}; + +const itemStyle = { + padding: '12px 20px', + borderRadius: 8, + color: '#fff', + fontSize: 14, + maxWidth: 360, + cursor: 'pointer', + pointerEvents: 'auto', + boxShadow: '0 4px 12px rgba(0,0,0,0.3)', + animation: 'toast-in 0.25s ease-out', +}; + +const typeStyles = { + info: { backgroundColor: '#3a3a3a' }, + success: { backgroundColor: '#2d6a4f' }, + error: { backgroundColor: '#9b2226' }, + warn: { backgroundColor: '#7f5539' }, +}; diff --git a/src/components/toast/toastContextValue.js b/src/components/toast/toastContextValue.js new file mode 100644 index 0000000..e61253e --- /dev/null +++ b/src/components/toast/toastContextValue.js @@ -0,0 +1,3 @@ +import { createContext } from 'react'; + +export const ToastContext = createContext(null); diff --git a/src/components/toast/useToast.js b/src/components/toast/useToast.js new file mode 100644 index 0000000..dd6f5c7 --- /dev/null +++ b/src/components/toast/useToast.js @@ -0,0 +1,8 @@ +import { useContext } from 'react'; +import { ToastContext } from './toastContextValue'; + +export function useToast() { + const ctx = useContext(ToastContext); + if (!ctx) throw new Error('useToast must be used within ToastProvider'); + return ctx; +} diff --git a/src/index.css b/src/index.css index 371c3c3..6f75ecd 100644 --- a/src/index.css +++ b/src/index.css @@ -31,3 +31,14 @@ a { a:hover { opacity: 0.85; } + +@keyframes toast-in { + from { + opacity: 0; + transform: translateX(40px); + } + to { + opacity: 1; + transform: translateX(0); + } +}