From 1359c38222097c7e1dbdff44c62d12702debd376 Mon Sep 17 00:00:00 2001 From: tolelom <98kimsungmin@naver.com> Date: Tue, 24 Feb 2026 14:52:28 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20=EA=B4=80=EB=A6=AC=EC=9E=90=20=ED=8E=98?= =?UTF-8?q?=EC=9D=B4=EC=A7=80=20=EC=B6=94=EA=B0=80=20(=EA=B3=B5=EC=A7=80?= =?UTF-8?q?=EC=82=AC=ED=95=AD,=20=EB=8B=A4=EC=9A=B4=EB=A1=9C=EB=93=9C,=20?= =?UTF-8?q?=EC=9C=A0=EC=A0=80=20=EA=B4=80=EB=A6=AC)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - /admin 라우트 추가 (admin 권한 전용) - 공지사항 CRUD, 다운로드 정보 수정, 유저 권한/삭제 관리 - AuthContext에 role 추가 및 localStorage 저장 - 홈 헤더에 admin 링크 표시 (admin만 노출) Co-Authored-By: Claude Sonnet 4.6 --- src/App.jsx | 21 ++- src/api/users.js | 16 ++ src/components/admin/AdminCommon.css | 200 +++++++++++++++++++++ src/components/admin/AnnouncementAdmin.jsx | 101 +++++++++++ src/components/admin/DownloadAdmin.jsx | 65 +++++++ src/components/admin/UserAdmin.jsx | 49 +++++ src/context/AuthContext.jsx | 7 +- src/pages/AdminPage.css | 97 ++++++++++ src/pages/AdminPage.jsx | 51 ++++++ src/pages/HomePage.css | 15 ++ src/pages/HomePage.jsx | 3 + 11 files changed, 619 insertions(+), 6 deletions(-) create mode 100644 src/api/users.js create mode 100644 src/components/admin/AdminCommon.css create mode 100644 src/components/admin/AnnouncementAdmin.jsx create mode 100644 src/components/admin/DownloadAdmin.jsx create mode 100644 src/components/admin/UserAdmin.jsx create mode 100644 src/pages/AdminPage.css create mode 100644 src/pages/AdminPage.jsx 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 ( +
+

공지사항 관리

+ +
+ setForm({ ...form, title: e.target.value })} + /> +