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 { 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>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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));
|
||||||
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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 || '삭제에 실패했습니다.');
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
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 {
|
a:hover {
|
||||||
opacity: 0.85;
|
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