diff --git a/src/App.jsx b/src/App.jsx
index 2a75c7e..3de530a 100644
--- a/src/App.jsx
+++ b/src/App.jsx
@@ -2,16 +2,29 @@ import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom';
import { AuthProvider, useAuth } from './context/AuthContext';
import LoginPage from './pages/LoginPage';
import HomePage from './pages/HomePage';
+import AdminPage from './pages/AdminPage';
+
+function AdminRoute({ children }) {
+ const { user } = useAuth();
+ if (!user) return ;
+ if (user.role !== 'admin') return ;
+ return children;
+}
function AppRoutes() {
const { user } = useAuth();
return (
- : }
- />
+ : } />
} />
+
+
+
+ }
+ />
);
}
diff --git a/src/api/users.js b/src/api/users.js
new file mode 100644
index 0000000..c0eca5f
--- /dev/null
+++ b/src/api/users.js
@@ -0,0 +1,16 @@
+import { apiFetch } from './client';
+
+export function getUsers() {
+ return apiFetch('/api/users');
+}
+
+export function updateUserRole(id, role) {
+ return apiFetch(`/api/users/${id}/role`, {
+ method: 'PATCH',
+ body: JSON.stringify({ role }),
+ });
+}
+
+export function deleteUser(id) {
+ return apiFetch(`/api/users/${id}`, { method: 'DELETE' });
+}
diff --git a/src/components/admin/AdminCommon.css b/src/components/admin/AdminCommon.css
new file mode 100644
index 0000000..1d0ef75
--- /dev/null
+++ b/src/components/admin/AdminCommon.css
@@ -0,0 +1,200 @@
+.admin-section {
+ display: flex;
+ flex-direction: column;
+ gap: 24px;
+}
+
+.admin-section-title {
+ font-size: 1.1rem;
+ font-weight: 700;
+ color: rgba(255, 255, 255, 0.9);
+ margin: 0;
+ padding-bottom: 12px;
+ border-bottom: 1px solid rgba(186, 205, 176, 0.12);
+}
+
+/* Form */
+.admin-form {
+ display: flex;
+ flex-direction: column;
+ gap: 12px;
+ padding: 20px;
+ background: rgba(255, 255, 255, 0.03);
+ border: 1px solid rgba(186, 205, 176, 0.1);
+ border-radius: 10px;
+}
+
+.admin-field {
+ display: flex;
+ flex-direction: column;
+ gap: 6px;
+}
+
+.admin-label {
+ font-size: 0.8rem;
+ color: rgba(255, 255, 255, 0.5);
+}
+
+.admin-input {
+ padding: 10px 14px;
+ background: rgba(255, 255, 255, 0.05);
+ border: 1px solid rgba(186, 205, 176, 0.15);
+ border-radius: 6px;
+ color: #fff;
+ font-size: 0.9rem;
+ outline: none;
+ transition: border-color 0.2s;
+}
+
+.admin-input:focus {
+ border-color: rgba(186, 205, 176, 0.5);
+}
+
+.admin-textarea {
+ padding: 10px 14px;
+ background: rgba(255, 255, 255, 0.05);
+ border: 1px solid rgba(186, 205, 176, 0.15);
+ border-radius: 6px;
+ color: #fff;
+ font-size: 0.9rem;
+ outline: none;
+ resize: vertical;
+ transition: border-color 0.2s;
+ font-family: inherit;
+}
+
+.admin-textarea:focus {
+ border-color: rgba(186, 205, 176, 0.5);
+}
+
+.admin-form-actions {
+ display: flex;
+ gap: 8px;
+ margin-top: 4px;
+}
+
+/* Buttons */
+.btn-admin-primary {
+ padding: 10px 24px;
+ background: #BACDB0;
+ color: #2E2C2F;
+ border: none;
+ border-radius: 6px;
+ font-size: 0.9rem;
+ font-weight: 600;
+ cursor: pointer;
+ transition: opacity 0.2s;
+}
+
+.btn-admin-primary:hover { opacity: 0.9; }
+.btn-admin-primary:disabled { opacity: 0.5; cursor: not-allowed; }
+
+.btn-admin-secondary {
+ padding: 10px 20px;
+ background: transparent;
+ color: rgba(255, 255, 255, 0.5);
+ border: 1px solid rgba(255, 255, 255, 0.15);
+ border-radius: 6px;
+ font-size: 0.9rem;
+ cursor: pointer;
+ transition: background 0.2s;
+}
+
+.btn-admin-secondary:hover {
+ background: rgba(255, 255, 255, 0.05);
+}
+
+/* List */
+.admin-list {
+ list-style: none;
+ margin: 0;
+ padding: 0;
+ display: flex;
+ flex-direction: column;
+ gap: 4px;
+}
+
+.admin-list-item {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ padding: 12px 16px;
+ background: rgba(255, 255, 255, 0.03);
+ border: 1px solid rgba(255, 255, 255, 0.06);
+ border-radius: 8px;
+}
+
+.admin-list-info {
+ display: flex;
+ align-items: center;
+ gap: 12px;
+ min-width: 0;
+}
+
+.admin-list-title {
+ font-size: 0.9rem;
+ color: rgba(255, 255, 255, 0.85);
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+}
+
+.admin-list-date {
+ font-size: 0.8rem;
+ color: rgba(255, 255, 255, 0.3);
+ flex-shrink: 0;
+}
+
+.admin-list-actions {
+ display: flex;
+ gap: 8px;
+ flex-shrink: 0;
+}
+
+.btn-admin-edit {
+ padding: 6px 14px;
+ background: transparent;
+ color: rgba(186, 205, 176, 0.8);
+ border: 1px solid rgba(186, 205, 176, 0.25);
+ border-radius: 5px;
+ font-size: 0.8rem;
+ cursor: pointer;
+ transition: background 0.2s;
+}
+
+.btn-admin-edit:hover {
+ background: rgba(186, 205, 176, 0.08);
+}
+
+.btn-admin-delete {
+ padding: 6px 14px;
+ background: transparent;
+ color: rgba(229, 115, 115, 0.8);
+ border: 1px solid rgba(229, 115, 115, 0.25);
+ border-radius: 5px;
+ font-size: 0.8rem;
+ cursor: pointer;
+ transition: background 0.2s;
+}
+
+.btn-admin-delete:hover {
+ background: rgba(229, 115, 115, 0.08);
+}
+
+/* Role badge */
+.admin-role-badge {
+ font-size: 0.7rem;
+ padding: 2px 8px;
+ border-radius: 4px;
+ font-weight: 600;
+}
+
+.admin-role-badge.admin {
+ background: rgba(186, 205, 176, 0.15);
+ color: #BACDB0;
+}
+
+.admin-role-badge.user {
+ background: rgba(255, 255, 255, 0.08);
+ color: rgba(255, 255, 255, 0.45);
+}
diff --git a/src/components/admin/AnnouncementAdmin.jsx b/src/components/admin/AnnouncementAdmin.jsx
new file mode 100644
index 0000000..e3c688c
--- /dev/null
+++ b/src/components/admin/AnnouncementAdmin.jsx
@@ -0,0 +1,101 @@
+import { useState, useEffect } from 'react';
+import { getAnnouncements } from '../../api/announcements';
+import { apiFetch } from '../../api/client';
+import './AdminCommon.css';
+
+export default function AnnouncementAdmin() {
+ const [list, setList] = useState([]);
+ const [form, setForm] = useState({ title: '', content: '' });
+ const [editingId, setEditingId] = useState(null);
+ const [loading, setLoading] = useState(false);
+
+ const load = () => getAnnouncements().then(setList);
+ useEffect(() => { load(); }, []);
+
+ const handleSubmit = async (e) => {
+ e.preventDefault();
+ if (!form.title || !form.content) return;
+ setLoading(true);
+ try {
+ if (editingId) {
+ await apiFetch(`/api/announcements/${editingId}`, {
+ method: 'PUT',
+ body: JSON.stringify(form),
+ });
+ } else {
+ await apiFetch('/api/announcements', {
+ method: 'POST',
+ body: JSON.stringify(form),
+ });
+ }
+ setForm({ title: '', content: '' });
+ setEditingId(null);
+ load();
+ } finally {
+ setLoading(false);
+ }
+ };
+
+ const handleEdit = (item) => {
+ setEditingId(item.id);
+ setForm({ title: item.title, content: item.content });
+ };
+
+ const handleDelete = async (id) => {
+ if (!confirm('삭제하시겠습니까?')) return;
+ await apiFetch(`/api/announcements/${id}`, { method: 'DELETE' });
+ load();
+ };
+
+ const handleCancel = () => {
+ setEditingId(null);
+ setForm({ title: '', content: '' });
+ };
+
+ return (
+
+
공지사항 관리
+
+
+
+
+ {list.map((item) => (
+ -
+
+ {item.title}
+ {item.createdAt?.slice(0, 10)}
+
+
+
+
+
+
+ ))}
+
+
+ );
+}
diff --git a/src/components/admin/DownloadAdmin.jsx b/src/components/admin/DownloadAdmin.jsx
new file mode 100644
index 0000000..721d724
--- /dev/null
+++ b/src/components/admin/DownloadAdmin.jsx
@@ -0,0 +1,65 @@
+import { useState, useEffect } from 'react';
+import { getDownloadInfo } from '../../api/download';
+import { apiFetch } from '../../api/client';
+import './AdminCommon.css';
+
+export default function DownloadAdmin() {
+ const [form, setForm] = useState({ url: '', version: '', fileName: '', fileSize: '' });
+ const [loading, setLoading] = useState(false);
+ const [saved, setSaved] = useState(false);
+
+ useEffect(() => {
+ getDownloadInfo()
+ .then((data) => setForm({
+ url: data.url,
+ version: data.version,
+ fileName: data.fileName,
+ fileSize: data.fileSize,
+ }))
+ .catch(() => {});
+ }, []);
+
+ const handleSubmit = async (e) => {
+ e.preventDefault();
+ setLoading(true);
+ try {
+ await apiFetch('/api/download/info', {
+ method: 'PUT',
+ body: JSON.stringify(form),
+ });
+ setSaved(true);
+ setTimeout(() => setSaved(false), 2000);
+ } finally {
+ setLoading(false);
+ }
+ };
+
+ const field = (label, key, placeholder) => (
+
+
+ setForm({ ...form, [key]: e.target.value })}
+ />
+
+ );
+
+ return (
+
+
다운로드 정보 관리
+
+
+ );
+}
diff --git a/src/components/admin/UserAdmin.jsx b/src/components/admin/UserAdmin.jsx
new file mode 100644
index 0000000..09c517e
--- /dev/null
+++ b/src/components/admin/UserAdmin.jsx
@@ -0,0 +1,49 @@
+import { useState, useEffect } from 'react';
+import { getUsers, updateUserRole, deleteUser } from '../../api/users';
+import { useAuth } from '../../context/AuthContext';
+import './AdminCommon.css';
+
+export default function UserAdmin() {
+ const [users, setUsers] = useState([]);
+ const { user: me } = useAuth();
+
+ const load = () => getUsers().then(setUsers);
+ useEffect(() => { load(); }, []);
+
+ const handleRoleToggle = async (u) => {
+ const newRole = u.role === 'admin' ? 'user' : 'admin';
+ if (!confirm(`${u.username}의 권한을 ${newRole}로 변경하시겠습니까?`)) return;
+ await updateUserRole(u.id, newRole);
+ load();
+ };
+
+ const handleDelete = async (u) => {
+ if (!confirm(`${u.username} 계정을 삭제하시겠습니까?`)) return;
+ await deleteUser(u.id);
+ load();
+ };
+
+ return (
+
+ );
+}
diff --git a/src/context/AuthContext.jsx b/src/context/AuthContext.jsx
index 98076ad..8349209 100644
--- a/src/context/AuthContext.jsx
+++ b/src/context/AuthContext.jsx
@@ -7,19 +7,22 @@ export function AuthProvider({ children }) {
const [user, setUser] = useState(() => {
const token = localStorage.getItem('token');
const username = localStorage.getItem('username');
- return token ? { token, username } : null;
+ const role = localStorage.getItem('role');
+ return token ? { token, username, role } : null;
});
const login = useCallback(async (username, password) => {
const data = await apiLogin(username, password);
localStorage.setItem('token', data.token);
localStorage.setItem('username', data.username);
- setUser({ token: data.token, username: data.username });
+ localStorage.setItem('role', data.role);
+ setUser({ token: data.token, username: data.username, role: data.role });
}, []);
const logout = useCallback(() => {
localStorage.removeItem('token');
localStorage.removeItem('username');
+ localStorage.removeItem('role');
setUser(null);
}, []);
diff --git a/src/pages/AdminPage.css b/src/pages/AdminPage.css
new file mode 100644
index 0000000..59fb36e
--- /dev/null
+++ b/src/pages/AdminPage.css
@@ -0,0 +1,97 @@
+.admin-page {
+ min-height: 100vh;
+ background-color: #2E2C2F;
+}
+
+.admin-header {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ padding: 16px 32px;
+ border-bottom: 1px solid rgba(186, 205, 176, 0.1);
+}
+
+.admin-header-left {
+ display: flex;
+ align-items: center;
+ gap: 20px;
+}
+
+.admin-home-link {
+ font-size: 0.85rem;
+ color: rgba(186, 205, 176, 0.6);
+ text-decoration: none;
+ transition: color 0.2s;
+}
+
+.admin-home-link:hover {
+ color: #BACDB0;
+}
+
+.admin-title {
+ font-size: 1.2rem;
+ font-weight: 700;
+ color: #BACDB0;
+ margin: 0;
+}
+
+.admin-header-right {
+ display: flex;
+ align-items: center;
+ gap: 16px;
+}
+
+.admin-username {
+ font-size: 0.9rem;
+ color: rgba(255, 255, 255, 0.6);
+}
+
+.btn-admin-logout {
+ padding: 8px 16px;
+ background: transparent;
+ color: rgba(186, 205, 176, 0.7);
+ border: 1px solid rgba(186, 205, 176, 0.25);
+ border-radius: 6px;
+ font-size: 0.8rem;
+ cursor: pointer;
+ transition: background 0.2s;
+}
+
+.btn-admin-logout:hover {
+ background: rgba(186, 205, 176, 0.08);
+}
+
+.admin-tabs {
+ display: flex;
+ gap: 4px;
+ padding: 16px 32px 0;
+ border-bottom: 1px solid rgba(255, 255, 255, 0.06);
+}
+
+.admin-tab {
+ padding: 10px 24px;
+ background: transparent;
+ color: rgba(255, 255, 255, 0.45);
+ border: none;
+ border-bottom: 2px solid transparent;
+ font-size: 0.9rem;
+ font-weight: 500;
+ cursor: pointer;
+ transition: color 0.2s, border-color 0.2s;
+ margin-bottom: -1px;
+}
+
+.admin-tab:hover {
+ color: rgba(255, 255, 255, 0.75);
+}
+
+.admin-tab.active {
+ color: #BACDB0;
+ border-bottom-color: #BACDB0;
+}
+
+.admin-main {
+ max-width: 900px;
+ margin: 0 auto;
+ padding: 32px 24px 80px;
+}
diff --git a/src/pages/AdminPage.jsx b/src/pages/AdminPage.jsx
new file mode 100644
index 0000000..1a2de3b
--- /dev/null
+++ b/src/pages/AdminPage.jsx
@@ -0,0 +1,51 @@
+import { useState } from 'react';
+import { Link } from 'react-router-dom';
+import { useAuth } from '../context/AuthContext';
+import AnnouncementAdmin from '../components/admin/AnnouncementAdmin';
+import DownloadAdmin from '../components/admin/DownloadAdmin';
+import UserAdmin from '../components/admin/UserAdmin';
+import './AdminPage.css';
+
+const TABS = [
+ { key: 'announcement', label: '공지사항' },
+ { key: 'download', label: '다운로드 정보' },
+ { key: 'user', label: '유저 관리' },
+];
+
+export default function AdminPage() {
+ const { user, logout } = useAuth();
+ const [tab, setTab] = useState('announcement');
+
+ return (
+
+
+
+
+ {TABS.map((t) => (
+
+ ))}
+
+
+
+ {tab === 'announcement' && }
+ {tab === 'download' && }
+ {tab === 'user' && }
+
+
+ );
+}
diff --git a/src/pages/HomePage.css b/src/pages/HomePage.css
index 1daf66a..885fd4b 100644
--- a/src/pages/HomePage.css
+++ b/src/pages/HomePage.css
@@ -47,6 +47,21 @@
border-color: rgba(186, 205, 176, 0.45);
}
+.btn-admin-link {
+ padding: 8px 16px;
+ background: transparent;
+ color: rgba(186, 205, 176, 0.7);
+ border: 1px solid rgba(186, 205, 176, 0.25);
+ border-radius: 6px;
+ font-size: 0.8rem;
+ text-decoration: none;
+ transition: background 0.2s;
+}
+
+.btn-admin-link:hover {
+ background: rgba(186, 205, 176, 0.08);
+}
+
.btn-header-login {
padding: 8px 20px;
background: #BACDB0;
diff --git a/src/pages/HomePage.jsx b/src/pages/HomePage.jsx
index be73af8..7716f2b 100644
--- a/src/pages/HomePage.jsx
+++ b/src/pages/HomePage.jsx
@@ -15,6 +15,9 @@ export default function HomePage() {
{user ? (
<>
{user.username}
+ {user.role === 'admin' && (
+ 관리자
+ )}
>
) : (