feat: Toast 알림 시스템 + 어드민 UX 개선
All checks were successful
Client CI/CD / deploy (push) Successful in 29s
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:
@@ -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 (
|
||||
<BrowserRouter>
|
||||
<AuthProvider>
|
||||
<ToastProvider>
|
||||
<AppRoutes />
|
||||
</ToastProvider>
|
||||
</AuthProvider>
|
||||
</BrowserRouter>
|
||||
);
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
@@ -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) => {
|
||||
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 (
|
||||
<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 (
|
||||
<div className="admin-section">
|
||||
<h2 className="admin-section-title">공지사항 관리</h2>
|
||||
|
||||
@@ -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) => {
|
||||
getDownloadInfo()
|
||||
.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 (
|
||||
<div className="admin-section">
|
||||
<h2 className="admin-section-title">게임 배포 관리</h2>
|
||||
|
||||
@@ -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 || '삭제에 실패했습니다.');
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
70
src/components/toast/ToastProvider.jsx
Normal file
70
src/components/toast/ToastProvider.jsx
Normal 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' },
|
||||
};
|
||||
3
src/components/toast/toastContextValue.js
Normal file
3
src/components/toast/toastContextValue.js
Normal file
@@ -0,0 +1,3 @@
|
||||
import { createContext } from 'react';
|
||||
|
||||
export const ToastContext = createContext(null);
|
||||
8
src/components/toast/useToast.js
Normal file
8
src/components/toast/useToast.js
Normal 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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user