feat: 관리자 페이지 개선
- 관리자 페이지 헤더 로고 클릭 시 어드민 대시보드로 이동 - 멤버 관리 페이지 UI 추가 (5열 그리드, 현재/이전 멤버 분리) - 대시보드 통계 실제 데이터 연결 및 슬롯머신 애니메이션 - 멤버 카드 페이드+스케일업 애니메이션
This commit is contained in:
parent
925cfe026a
commit
97e63580e5
6 changed files with 312 additions and 11 deletions
|
|
@ -12,6 +12,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 AdminAlbums from './pages/pc/admin/AdminAlbums';
|
||||
import AdminAlbumForm from './pages/pc/admin/AdminAlbumForm';
|
||||
import AdminAlbumPhotos from './pages/pc/admin/AdminAlbumPhotos';
|
||||
|
|
@ -27,6 +28,7 @@ function App() {
|
|||
{/* 관리자 페이지 (레이아웃 없음) */}
|
||||
<Route path="/admin" element={<AdminLogin />} />
|
||||
<Route path="/admin/dashboard" element={<AdminDashboard />} />
|
||||
<Route path="/admin/members" element={<AdminMembers />} />
|
||||
<Route path="/admin/albums" element={<AdminAlbums />} />
|
||||
<Route path="/admin/albums/new" element={<AdminAlbumForm />} />
|
||||
<Route path="/admin/albums/:id/edit" element={<AdminAlbumForm />} />
|
||||
|
|
|
|||
|
|
@ -566,7 +566,7 @@ function AdminAlbumForm() {
|
|||
<header className="bg-white shadow-sm border-b border-gray-100">
|
||||
<div className="max-w-7xl mx-auto px-6 py-4 flex items-center justify-between">
|
||||
<div className="flex items-center gap-4">
|
||||
<Link to="/" className="text-2xl font-bold text-primary hover:opacity-80 transition-opacity">
|
||||
<Link to="/admin/dashboard" className="text-2xl font-bold text-primary hover:opacity-80 transition-opacity">
|
||||
fromis_9
|
||||
</Link>
|
||||
<span className="px-3 py-1 bg-primary/10 text-primary text-sm font-medium rounded-full">
|
||||
|
|
|
|||
|
|
@ -671,7 +671,7 @@ function AdminAlbumPhotos() {
|
|||
<header className="bg-white shadow-sm border-b border-gray-100">
|
||||
<div className="max-w-7xl mx-auto px-6 py-4 flex items-center justify-between">
|
||||
<div className="flex items-center gap-4">
|
||||
<Link to="/" className="text-2xl font-bold text-primary hover:opacity-80 transition-opacity">
|
||||
<Link to="/admin/dashboard" className="text-2xl font-bold text-primary hover:opacity-80 transition-opacity">
|
||||
fromis_9
|
||||
</Link>
|
||||
<span className="px-3 py-1 bg-primary/10 text-primary text-sm font-medium rounded-full">
|
||||
|
|
|
|||
|
|
@ -169,7 +169,7 @@ function AdminAlbums() {
|
|||
<header className="bg-white shadow-sm border-b border-gray-100">
|
||||
<div className="max-w-7xl mx-auto px-6 py-4 flex items-center justify-between">
|
||||
<div className="flex items-center gap-4">
|
||||
<Link to="/" className="text-2xl font-bold text-primary hover:opacity-80 transition-opacity">
|
||||
<Link to="/admin/dashboard" className="text-2xl font-bold text-primary hover:opacity-80 transition-opacity">
|
||||
fromis_9
|
||||
</Link>
|
||||
<span className="px-3 py-1 bg-primary/10 text-primary text-sm font-medium rounded-full">
|
||||
|
|
|
|||
|
|
@ -6,9 +6,46 @@ import {
|
|||
Home, ChevronRight
|
||||
} from 'lucide-react';
|
||||
|
||||
// 슬롯머신 스타일 롤링 숫자 컴포넌트 (아래에서 위로)
|
||||
function AnimatedNumber({ value }) {
|
||||
const digits = String(value).split('');
|
||||
|
||||
return (
|
||||
<span className="inline-flex overflow-hidden">
|
||||
{digits.map((digit, i) => (
|
||||
<span key={i} className="relative h-[1.2em] overflow-hidden">
|
||||
<motion.span
|
||||
className="flex flex-col"
|
||||
initial={{ y: '100%' }}
|
||||
animate={{ y: `-${parseInt(digit) * 10}%` }}
|
||||
transition={{
|
||||
type: 'tween',
|
||||
ease: 'easeOut',
|
||||
duration: 0.8,
|
||||
delay: i * 0.2
|
||||
}}
|
||||
>
|
||||
{[0, 1, 2, 3, 4, 5, 6, 7, 8, 9].map(n => (
|
||||
<span key={n} className="h-[1.2em] flex items-center justify-center">
|
||||
{n}
|
||||
</span>
|
||||
))}
|
||||
</motion.span>
|
||||
</span>
|
||||
))}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function AdminDashboard() {
|
||||
const navigate = useNavigate();
|
||||
const [user, setUser] = useState(null);
|
||||
const [stats, setStats] = useState({
|
||||
albums: 0,
|
||||
photos: 0,
|
||||
schedules: 0,
|
||||
members: 0
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
// 로그인 상태 확인
|
||||
|
|
@ -35,8 +72,58 @@ function AdminDashboard() {
|
|||
localStorage.removeItem('adminUser');
|
||||
navigate('/admin');
|
||||
});
|
||||
|
||||
// 통계 데이터 가져오기
|
||||
fetchStats();
|
||||
}, [navigate]);
|
||||
|
||||
const fetchStats = async () => {
|
||||
// 각 통계를 개별적으로 가져와서 하나가 실패해도 다른 것은 표시
|
||||
try {
|
||||
const membersRes = await fetch('/api/members');
|
||||
if (membersRes.ok) {
|
||||
const members = await membersRes.json();
|
||||
setStats(prev => ({ ...prev, members: members.filter(m => !m.is_former).length }));
|
||||
}
|
||||
} catch (e) { console.error('멤버 통계 오류:', e); }
|
||||
|
||||
try {
|
||||
const albumsRes = await fetch('/api/albums');
|
||||
if (albumsRes.ok) {
|
||||
const albums = await albumsRes.json();
|
||||
setStats(prev => ({ ...prev, albums: albums.length }));
|
||||
|
||||
// 사진 수 계산
|
||||
let totalPhotos = 0;
|
||||
for (const album of albums) {
|
||||
try {
|
||||
const detailRes = await fetch(`/api/albums/${album.id}`);
|
||||
if (detailRes.ok) {
|
||||
const detail = await detailRes.json();
|
||||
if (detail.conceptPhotos) {
|
||||
Object.values(detail.conceptPhotos).forEach(photos => {
|
||||
totalPhotos += photos.length;
|
||||
});
|
||||
}
|
||||
if (detail.teasers) {
|
||||
totalPhotos += detail.teasers.length;
|
||||
}
|
||||
}
|
||||
} catch (e) { /* 개별 앨범 오류 무시 */ }
|
||||
}
|
||||
setStats(prev => ({ ...prev, photos: totalPhotos }));
|
||||
}
|
||||
} catch (e) { console.error('앨범 통계 오류:', e); }
|
||||
|
||||
try {
|
||||
const schedulesRes = await fetch('/api/schedules');
|
||||
if (schedulesRes.ok) {
|
||||
const schedules = await schedulesRes.json();
|
||||
setStats(prev => ({ ...prev, schedules: Array.isArray(schedules) ? schedules.length : 0 }));
|
||||
}
|
||||
} catch (e) { console.error('일정 통계 오류:', e); }
|
||||
};
|
||||
|
||||
const handleLogout = () => {
|
||||
localStorage.removeItem('adminToken');
|
||||
localStorage.removeItem('adminUser');
|
||||
|
|
@ -74,7 +161,7 @@ function AdminDashboard() {
|
|||
<header className="bg-white shadow-sm border-b border-gray-100">
|
||||
<div className="max-w-7xl mx-auto px-6 py-4 flex items-center justify-between">
|
||||
<div className="flex items-center gap-4">
|
||||
<Link to="/" className="text-2xl font-bold text-primary hover:opacity-80 transition-opacity">
|
||||
<Link to="/admin/dashboard" className="text-2xl font-bold text-primary hover:opacity-80 transition-opacity">
|
||||
fromis_9
|
||||
</Link>
|
||||
<span className="px-3 py-1 bg-primary/10 text-primary text-sm font-medium rounded-full">
|
||||
|
|
@ -139,21 +226,21 @@ function AdminDashboard() {
|
|||
<h2 className="text-xl font-bold text-gray-900 mb-6">빠른 통계</h2>
|
||||
<div className="grid grid-cols-4 gap-6">
|
||||
<div className="bg-white rounded-xl p-6 border border-gray-100 shadow-sm">
|
||||
<p className="text-3xl font-bold text-primary mb-1">-</p>
|
||||
<p className="text-3xl font-bold text-primary mb-1"><AnimatedNumber value={stats.members} /></p>
|
||||
<p className="text-gray-500 text-sm">멤버</p>
|
||||
</div>
|
||||
<div className="bg-white rounded-xl p-6 border border-gray-100 shadow-sm">
|
||||
<p className="text-3xl font-bold text-primary mb-1"><AnimatedNumber value={stats.albums} /></p>
|
||||
<p className="text-gray-500 text-sm">총 앨범</p>
|
||||
</div>
|
||||
<div className="bg-white rounded-xl p-6 border border-gray-100 shadow-sm">
|
||||
<p className="text-3xl font-bold text-primary mb-1">-</p>
|
||||
<p className="text-3xl font-bold text-primary mb-1"><AnimatedNumber value={stats.photos} /></p>
|
||||
<p className="text-gray-500 text-sm">총 사진</p>
|
||||
</div>
|
||||
<div className="bg-white rounded-xl p-6 border border-gray-100 shadow-sm">
|
||||
<p className="text-3xl font-bold text-primary mb-1">-</p>
|
||||
<p className="text-3xl font-bold text-primary mb-1"><AnimatedNumber value={stats.schedules} /></p>
|
||||
<p className="text-gray-500 text-sm">총 일정</p>
|
||||
</div>
|
||||
<div className="bg-white rounded-xl p-6 border border-gray-100 shadow-sm">
|
||||
<p className="text-3xl font-bold text-primary mb-1">5</p>
|
||||
<p className="text-gray-500 text-sm">멤버</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
|
|
|
|||
212
frontend/src/pages/pc/admin/AdminMembers.jsx
Normal file
212
frontend/src/pages/pc/admin/AdminMembers.jsx
Normal file
|
|
@ -0,0 +1,212 @@
|
|||
import { useState, useEffect } from 'react';
|
||||
import { useNavigate, Link } from 'react-router-dom';
|
||||
import { motion } from 'framer-motion';
|
||||
import {
|
||||
Edit2, LogOut,
|
||||
Home, ChevronRight, Users, User
|
||||
} from 'lucide-react';
|
||||
import Toast from '../../../components/Toast';
|
||||
|
||||
function AdminMembers() {
|
||||
const navigate = useNavigate();
|
||||
const [members, setMembers] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [user, setUser] = useState(null);
|
||||
const [toast, setToast] = useState(null);
|
||||
|
||||
// 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));
|
||||
fetchMembers();
|
||||
}, [navigate]);
|
||||
|
||||
const fetchMembers = () => {
|
||||
fetch('/api/members')
|
||||
.then(res => res.json())
|
||||
.then(data => {
|
||||
setMembers(data);
|
||||
setLoading(false);
|
||||
})
|
||||
.catch(error => {
|
||||
console.error('멤버 로드 오류:', error);
|
||||
setLoading(false);
|
||||
});
|
||||
};
|
||||
|
||||
const handleLogout = () => {
|
||||
localStorage.removeItem('adminToken');
|
||||
localStorage.removeItem('adminUser');
|
||||
navigate('/admin');
|
||||
};
|
||||
|
||||
// 활동/탈퇴 멤버 분리 (is_former: 0=활동, 1=탈퇴)
|
||||
const activeMembers = members.filter(m => !m.is_former);
|
||||
const formerMembers = members.filter(m => m.is_former);
|
||||
|
||||
// 멤버 카드 컴포넌트
|
||||
const MemberCard = ({ member, index, isFormer = false }) => (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, scale: 0.9 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
transition={{
|
||||
type: 'tween',
|
||||
ease: 'easeOut',
|
||||
duration: 0.35,
|
||||
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' })}
|
||||
>
|
||||
{/* 프로필 이미지 */}
|
||||
<div className={`aspect-[3/4] bg-gray-100 relative overflow-hidden ${isFormer ? 'grayscale' : ''}`}>
|
||||
{member.image_url ? (
|
||||
<img
|
||||
src={member.image_url}
|
||||
alt={member.name}
|
||||
className="w-full h-full object-cover group-hover:scale-105 transition-transform duration-300"
|
||||
/>
|
||||
) : (
|
||||
<div className="w-full h-full flex items-center justify-center bg-gradient-to-b from-gray-100 to-gray-200">
|
||||
<User size={48} className="text-gray-300" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 이름 오버레이 - 하단 그라데이션 */}
|
||||
<div className="absolute inset-x-0 bottom-0 h-24 bg-gradient-to-t from-black/70 via-black/30 to-transparent" />
|
||||
<div className="absolute bottom-0 left-0 right-0 p-4">
|
||||
<h3 className="text-lg font-bold text-white drop-shadow-lg">{member.name}</h3>
|
||||
</div>
|
||||
|
||||
{/* 수정 버튼 오버레이 */}
|
||||
<div className="absolute inset-0 bg-black/0 group-hover:bg-black/20 transition-colors flex items-center justify-center opacity-0 group-hover:opacity-100">
|
||||
<div className="px-4 py-2 bg-white/90 backdrop-blur-sm text-gray-900 rounded-lg font-medium flex items-center gap-2 shadow-lg">
|
||||
<Edit2 size={16} />
|
||||
수정
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50">
|
||||
{/* Toast */}
|
||||
<Toast toast={toast} onClose={() => setToast(null)} />
|
||||
|
||||
{/* 헤더 */}
|
||||
<header className="bg-white shadow-sm border-b border-gray-100">
|
||||
<div className="max-w-7xl mx-auto px-6 py-4 flex items-center justify-between">
|
||||
<div className="flex items-center gap-4">
|
||||
<Link to="/admin/dashboard" className="text-2xl font-bold text-primary hover:opacity-80 transition-opacity">
|
||||
fromis_9
|
||||
</Link>
|
||||
<span className="px-3 py-1 bg-primary/10 text-primary text-sm font-medium rounded-full">
|
||||
Admin
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-4">
|
||||
<span className="text-gray-500 text-sm">
|
||||
안녕하세요, <span className="text-gray-900 font-medium">{user?.username}</span>님
|
||||
</span>
|
||||
<button
|
||||
onClick={handleLogout}
|
||||
className="flex items-center gap-2 px-4 py-2 text-gray-500 hover:text-gray-900 hover:bg-gray-100 rounded-lg transition-colors"
|
||||
>
|
||||
<LogOut size={18} />
|
||||
<span>로그아웃</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* 메인 콘텐츠 */}
|
||||
<main className="max-w-7xl mx-auto px-6 py-8">
|
||||
{/* 브레드크럼 */}
|
||||
<div className="flex items-center gap-2 text-sm text-gray-400 mb-8">
|
||||
<Link to="/admin/dashboard" className="hover:text-primary transition-colors">
|
||||
<Home size={16} />
|
||||
</Link>
|
||||
<ChevronRight size={14} />
|
||||
<span className="text-gray-700">멤버 관리</span>
|
||||
</div>
|
||||
|
||||
{/* 타이틀 */}
|
||||
<div className="mb-8">
|
||||
<h1 className="text-3xl font-bold text-gray-900 mb-2">멤버 관리</h1>
|
||||
<p className="text-gray-500">멤버 정보 및 프로필을 관리합니다</p>
|
||||
</div>
|
||||
|
||||
{/* 멤버 목록 */}
|
||||
{loading ? (
|
||||
<div className="flex justify-center items-center py-20">
|
||||
<div className="animate-spin rounded-full h-12 w-12 border-4 border-primary border-t-transparent"></div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-12">
|
||||
{/* 활동 멤버 */}
|
||||
<div>
|
||||
<div className="flex items-center gap-2 mb-6">
|
||||
<Users size={20} className="text-primary" />
|
||||
<h2 className="text-xl font-bold text-gray-900">현재 멤버</h2>
|
||||
<span className="px-2 py-0.5 bg-primary/10 text-primary text-sm font-medium rounded-full">
|
||||
{activeMembers.length}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* 5열 그리드 */}
|
||||
<div className="grid grid-cols-5 gap-5">
|
||||
{activeMembers.map((member, index) => (
|
||||
<MemberCard key={member.id} member={member} index={index} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 탈퇴 멤버 */}
|
||||
{formerMembers.length > 0 && (
|
||||
<div>
|
||||
<div className="flex items-center gap-2 mb-6">
|
||||
<User size={20} className="text-gray-400" />
|
||||
<h2 className="text-xl font-bold text-gray-500">이전 멤버</h2>
|
||||
<span className="px-2 py-0.5 bg-gray-100 text-gray-500 text-sm font-medium rounded-full">
|
||||
{formerMembers.length}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* 5열 그리드 (탈퇴 멤버용 - 4명이면 4개만 표시) */}
|
||||
<div className="grid grid-cols-5 gap-5">
|
||||
{formerMembers.map((member, index) => (
|
||||
<MemberCard key={member.id} member={member} index={index} isFormer />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{members.length === 0 && (
|
||||
<div className="text-center py-12 text-gray-500">
|
||||
등록된 멤버가 없습니다.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default AdminMembers;
|
||||
Loading…
Add table
Reference in a new issue