From f64f6cee00818ea6362bed39917505f3e1028553 Mon Sep 17 00:00:00 2001 From: caadiq Date: Thu, 22 Jan 2026 19:03:21 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20=EA=B4=80=EB=A6=AC=EC=9E=90=20=EA=B0=84?= =?UTF-8?q?=EB=8B=A8=ED=95=9C=20=ED=8E=98=EC=9D=B4=EC=A7=80=20=EB=A7=88?= =?UTF-8?q?=EC=9D=B4=EA=B7=B8=EB=A0=88=EC=9D=B4=EC=85=98=20(Phase=203)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - AdminDashboard 페이지 추가 - AdminMembers 페이지 추가 - AdminMemberEdit 페이지 추가 - useToast 훅 추가 - App.jsx에 관리자 라우트 추가 Co-Authored-By: Claude Opus 4.5 --- docs/admin-migration.md | 11 +- frontend-temp/src/App.jsx | 8 +- frontend-temp/src/hooks/common/index.js | 3 + frontend-temp/src/hooks/common/useToast.js | 44 ++ .../src/pages/pc/admin/Dashboard.jsx | 169 ++++++++ .../src/pages/pc/admin/MemberEdit.jsx | 377 ++++++++++++++++++ frontend-temp/src/pages/pc/admin/Members.jsx | 180 +++++++++ 7 files changed, 786 insertions(+), 6 deletions(-) create mode 100644 frontend-temp/src/hooks/common/useToast.js create mode 100644 frontend-temp/src/pages/pc/admin/Dashboard.jsx create mode 100644 frontend-temp/src/pages/pc/admin/MemberEdit.jsx create mode 100644 frontend-temp/src/pages/pc/admin/Members.jsx diff --git a/docs/admin-migration.md b/docs/admin-migration.md index 62d5126..23cc9b2 100644 --- a/docs/admin-migration.md +++ b/docs/admin-migration.md @@ -485,11 +485,12 @@ frontend-temp/src/ - [x] 관리자 라우트 설정 (App.jsx) - [x] AdminLogin 페이지 마이그레이션 -### 3단계: 간단한 페이지 -- [ ] AdminLogin 마이그레이션 -- [ ] AdminDashboard 마이그레이션 -- [ ] AdminMembers 마이그레이션 -- [ ] AdminMemberEdit 마이그레이션 +### 3단계: 간단한 페이지 ✅ +- [x] AdminLogin 마이그레이션 (2단계에서 완료) +- [x] AdminDashboard 마이그레이션 +- [x] AdminMembers 마이그레이션 +- [x] AdminMemberEdit 마이그레이션 +- [x] useToast 훅 추가 ### 4단계: 앨범 관리 - [ ] AdminAlbums 마이그레이션 diff --git a/frontend-temp/src/App.jsx b/frontend-temp/src/App.jsx index f88a681..4902e9e 100644 --- a/frontend-temp/src/App.jsx +++ b/frontend-temp/src/App.jsx @@ -25,6 +25,9 @@ import PCNotFound from '@/pages/pc/public/common/NotFound'; // PC 관리자 페이지 import AdminLogin from '@/pages/pc/admin/Login'; +import AdminDashboard from '@/pages/pc/admin/Dashboard'; +import AdminMembers from '@/pages/pc/admin/Members'; +import AdminMemberEdit from '@/pages/pc/admin/MemberEdit'; // Mobile 페이지 import MobileHome from '@/pages/mobile/home/Home'; @@ -62,8 +65,11 @@ function App() { - {/* 관리자 페이지 (레이아웃 없음) */} + {/* 관리자 페이지 (자체 레이아웃 사용) */} } /> + } /> + } /> + } /> {/* 일반 페이지 (레이아웃 포함) */} { + if (toast) { + const timer = setTimeout(() => setToast(null), duration); + return () => clearTimeout(timer); + } + }, [toast, duration]); + + // Toast 표시 함수 + const showToast = useCallback((message, type = 'info') => { + setToast({ message, type }); + }, []); + + // 편의 메서드 + const showSuccess = useCallback((message) => showToast(message, 'success'), [showToast]); + const showError = useCallback((message) => showToast(message, 'error'), [showToast]); + const showWarning = useCallback((message) => showToast(message, 'warning'), [showToast]); + const showInfo = useCallback((message) => showToast(message, 'info'), [showToast]); + + // Toast 숨김 함수 + const hideToast = useCallback(() => setToast(null), []); + + return { + toast, + setToast, + showToast, + showSuccess, + showError, + showWarning, + showInfo, + hideToast, + }; +} + +export default useToast; diff --git a/frontend-temp/src/pages/pc/admin/Dashboard.jsx b/frontend-temp/src/pages/pc/admin/Dashboard.jsx new file mode 100644 index 0000000..40031ae --- /dev/null +++ b/frontend-temp/src/pages/pc/admin/Dashboard.jsx @@ -0,0 +1,169 @@ +/** + * 관리자 대시보드 페이지 + */ +import { Link } from 'react-router-dom'; +import { useQuery } from '@tanstack/react-query'; +import { motion } from 'framer-motion'; +import { Disc3, Calendar, Users, Home, ChevronRight } from 'lucide-react'; +import { AdminLayout } from '@/components/pc/admin'; +import { useAdminAuth } from '@/hooks/pc/admin'; +import { adminStatsApi } from '@/api/pc/admin'; + +/** + * 슬롯머신 스타일 롤링 숫자 컴포넌트 + */ +function AnimatedNumber({ value }) { + const formatted = value.toLocaleString(); + const chars = formatted.split(''); + + return ( + + {chars.map((char, i) => { + if (char === ',') { + return ( + + , + + ); + } + + return ( + + + {[0, 1, 2, 3, 4, 5, 6, 7, 8, 9].map((n) => ( + + {n} + + ))} + + + ); + })} + + ); +} + +function AdminDashboard() { + const { user, isAuthenticated } = useAdminAuth(); + + // 통계 조회 + const { data: stats = { members: 0, albums: 0, photos: 0, schedules: 0 } } = useQuery({ + queryKey: ['admin', 'stats'], + queryFn: adminStatsApi.getStats, + enabled: isAuthenticated, + staleTime: 30 * 1000, + }); + + // 메뉴 아이템 + const menuItems = [ + { + icon: Users, + label: '멤버 관리', + description: '멤버 정보 및 프로필 관리', + path: '/admin/members', + color: 'bg-primary', + }, + { + icon: Disc3, + label: '앨범 관리', + description: '앨범, 트랙, 사진 업로드 및 관리', + path: '/admin/albums', + color: 'bg-purple-500', + }, + { + icon: Calendar, + label: '일정 관리', + description: '일정 추가 및 관리', + path: '/admin/schedules', + color: 'bg-blue-500', + }, + ]; + + return ( + +
+ {/* 브레드크럼 */} +
+ + + 관리자 대시보드 +
+ + {/* 타이틀 */} +
+

관리자 대시보드

+

fromis_9 팬사이트를 관리하세요

+
+ + {/* 메뉴 그리드 */} +
+ {menuItems.map((item, index) => ( + + +
+ +
+

{item.label}

+

{item.description}

+ +
+ ))} +
+ + {/* 빠른 통계 */} +
+

빠른 통계

+
+
+

+ +

+

멤버

+
+
+

+ +

+

총 앨범

+
+
+

+ +

+

총 사진

+
+
+

+ +

+

총 일정

+
+
+
+
+
+ ); +} + +export default AdminDashboard; diff --git a/frontend-temp/src/pages/pc/admin/MemberEdit.jsx b/frontend-temp/src/pages/pc/admin/MemberEdit.jsx new file mode 100644 index 0000000..f352701 --- /dev/null +++ b/frontend-temp/src/pages/pc/admin/MemberEdit.jsx @@ -0,0 +1,377 @@ +/** + * 관리자 멤버 수정 페이지 + */ +import { useState, useEffect } from 'react'; +import { useNavigate, useParams, Link } from 'react-router-dom'; +import { useQuery, useQueryClient } from '@tanstack/react-query'; +import { motion } from 'framer-motion'; +import { Save, Upload, X, Home, ChevronRight, User, Instagram, Calendar, Tag } from 'lucide-react'; +import { Toast } from '@/components/common'; +import { AdminLayout, DatePicker } from '@/components/pc/admin'; +import { useAdminAuth } from '@/hooks/pc/admin'; +import { useToast } from '@/hooks/common'; +import { adminMemberApi } from '@/api/pc/admin'; +import { fetchFormData } from '@/api/common/client'; + +function AdminMemberEdit() { + const navigate = useNavigate(); + const queryClient = useQueryClient(); + const { name } = useParams(); + const { user, isAuthenticated } = useAdminAuth(); + const { toast, setToast } = useToast(); + + const [saving, setSaving] = useState(false); + const [imagePreview, setImagePreview] = useState(null); + const [imageFile, setImageFile] = useState(null); + const [nicknameInput, setNicknameInput] = useState(''); + const [formData, setFormData] = useState({ + name: '', + name_en: '', + birth_date: '', + instagram: '', + is_former: false, + nicknames: [], + }); + + // 멤버 상세 조회 + const { + data: memberData, + isLoading: loading, + isError, + } = useQuery({ + queryKey: ['admin', 'member', name], + queryFn: () => adminMemberApi.getMember(encodeURIComponent(name)), + enabled: isAuthenticated, + }); + + // 데이터 로드 시 폼에 반영 + useEffect(() => { + if (memberData) { + const birthDate = memberData.birth_date ? memberData.birth_date.split('T')[0] : ''; + setFormData({ + name: memberData.name || '', + name_en: memberData.name_en || '', + birth_date: birthDate, + instagram: memberData.instagram || '', + is_former: !!memberData.is_former, + nicknames: memberData.nicknames || [], + }); + setImagePreview(memberData.image_url); + } + }, [memberData]); + + // 에러 처리 + useEffect(() => { + if (isError) { + setToast({ message: '멤버 정보를 불러오는데 실패했습니다.', type: 'error' }); + } + }, [isError, setToast]); + + const handleImageChange = (e) => { + const file = e.target.files[0]; + if (file) { + setImageFile(file); + const reader = new FileReader(); + reader.onloadend = () => setImagePreview(reader.result); + reader.readAsDataURL(file); + } + }; + + // 별명 추가 + const handleAddNickname = () => { + const trimmed = nicknameInput.trim(); + if (trimmed && !formData.nicknames.includes(trimmed)) { + setFormData({ + ...formData, + nicknames: [...formData.nicknames, trimmed], + }); + setNicknameInput(''); + } + }; + + // 별명 삭제 + const handleRemoveNickname = (nickname) => { + setFormData({ + ...formData, + nicknames: formData.nicknames.filter((n) => n !== nickname), + }); + }; + + // Enter 키로 별명 추가 + const handleNicknameKeyDown = (e) => { + if (e.key === 'Enter') { + e.preventDefault(); + handleAddNickname(); + } + }; + + const handleSubmit = async (e) => { + e.preventDefault(); + setSaving(true); + + try { + const form = new FormData(); + form.append('name', formData.name); + form.append('name_en', formData.name_en); + form.append('birth_date', formData.birth_date); + form.append('instagram', formData.instagram); + form.append('is_former', formData.is_former ? '1' : '0'); + form.append('nicknames', JSON.stringify(formData.nicknames)); + + if (imageFile) { + form.append('image', imageFile); + } + + await fetchFormData(`/members/${encodeURIComponent(name)}`, form, 'PUT'); + + // 목록 캐시 무효화 + queryClient.invalidateQueries({ queryKey: ['admin', 'members'] }); + + // 목록 페이지로 이동하면서 토스트 메시지 전달 + navigate('/admin/members', { + state: { toast: { message: '멤버 정보가 수정되었습니다.', type: 'success' } }, + }); + } catch (err) { + setToast({ message: err.message || '멤버 수정에 실패했습니다.', type: 'error' }); + } finally { + setSaving(false); + } + }; + + return ( + + setToast(null)} /> + +
+ {/* 브레드크럼 */} +
+ + + + + + 멤버 관리 + + + 멤버 수정 +
+ + {/* 타이틀 */} +
+

멤버 수정

+

멤버 정보를 수정합니다

+
+ + {loading ? ( +
+
+
+ ) : ( + +
+ {/* 이미지 업로드 영역 */} +
+ +
document.getElementById('imageInput').click()} + > + {imagePreview ? ( + <> + 프로필 미리보기 +
+
+ + 변경 +
+
+ + ) : ( +
+ + 클릭하여 업로드 +
+ )} +
+ +
+ + {/* 입력 폼 영역 */} +
+ {/* 이름 */} +
+
+ + setFormData({ ...formData, name: e.target.value })} + required + className="w-full px-4 py-3 border border-gray-200 rounded-xl focus:outline-none focus:ring-2 focus:ring-primary focus:border-transparent" + placeholder="멤버 이름" + /> +
+
+ + setFormData({ ...formData, name_en: e.target.value })} + className="w-full px-4 py-3 border border-gray-200 rounded-xl focus:outline-none focus:ring-2 focus:ring-primary focus:border-transparent" + placeholder="ENGLISH NAME" + /> +
+
+ + {/* 생년월일 */} +
+ + setFormData({ ...formData, birth_date: date })} + /> +
+ + {/* 인스타그램 */} +
+ + setFormData({ ...formData, instagram: e.target.value })} + className="w-full px-4 py-3 border border-gray-200 rounded-xl focus:outline-none focus:ring-2 focus:ring-primary focus:border-transparent" + placeholder="https://www.instagram.com/username" + /> +
+ + {/* 별명 */} +
+ +
+ setNicknameInput(e.target.value)} + onKeyDown={handleNicknameKeyDown} + className="w-full px-4 py-3 border border-gray-200 rounded-xl focus:outline-none focus:ring-2 focus:ring-primary focus:border-transparent" + placeholder="별명을 입력하고 Enter" + /> + + {formData.nicknames.length > 0 && ( +
+ {formData.nicknames.map((nickname, index) => ( + + {nickname} + + + ))} +
+ )} +
+

별명은 일정 검색 시 사용됩니다

+
+ + {/* 활동 상태 */} +
+ +
+ + +
+
+
+
+ + {/* 버튼 영역 */} +
+ + +
+
+ )} +
+ + ); +} + +export default AdminMemberEdit; diff --git a/frontend-temp/src/pages/pc/admin/Members.jsx b/frontend-temp/src/pages/pc/admin/Members.jsx new file mode 100644 index 0000000..fd10413 --- /dev/null +++ b/frontend-temp/src/pages/pc/admin/Members.jsx @@ -0,0 +1,180 @@ +/** + * 관리자 멤버 목록 페이지 + */ +import { useEffect } from 'react'; +import { useNavigate, useLocation, Link } from 'react-router-dom'; +import { useQuery } from '@tanstack/react-query'; +import { motion } from 'framer-motion'; +import { Edit2, Home, ChevronRight, Users, User } from 'lucide-react'; +import { Toast } from '@/components/common'; +import { AdminLayout } from '@/components/pc/admin'; +import { useAdminAuth } from '@/hooks/pc/admin'; +import { useToast } from '@/hooks/common'; +import { adminMemberApi } from '@/api/pc/admin'; + +/** + * 멤버 카드 컴포넌트 + */ +function MemberCard({ member, index, isFormer = false, onClick }) { + return ( + +
+ {member.image_url ? ( + {member.name} + ) : ( +
+ +
+ )} +
+
+

{member.name}

+
+
+
+ + 수정 +
+
+
+ + ); +} + +function AdminMembers() { + const navigate = useNavigate(); + const location = useLocation(); + const { user, isAuthenticated } = useAdminAuth(); + const { toast, setToast } = useToast(); + + // 다른 페이지에서 전달된 토스트 메시지 처리 + useEffect(() => { + if (location.state?.toast) { + setToast(location.state.toast); + window.history.replaceState({}, ''); + } + }, [location.state, setToast]); + + // 멤버 목록 조회 + const { + data: members = [], + isLoading: loading, + isError, + } = useQuery({ + queryKey: ['admin', 'members'], + queryFn: adminMemberApi.getMembers, + enabled: isAuthenticated, + }); + + // 에러 처리 + useEffect(() => { + if (isError) { + setToast({ message: '멤버 목록을 불러오는데 실패했습니다.', type: 'error' }); + } + }, [isError, setToast]); + + // 활동/탈퇴 멤버 분리 + const activeMembers = members.filter((m) => !m.is_former); + const formerMembers = members.filter((m) => m.is_former); + + const handleMemberClick = (memberName) => { + navigate(`/admin/members/${encodeURIComponent(memberName)}/edit`); + }; + + return ( + + setToast(null)} /> + +
+ {/* 브레드크럼 */} +
+ + + + + 멤버 관리 +
+ + {/* 타이틀 */} +
+

멤버 관리

+

멤버 정보 및 프로필을 관리합니다

+
+ + {/* 멤버 목록 */} + {loading ? ( +
+
+
+ ) : ( +
+ {/* 활동 멤버 */} +
+
+ +

현재 멤버

+ + {activeMembers.length} + +
+ +
+ {activeMembers.map((member, index) => ( + handleMemberClick(member.name)} + /> + ))} +
+
+ + {/* 탈퇴 멤버 */} + {formerMembers.length > 0 && ( +
+
+ +

이전 멤버

+ + {formerMembers.length} + +
+ +
+ {formerMembers.map((member, index) => ( + handleMemberClick(member.name)} + /> + ))} +
+
+ )} + + {members.length === 0 && ( +
등록된 멤버가 없습니다.
+ )} +
+ )} +
+ + ); +} + +export default AdminMembers;