feat: Toast 알림 시스템 + 어드민 UX 개선
All checks were successful
Client CI/CD / deploy (push) Successful in 29s

- Toast 알림 컴포넌트 추가 (success/error/warn/info)
- AnnouncementAdmin 초기 로딩 상태 + 에러 표시
- DownloadAdmin 로딩 상태 추가
- UserAdmin/AnnouncementAdmin에 Toast 피드백 적용
- API client 5xx/네트워크 에러 자동 재시도

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-03-15 03:42:20 +09:00
parent f93d81b6d9
commit 254617530c
9 changed files with 190 additions and 13 deletions

View File

@@ -1,6 +1,7 @@
import { BrowserRouter, Routes, Route, Navigate, useNavigate, useLocation } from 'react-router-dom'; import { BrowserRouter, Routes, Route, Navigate, useNavigate, useLocation } from 'react-router-dom';
import { useEffect, useRef } from 'react'; import { useEffect, useRef } from 'react';
import { AuthProvider } from './context/AuthContext'; import { AuthProvider } from './context/AuthContext';
import { ToastProvider } from './components/toast/ToastProvider';
import { useAuth } from './context/useAuth'; import { useAuth } from './context/useAuth';
import LoginPage from './pages/LoginPage'; import LoginPage from './pages/LoginPage';
import RegisterPage from './pages/RegisterPage'; import RegisterPage from './pages/RegisterPage';
@@ -62,7 +63,9 @@ export default function App() {
return ( return (
<BrowserRouter> <BrowserRouter>
<AuthProvider> <AuthProvider>
<AppRoutes /> <ToastProvider>
<AppRoutes />
</ToastProvider>
</AuthProvider> </AuthProvider>
</BrowserRouter> </BrowserRouter>
); );

View File

@@ -52,9 +52,20 @@ async function parseResponse(res) {
return res.json(); return res.json();
} }
export async function apiFetch(path, options = {}) { export async function apiFetch(path, options = {}, _retryCount = 0) {
const token = localStorage.getItem('token'); 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) { if (res.status === 401) {
try { 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); if (!res.ok) throw await parseError(res);
return parseResponse(res); return parseResponse(res);
} }
function delay(ms) {
return new Promise(resolve => setTimeout(resolve, ms));
}

View File

@@ -1,18 +1,27 @@
import { useState, useEffect, useCallback } from 'react'; import { useState, useEffect, useCallback } from 'react';
import { getAnnouncements, createAnnouncement, updateAnnouncement, deleteAnnouncement } from '../../api/announcements'; import { getAnnouncements, createAnnouncement, updateAnnouncement, deleteAnnouncement } from '../../api/announcements';
import { useToast } from '../toast/useToast';
import './AdminCommon.css'; import './AdminCommon.css';
export default function AnnouncementAdmin() { export default function AnnouncementAdmin() {
const toast = useToast();
const [list, setList] = useState([]); const [list, setList] = useState([]);
const [form, setForm] = useState({ title: '', content: '' }); const [form, setForm] = useState({ title: '', content: '' });
const [editingId, setEditingId] = useState(null); const [editingId, setEditingId] = useState(null);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [fetchLoading, setFetchLoading] = useState(true);
const [error, setError] = useState(''); const [error, setError] = useState('');
const [fetchError, setFetchError] = useState('');
const load = useCallback(() => { const load = useCallback(() => {
getAnnouncements().then(setList).catch((err) => { setFetchError('');
console.error('공지사항 로드 실패:', err); getAnnouncements()
}); .then(setList)
.catch((err) => {
console.error('공지사항 로드 실패:', err);
setFetchError('공지사항을 불러오지 못했습니다.');
})
.finally(() => setFetchLoading(false));
}, []); }, []);
useEffect(() => { load(); }, [load]); useEffect(() => { load(); }, [load]);
@@ -30,11 +39,12 @@ export default function AnnouncementAdmin() {
} else { } else {
await createAnnouncement(form.title, form.content); await createAnnouncement(form.title, form.content);
} }
toast.success(editingId ? '공지사항이 수정되었습니다.' : '공지사항이 등록되었습니다.');
setForm({ title: '', content: '' }); setForm({ title: '', content: '' });
setEditingId(null); setEditingId(null);
load(); load();
} catch (err) { } catch (err) {
setError(err.message || '처리에 실패했습니다.'); toast.error(err.message || '처리에 실패했습니다.');
} finally { } finally {
setLoading(false); setLoading(false);
} }
@@ -50,9 +60,10 @@ export default function AnnouncementAdmin() {
if (!confirm('삭제하시겠습니까?')) return; if (!confirm('삭제하시겠습니까?')) return;
try { try {
await deleteAnnouncement(id); await deleteAnnouncement(id);
toast.success('공지사항이 삭제되었습니다.');
load(); load();
} catch (err) { } catch (err) {
setError(err.message || '삭제에 실패했습니다.'); toast.error(err.message || '삭제에 실패했습니다.');
} }
}; };
@@ -62,6 +73,25 @@ export default function AnnouncementAdmin() {
setError(''); setError('');
}; };
if (fetchLoading) {
return (
<div className="admin-section">
<h2 className="admin-section-title">공지사항 관리</h2>
<p className="admin-loading">불러오는 ...</p>
</div>
);
}
if (fetchError) {
return (
<div className="admin-section">
<h2 className="admin-section-title">공지사항 관리</h2>
<p className="admin-error">{fetchError}</p>
<button className="btn-admin-secondary" onClick={load}>다시 시도</button>
</div>
);
}
return ( return (
<div className="admin-section"> <div className="admin-section">
<h2 className="admin-section-title">공지사항 관리</h2> <h2 className="admin-section-title">공지사항 관리</h2>

View File

@@ -141,13 +141,40 @@ function UploadForm({ title, hint, accept, endpoint, onSuccess }) {
export default function DownloadAdmin() { export default function DownloadAdmin() {
const [info, setInfo] = useState(null); const [info, setInfo] = useState(null);
const [loading, setLoading] = useState(true);
const [loadError, setLoadError] = useState('');
useEffect(() => { useEffect(() => {
getDownloadInfo().then(setInfo).catch((err) => { getDownloadInfo()
console.error('다운로드 정보 로드 실패:', err); .then((data) => {
}); setInfo(data);
setLoadError('');
})
.catch((err) => {
console.error('다운로드 정보 로드 실패:', err);
setLoadError('배포 정보를 불러올 수 없습니다.');
})
.finally(() => setLoading(false));
}, []); }, []);
if (loading) {
return (
<div className="admin-section">
<h2 className="admin-section-title">게임 배포 관리</h2>
<p className="admin-loading">불러오는 ...</p>
</div>
);
}
if (loadError) {
return (
<div className="admin-section">
<h2 className="admin-section-title">게임 배포 관리</h2>
<p className="admin-error">{loadError}</p>
</div>
);
}
return ( return (
<div className="admin-section"> <div className="admin-section">
<h2 className="admin-section-title">게임 배포 관리</h2> <h2 className="admin-section-title">게임 배포 관리</h2>

View File

@@ -1,6 +1,7 @@
import { useState, useEffect, useCallback } from 'react'; import { useState, useEffect, useCallback } from 'react';
import { getUsers, updateUserRole, deleteUser } from '../../api/users'; import { getUsers, updateUserRole, deleteUser } from '../../api/users';
import { useAuth } from '../../context/useAuth'; import { useAuth } from '../../context/useAuth';
import { useToast } from '../toast/useToast';
import './AdminCommon.css'; import './AdminCommon.css';
export default function UserAdmin() { export default function UserAdmin() {
@@ -8,6 +9,7 @@ export default function UserAdmin() {
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [error, setError] = useState(''); const [error, setError] = useState('');
const { user: me } = useAuth(); const { user: me } = useAuth();
const toast = useToast();
const load = useCallback(() => { const load = useCallback(() => {
getUsers() getUsers()
@@ -23,9 +25,10 @@ export default function UserAdmin() {
setError(''); setError('');
try { try {
await updateUserRole(u.id, newRole); await updateUserRole(u.id, newRole);
toast.success(`${u.username}의 권한이 ${newRole}로 변경되었습니다.`);
load(); load();
} catch (err) { } catch (err) {
setError(err.message || '권한 변경에 실패했습니다.'); toast.error(err.message || '권한 변경에 실패했습니다.');
} }
}; };
@@ -34,9 +37,10 @@ export default function UserAdmin() {
setError(''); setError('');
try { try {
await deleteUser(u.id); await deleteUser(u.id);
toast.success(`${u.username} 계정이 삭제되었습니다.`);
load(); load();
} catch (err) { } catch (err) {
setError(err.message || '삭제에 실패했습니다.'); toast.error(err.message || '삭제에 실패했습니다.');
} }
}; };

View File

@@ -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 (
<ToastContext.Provider value={toast}>
{children}
<div style={containerStyle}>
{toasts.map((t) => (
<div key={t.id} style={{ ...itemStyle, ...typeStyles[t.type] }} onClick={() => removeToast(t.id)}>
{t.message}
</div>
))}
</div>
</ToastContext.Provider>
);
}
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' },
};

View File

@@ -0,0 +1,3 @@
import { createContext } from 'react';
export const ToastContext = createContext(null);

View File

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

View File

@@ -31,3 +31,14 @@ a {
a:hover { a:hover {
opacity: 0.85; opacity: 0.85;
} }
@keyframes toast-in {
from {
opacity: 0;
transform: translateX(40px);
}
to {
opacity: 1;
transform: translateX(0);
}
}