diff --git a/backend/routes/admin.js b/backend/routes/admin.js index 3159013..bab0329 100644 --- a/backend/routes/admin.js +++ b/backend/routes/admin.js @@ -907,4 +907,99 @@ router.delete( } ); +// ==================== 멤버 관리 API ==================== + +// 멤버 상세 조회 (이름으로) +router.get("/members/:name", authenticateToken, async (req, res) => { + try { + const memberName = decodeURIComponent(req.params.name); + const [members] = await pool.query("SELECT * FROM members WHERE name = ?", [ + memberName, + ]); + + if (members.length === 0) { + return res.status(404).json({ error: "멤버를 찾을 수 없습니다." }); + } + + res.json(members[0]); + } catch (error) { + console.error("멤버 조회 오류:", error); + res.status(500).json({ error: "멤버 조회 중 오류가 발생했습니다." }); + } +}); + +// 멤버 수정 (이름으로) +router.put( + "/members/:name", + authenticateToken, + upload.single("image"), + async (req, res) => { + try { + const memberName = decodeURIComponent(req.params.name); + const { name, birth_date, position, instagram, is_former } = req.body; + + // 기존 멤버 확인 + const [existing] = await pool.query( + "SELECT * FROM members WHERE name = ?", + [memberName] + ); + if (existing.length === 0) { + return res.status(404).json({ error: "멤버를 찾을 수 없습니다." }); + } + + const memberId = existing[0].id; + + let imageUrl = existing[0].image_url; + + // 새 이미지 업로드 + if (req.file) { + const webpBuffer = await sharp(req.file.buffer) + .webp({ quality: 90 }) + .toBuffer(); + + const key = `member/${memberId}/profile.webp`; + + await s3Client.send( + new PutObjectCommand({ + Bucket: BUCKET, + Key: key, + Body: webpBuffer, + ContentType: "image/webp", + }) + ); + + const publicUrl = + process.env.RUSTFS_PUBLIC_URL || process.env.RUSTFS_ENDPOINT; + imageUrl = `${publicUrl}/${BUCKET}/${key}`; + } + + // 멤버 업데이트 + await pool.query( + `UPDATE members SET + name = ?, + birth_date = ?, + position = ?, + instagram = ?, + is_former = ?, + image_url = ? + WHERE id = ?`, + [ + name, + birth_date || null, + position || null, + instagram || null, + is_former === "true" || is_former === true ? 1 : 0, + imageUrl, + memberId, + ] + ); + + res.json({ message: "멤버 정보가 수정되었습니다." }); + } catch (error) { + console.error("멤버 수정 오류:", error); + res.status(500).json({ error: "멤버 수정 중 오류가 발생했습니다." }); + } + } +); + export default router; diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index eecf5ec..24c09c2 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -13,6 +13,7 @@ import PCSchedule from './pages/pc/Schedule'; import AdminLogin from './pages/pc/admin/AdminLogin'; import AdminDashboard from './pages/pc/admin/AdminDashboard'; import AdminMembers from './pages/pc/admin/AdminMembers'; +import AdminMemberEdit from './pages/pc/admin/AdminMemberEdit'; import AdminAlbums from './pages/pc/admin/AdminAlbums'; import AdminAlbumForm from './pages/pc/admin/AdminAlbumForm'; import AdminAlbumPhotos from './pages/pc/admin/AdminAlbumPhotos'; @@ -29,6 +30,7 @@ function App() { } /> } /> } /> + } /> } /> } /> } /> diff --git a/frontend/src/pages/pc/Members.jsx b/frontend/src/pages/pc/Members.jsx index b09117d..35d2ef7 100644 --- a/frontend/src/pages/pc/Members.jsx +++ b/frontend/src/pages/pc/Members.jsx @@ -127,7 +127,7 @@ function Members() { initial={{ opacity: 0, x: -10 }} animate={{ opacity: 1, x: 0 }} transition={{ delay: 0.6 + index * 0.05 }} - className="group flex items-center gap-3 bg-gray-50 rounded-full pr-4 hover:bg-gray-100 transition-colors" + className="group flex items-center gap-3 bg-gray-100 rounded-full pr-4 hover:bg-gray-200 transition-colors" > {/* 작은 원형 이미지 */}
@@ -137,11 +137,8 @@ function Members() { className="w-full h-full object-cover grayscale group-hover:grayscale-0 transition-all duration-300" />
- {/* 이름과 포지션 */} -
-

{member.name}

-

{member.position || ''}

-
+ {/* 이름 */} +

{member.name}

))} diff --git a/frontend/src/pages/pc/admin/AdminMemberEdit.jsx b/frontend/src/pages/pc/admin/AdminMemberEdit.jsx new file mode 100644 index 0000000..a3bf200 --- /dev/null +++ b/frontend/src/pages/pc/admin/AdminMemberEdit.jsx @@ -0,0 +1,595 @@ +import { useState, useEffect, useRef } from 'react'; +import { useNavigate, useParams, Link } from 'react-router-dom'; +import { motion, AnimatePresence } from 'framer-motion'; +import { + Save, Upload, LogOut, + Home, ChevronRight, ChevronLeft, ChevronDown, User, Instagram, Calendar, Briefcase +} from 'lucide-react'; +import Toast from '../../../components/Toast'; + +// 커스텀 데이트픽커 컴포넌트 +function CustomDatePicker({ value, onChange }) { + const [isOpen, setIsOpen] = useState(false); + const [viewMode, setViewMode] = useState('days'); + const [viewDate, setViewDate] = useState(() => { + if (value) return new Date(value); + return new Date(); + }); + const ref = useRef(null); + + useEffect(() => { + const handleClickOutside = (e) => { + if (ref.current && !ref.current.contains(e.target)) { + setIsOpen(false); + setViewMode('days'); + } + }; + document.addEventListener('mousedown', handleClickOutside); + return () => document.removeEventListener('mousedown', handleClickOutside); + }, []); + + const year = viewDate.getFullYear(); + const month = viewDate.getMonth(); + + const firstDay = new Date(year, month, 1).getDay(); + const daysInMonth = new Date(year, month + 1, 0).getDate(); + + const days = []; + for (let i = 0; i < firstDay; i++) { + days.push(null); + } + for (let i = 1; i <= daysInMonth; i++) { + days.push(i); + } + + const startYear = Math.floor(year / 10) * 10 - 1; + const years = Array.from({ length: 12 }, (_, i) => startYear + i); + + const prevMonth = () => setViewDate(new Date(year, month - 1, 1)); + const nextMonth = () => setViewDate(new Date(year, month + 1, 1)); + const prevYearRange = () => setViewDate(new Date(year - 10, month, 1)); + const nextYearRange = () => setViewDate(new Date(year + 10, month, 1)); + + const selectDate = (day) => { + const dateStr = `${year}-${String(month + 1).padStart(2, '0')}-${String(day).padStart(2, '0')}`; + onChange(dateStr); + setIsOpen(false); + setViewMode('days'); + }; + + const selectYear = (y) => { + setViewDate(new Date(y, month, 1)); + setViewMode('months'); + }; + + const selectMonth = (m) => { + setViewDate(new Date(year, m, 1)); + setViewMode('days'); + }; + + const formatDisplayDate = (dateStr) => { + if (!dateStr) return ''; + const [y, m, d] = dateStr.split('-'); + return `${y}년 ${parseInt(m)}월 ${parseInt(d)}일`; + }; + + const isSelected = (day) => { + if (!value || !day) return false; + const [y, m, d] = value.split('-'); + return parseInt(y) === year && parseInt(m) === month + 1 && parseInt(d) === day; + }; + + const isToday = (day) => { + if (!day) return false; + const today = new Date(); + return today.getFullYear() === year && today.getMonth() === month && today.getDate() === day; + }; + + const isCurrentYear = (y) => new Date().getFullYear() === y; + const isCurrentMonth = (m) => { + const today = new Date(); + return today.getFullYear() === year && today.getMonth() === m; + }; + + const months = ['1월', '2월', '3월', '4월', '5월', '6월', '7월', '8월', '9월', '10월', '11월', '12월']; + + return ( +
+ + + + {isOpen && ( + +
+ + + +
+ + + {viewMode === 'years' && ( + +
년도
+
+ {years.map((y) => ( + + ))} +
+
+
+ {months.map((m, i) => ( + + ))} +
+
+ )} + + {viewMode === 'months' && ( + +
월 선택
+
+ {months.map((m, i) => ( + + ))} +
+
+ )} + + {viewMode === 'days' && ( + +
+ {['일', '월', '화', '수', '목', '금', '토'].map((d, i) => ( +
+ {d} +
+ ))} +
+
+ {days.map((day, i) => ( + + ))} +
+
+ )} +
+
+ )} +
+
+ ); +} + + +function AdminMemberEdit() { + const navigate = useNavigate(); + const { name } = useParams(); + const [user, setUser] = useState(null); + const [loading, setLoading] = useState(true); + const [saving, setSaving] = useState(false); + const [toast, setToast] = useState(null); + const [imagePreview, setImagePreview] = useState(null); + const [imageFile, setImageFile] = useState(null); + + const [formData, setFormData] = useState({ + name: '', + birth_date: '', + position: '', + instagram: '', + is_former: false + }); + + // Toast 자동 숨김 + useEffect(() => { + if (toast) { + const timer = setTimeout(() => setToast(null), 3000); + return () => clearTimeout(timer); + } + }, [toast]); + + useEffect(() => { + // 로그인 확인 + const token = localStorage.getItem('adminToken'); + const userData = localStorage.getItem('adminUser'); + + if (!token || !userData) { + navigate('/admin'); + return; + } + + setUser(JSON.parse(userData)); + fetchMember(); + }, [navigate, name]); + + const fetchMember = async () => { + try { + const token = localStorage.getItem('adminToken'); + const res = await fetch(`/api/admin/members/${encodeURIComponent(name)}`, { + headers: { Authorization: `Bearer ${token}` } + }); + + if (!res.ok) throw new Error('멤버 조회 실패'); + + const data = await res.json(); + setFormData({ + name: data.name || '', + birth_date: data.birth_date ? data.birth_date.split('T')[0] : '', + position: data.position || '', + instagram: data.instagram || '', + is_former: !!data.is_former + }); + setImagePreview(data.image_url); + setLoading(false); + } catch (error) { + console.error('멤버 로드 오류:', error); + setToast({ message: '멤버 정보를 불러올 수 없습니다.', type: 'error' }); + setLoading(false); + } + }; + + const handleLogout = () => { + localStorage.removeItem('adminToken'); + localStorage.removeItem('adminUser'); + navigate('/admin'); + }; + + 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 handleSubmit = async (e) => { + e.preventDefault(); + setSaving(true); + + try { + const token = localStorage.getItem('adminToken'); + const formDataToSend = new FormData(); + + formDataToSend.append('name', formData.name); + formDataToSend.append('birth_date', formData.birth_date); + formDataToSend.append('position', formData.position); + formDataToSend.append('instagram', formData.instagram); + formDataToSend.append('is_former', formData.is_former); + + if (imageFile) { + formDataToSend.append('image', imageFile); + } + + const res = await fetch(`/api/admin/members/${encodeURIComponent(name)}`, { + method: 'PUT', + headers: { Authorization: `Bearer ${token}` }, + body: formDataToSend + }); + + if (!res.ok) throw new Error('수정 실패'); + + setToast({ message: '멤버 정보가 수정되었습니다.', type: 'success' }); + setTimeout(() => navigate('/admin/members'), 1000); + } catch (error) { + console.error('수정 오류:', error); + setToast({ message: '수정 중 오류가 발생했습니다.', type: 'error' }); + } finally { + setSaving(false); + } + }; + + return ( +
+ {/* Toast */} + setToast(null)} /> + + {/* 헤더 */} +
+
+
+ + fromis_9 + + + Admin + +
+
+ + 안녕하세요, {user?.username}님 + + +
+
+
+ + {/* 메인 콘텐츠 */} +
+ {/* 브레드크럼 */} +
+ + + + + + 멤버 관리 + + + 멤버 수정 +
+ + {/* 타이틀 */} +
+

멤버 수정

+

멤버 정보를 수정합니다

+
+ + {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, birth_date: date })} + /> +
+ + {/* 포지션 */} +
+ + setFormData({ ...formData, position: 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="메인보컬, 리드댄서 등" + /> +
+ + {/* 인스타그램 */} +
+ + 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="@username" + /> +
+ + {/* 활동 상태 */} +
+ +
+ + +
+
+
+
+ + {/* 버튼 영역 */} +
+ + +
+
+ )} +
+
+ ); +} + +export default AdminMemberEdit; diff --git a/frontend/src/pages/pc/admin/AdminMembers.jsx b/frontend/src/pages/pc/admin/AdminMembers.jsx index 3ef7a40..2cf089f 100644 --- a/frontend/src/pages/pc/admin/AdminMembers.jsx +++ b/frontend/src/pages/pc/admin/AdminMembers.jsx @@ -71,7 +71,7 @@ function AdminMembers() { delay: index * 0.06 }} className={`relative rounded-2xl overflow-hidden shadow-sm hover:shadow-lg transition-all group cursor-pointer ${isFormer ? 'opacity-60' : ''}`} - onClick={() => setToast({ message: '수정 기능은 준비 중입니다.', type: 'info' })} + onClick={() => navigate(`/admin/members/${encodeURIComponent(member.name)}/edit`)} > {/* 프로필 이미지 */}