diff --git a/index.html b/index.html
index 9afc5b0..baaaf83 100644
--- a/index.html
+++ b/index.html
@@ -1,10 +1,9 @@
-
+
-
- a301_client
+ One of the plans
diff --git a/public/vite.svg b/public/vite.svg
deleted file mode 100644
index e7b8dfb..0000000
--- a/public/vite.svg
+++ /dev/null
@@ -1 +0,0 @@
-
\ No newline at end of file
diff --git a/src/App.css b/src/App.css
deleted file mode 100644
index 2acca77..0000000
--- a/src/App.css
+++ /dev/null
@@ -1 +0,0 @@
-/* Global app styles - kept minimal, page-level styles in pages/ */
diff --git a/src/api/announcements.js b/src/api/announcements.js
index 6eae046..52fd871 100644
--- a/src/api/announcements.js
+++ b/src/api/announcements.js
@@ -3,3 +3,21 @@ import { apiFetch } from './client';
export async function getAnnouncements() {
return apiFetch('/api/announcements');
}
+
+export async function createAnnouncement(title, content) {
+ return apiFetch('/api/announcements', {
+ method: 'POST',
+ body: JSON.stringify({ title, content }),
+ });
+}
+
+export async function updateAnnouncement(id, title, content) {
+ return apiFetch(`/api/announcements/${id}`, {
+ method: 'PUT',
+ body: JSON.stringify({ title, content }),
+ });
+}
+
+export async function deleteAnnouncement(id) {
+ return apiFetch(`/api/announcements/${id}`, { method: 'DELETE' });
+}
diff --git a/src/api/auth.js b/src/api/auth.js
index 1206461..fe9d6ed 100644
--- a/src/api/auth.js
+++ b/src/api/auth.js
@@ -29,25 +29,6 @@ export async function ssafyCallback(code) {
});
}
-// 토큰을 리프레시하고 새 access token을 반환
-export async function refreshToken() {
- const rt = localStorage.getItem('refreshToken');
- if (!rt) throw new Error('no_refresh_token');
-
- const res = await fetch(
- (import.meta.env.VITE_API_BASE_URL || '') + '/api/auth/refresh',
- {
- method: 'POST',
- headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify({ refreshToken: rt }),
- }
- );
-
- if (!res.ok) throw new Error('refresh_failed');
-
- const data = await res.json();
- localStorage.setItem('token', data.token);
- localStorage.setItem('refreshToken', data.refreshToken);
- return data.token;
-}
+// 토큰을 리프레시하고 새 access token을 반환 (동시 호출 방지 포함)
+export { tryRefresh as refreshToken } from './client';
diff --git a/src/api/client.js b/src/api/client.js
index 2b46a88..2b6073e 100644
--- a/src/api/client.js
+++ b/src/api/client.js
@@ -3,7 +3,7 @@ const BASE = import.meta.env.VITE_API_BASE_URL || '';
// 동시 401 발생 시 refresh를 한 번만 실행하기 위한 Promise 공유
let refreshingPromise = null;
-async function tryRefresh() {
+export async function tryRefresh() {
if (refreshingPromise) return refreshingPromise;
refreshingPromise = (async () => {
@@ -40,7 +40,7 @@ async function parseError(res) {
try {
const body = await res.json();
if (body.error) message = body.error;
- } catch {}
+ } catch { /* 응답 바디 파싱 실패 시 statusText 사용 */ }
const err = new Error(message);
err.status = res.status;
return err;
diff --git a/src/assets/react.svg b/src/assets/react.svg
deleted file mode 100644
index 6c87de9..0000000
--- a/src/assets/react.svg
+++ /dev/null
@@ -1 +0,0 @@
-
\ No newline at end of file
diff --git a/src/components/AnnouncementBoard.css b/src/components/AnnouncementBoard.css
index 30eb071..08c934c 100644
--- a/src/components/AnnouncementBoard.css
+++ b/src/components/AnnouncementBoard.css
@@ -59,3 +59,15 @@
color: rgba(255, 255, 255, 0.6);
line-height: 1.6;
}
+
+.announcement-error {
+ font-size: 0.9rem;
+ color: #e57373;
+ padding: 12px 8px;
+}
+
+.announcement-empty {
+ font-size: 0.9rem;
+ color: rgba(255, 255, 255, 0.35);
+ padding: 12px 8px;
+}
diff --git a/src/components/AnnouncementBoard.jsx b/src/components/AnnouncementBoard.jsx
index beeab97..214a75a 100644
--- a/src/components/AnnouncementBoard.jsx
+++ b/src/components/AnnouncementBoard.jsx
@@ -5,18 +5,24 @@ import './AnnouncementBoard.css';
export default function AnnouncementBoard() {
const [list, setList] = useState([]);
const [expanded, setExpanded] = useState(null);
+ const [loading, setLoading] = useState(true);
const [error, setError] = useState(false);
useEffect(() => {
getAnnouncements()
.then(setList)
- .catch(() => setError(true));
+ .catch(() => setError(true))
+ .finally(() => setLoading(false));
}, []);
return (
공지사항
+ {loading && 불러오는 중...
}
{error && 공지사항을 불러오지 못했습니다.
}
+ {!loading && !error && list.length === 0 && (
+ 등록된 공지사항이 없습니다.
+ )}
{list.map((item) => (
-
diff --git a/src/components/DownloadSection.jsx b/src/components/DownloadSection.jsx
index b6f87cc..7af6f23 100644
--- a/src/components/DownloadSection.jsx
+++ b/src/components/DownloadSection.jsx
@@ -1,4 +1,4 @@
-import { useState, useEffect, useCallback } from 'react';
+import { useState, useEffect } from 'react';
import { useNavigate } from 'react-router-dom';
import { useAuth } from '../context/useAuth';
import { getDownloadInfo } from '../api/download';
@@ -14,15 +14,19 @@ export default function DownloadSection() {
const { user } = useAuth();
const navigate = useNavigate();
- const loadInfo = useCallback(() => {
+ const loadInfo = () => {
setReady(false);
setLoadError(false);
getDownloadInfo()
.then((data) => { setInfo(data); setReady(true); })
.catch(() => { setLoadError(true); setReady(true); });
- }, []);
+ };
- useEffect(() => { loadInfo(); }, [loadInfo]);
+ useEffect(() => {
+ getDownloadInfo()
+ .then((data) => { setInfo(data); setReady(true); })
+ .catch(() => { setLoadError(true); setReady(true); });
+ }, []);
const handlePlay = async () => {
if (!user) {
@@ -76,7 +80,7 @@ export default function DownloadSection() {
- {info?.launcherUrl && (
+ {info.launcherUrl && (
diff --git a/src/components/admin/AdminCommon.css b/src/components/admin/AdminCommon.css
index 174f5c8..de48832 100644
--- a/src/components/admin/AdminCommon.css
+++ b/src/components/admin/AdminCommon.css
@@ -282,6 +282,13 @@
}
/* Role badge */
+.admin-list-empty {
+ font-size: 0.9rem;
+ color: rgba(255, 255, 255, 0.35);
+ padding: 12px 16px;
+ margin: 0;
+}
+
.admin-role-badge {
font-size: 0.7rem;
padding: 2px 8px;
diff --git a/src/components/admin/AnnouncementAdmin.jsx b/src/components/admin/AnnouncementAdmin.jsx
index 98e35f6..17f8e4e 100644
--- a/src/components/admin/AnnouncementAdmin.jsx
+++ b/src/components/admin/AnnouncementAdmin.jsx
@@ -1,6 +1,5 @@
-import { useState, useEffect } from 'react';
-import { getAnnouncements } from '../../api/announcements';
-import { apiFetch } from '../../api/client';
+import { useState, useEffect, useCallback } from 'react';
+import { getAnnouncements, createAnnouncement, updateAnnouncement, deleteAnnouncement } from '../../api/announcements';
import './AdminCommon.css';
export default function AnnouncementAdmin() {
@@ -10,25 +9,24 @@ export default function AnnouncementAdmin() {
const [loading, setLoading] = useState(false);
const [error, setError] = useState('');
- const load = () => getAnnouncements().then(setList).catch(() => {});
- useEffect(() => { load(); }, []);
+ const load = useCallback(() => {
+ getAnnouncements().then(setList).catch(() => {});
+ }, []);
+ useEffect(() => { load(); }, [load]);
const handleSubmit = async (e) => {
e.preventDefault();
- if (!form.title || !form.content) return;
+ if (!form.title || !form.content) {
+ setError('제목과 내용을 모두 입력해주세요.');
+ return;
+ }
setLoading(true);
setError('');
try {
if (editingId) {
- await apiFetch(`/api/announcements/${editingId}`, {
- method: 'PUT',
- body: JSON.stringify(form),
- });
+ await updateAnnouncement(editingId, form.title, form.content);
} else {
- await apiFetch('/api/announcements', {
- method: 'POST',
- body: JSON.stringify(form),
- });
+ await createAnnouncement(form.title, form.content);
}
setForm({ title: '', content: '' });
setEditingId(null);
@@ -49,7 +47,7 @@ export default function AnnouncementAdmin() {
const handleDelete = async (id) => {
if (!confirm('삭제하시겠습니까?')) return;
try {
- await apiFetch(`/api/announcements/${id}`, { method: 'DELETE' });
+ await deleteAnnouncement(id);
load();
} catch (err) {
setError(err.message || '삭제에 실패했습니다.');
diff --git a/src/components/admin/DownloadAdmin.jsx b/src/components/admin/DownloadAdmin.jsx
index 6c88371..1a026e3 100644
--- a/src/components/admin/DownloadAdmin.jsx
+++ b/src/components/admin/DownloadAdmin.jsx
@@ -1,9 +1,28 @@
import { useState, useEffect } from 'react';
import { getDownloadInfo } from '../../api/download';
+import { tryRefresh } from '../../api/client';
import './AdminCommon.css';
const BASE = import.meta.env.VITE_API_BASE_URL || '';
+function sendXhr(url, token, file, { onProgress, onDone, onError }) {
+ const xhr = new XMLHttpRequest();
+
+ xhr.upload.onprogress = (event) => {
+ if (event.lengthComputable) {
+ onProgress(Math.round((event.loaded / event.total) * 100));
+ }
+ };
+
+ xhr.onload = () => onDone(xhr);
+ xhr.onerror = () => onError();
+
+ xhr.open('POST', url);
+ xhr.setRequestHeader('Authorization', `Bearer ${token}`);
+ xhr.setRequestHeader('Content-Type', 'application/octet-stream');
+ xhr.send(file);
+}
+
function UploadForm({ title, hint, accept, endpoint, onSuccess }) {
const [file, setFile] = useState(null);
const [uploading, setUploading] = useState(false);
@@ -21,43 +40,71 @@ function UploadForm({ title, hint, accept, endpoint, onSuccess }) {
if (!file) return;
const token = localStorage.getItem('token');
- const xhr = new XMLHttpRequest();
+ const url = `${BASE}${endpoint}?filename=${encodeURIComponent(file.name)}`;
- xhr.upload.onprogress = (event) => {
- if (event.lengthComputable) {
- setProgress(Math.round((event.loaded / event.total) * 100));
- }
- };
+ setUploading(true);
+ setError('');
- xhr.onload = () => {
- setUploading(false);
+ const handleDone = (xhr) => {
+ // 401 시 토큰 갱신 후 재시도
if (xhr.status === 401) {
- window.dispatchEvent(new Event('auth:unauthorized'));
+ tryRefresh()
+ .then((newToken) => {
+ sendXhr(url, newToken, file, {
+ onProgress: (p) => setProgress(p),
+ onDone: (retryXhr) => {
+ setUploading(false);
+ if (retryXhr.status === 401) {
+ window.dispatchEvent(new Event('auth:unauthorized'));
+ return;
+ }
+ parseXhrResponse(retryXhr);
+ },
+ onError: handleError,
+ });
+ })
+ .catch(() => {
+ setUploading(false);
+ window.dispatchEvent(new Event('auth:unauthorized'));
+ });
return;
}
- if (xhr.status >= 200 && xhr.status < 300) {
- onSuccess(JSON.parse(xhr.responseText));
- setFile(null);
- setProgress(0);
- } else {
- const res = JSON.parse(xhr.responseText || '{}');
- setError(res.error || '업로드에 실패했습니다.');
+ setUploading(false);
+ parseXhrResponse(xhr);
+ };
+
+ const parseXhrResponse = (xhr) => {
+ try {
+ const body = JSON.parse(xhr.responseText || '{}');
+ if (xhr.status >= 200 && xhr.status < 300) {
+ onSuccess(body);
+ setFile(null);
+ setProgress(0);
+ } else {
+ setError(body.error || '업로드에 실패했습니다.');
+ setProgress(0);
+ }
+ } catch {
+ if (xhr.status >= 200 && xhr.status < 300) {
+ setError('응답을 처리할 수 없습니다.');
+ } else {
+ setError('업로드에 실패했습니다.');
+ }
setProgress(0);
}
};
- xhr.onerror = () => {
+ const handleError = () => {
setUploading(false);
setError('네트워크 오류가 발생했습니다.');
setProgress(0);
};
- xhr.open('POST', `${BASE}${endpoint}?filename=${encodeURIComponent(file.name)}`);
- xhr.setRequestHeader('Authorization', `Bearer ${token}`);
- xhr.setRequestHeader('Content-Type', 'application/octet-stream');
- setUploading(true);
- setError('');
- xhr.send(file);
+ sendXhr(url, token, file, {
+ onProgress: (p) => setProgress(p),
+ onDone: handleDone,
+ onError: handleError,
+ });
};
return (
diff --git a/src/components/admin/UserAdmin.jsx b/src/components/admin/UserAdmin.jsx
index 0b6d9a5..537e521 100644
--- a/src/components/admin/UserAdmin.jsx
+++ b/src/components/admin/UserAdmin.jsx
@@ -1,15 +1,21 @@
-import { useState, useEffect } from 'react';
+import { useState, useEffect, useCallback } from 'react';
import { getUsers, updateUserRole, deleteUser } from '../../api/users';
import { useAuth } from '../../context/useAuth';
import './AdminCommon.css';
export default function UserAdmin() {
const [users, setUsers] = useState([]);
+ const [loading, setLoading] = useState(true);
const [error, setError] = useState('');
const { user: me } = useAuth();
- const load = () => getUsers().then(setUsers).catch(() => {});
- useEffect(() => { load(); }, []);
+ const load = useCallback(() => {
+ getUsers()
+ .then(setUsers)
+ .catch(() => {})
+ .finally(() => setLoading(false));
+ }, []);
+ useEffect(() => { load(); }, [load]);
const handleRoleToggle = async (u) => {
const newRole = u.role === 'admin' ? 'user' : 'admin';
@@ -38,6 +44,8 @@ export default function UserAdmin() {
유저 관리
{error &&
{error}
}
+ {loading &&
불러오는 중...
}
+ {!loading && users.length === 0 &&
등록된 유저가 없습니다.
}
{users.map((u) => (
-
diff --git a/src/context/AuthContext.jsx b/src/context/AuthContext.jsx
index 8c08017..fe6e881 100644
--- a/src/context/AuthContext.jsx
+++ b/src/context/AuthContext.jsx
@@ -41,7 +41,7 @@ export function AuthProvider({ children }) {
const logout = useCallback(async () => {
try {
await apiLogout();
- } catch {}
+ } catch { /* 서버 실패해도 로컬 세션은 정리 */ }
clearSession();
}, [clearSession]);
diff --git a/src/pages/LoginPage.jsx b/src/pages/LoginPage.jsx
index 6ff3482..2788943 100644
--- a/src/pages/LoginPage.jsx
+++ b/src/pages/LoginPage.jsx
@@ -17,6 +17,10 @@ export default function LoginPage() {
const handleSubmit = async (e) => {
e.preventDefault();
setError('');
+ if (!username.trim() || !password) {
+ setError('아이디와 비밀번호를 입력해주세요.');
+ return;
+ }
setLoading(true);
try {
await login(username, password);
diff --git a/src/pages/RegisterPage.jsx b/src/pages/RegisterPage.jsx
index 4710050..00771bc 100644
--- a/src/pages/RegisterPage.jsx
+++ b/src/pages/RegisterPage.jsx
@@ -31,6 +31,10 @@ export default function RegisterPage() {
e.preventDefault();
setError('');
+ if (!username.trim()) {
+ setError('아이디를 입력해주세요.');
+ return;
+ }
if (password !== confirm) {
setError('비밀번호가 일치하지 않습니다.');
return;