fromis_9/frontend/src/pages/pc/admin/AdminDashboard.jsx
caadiq 97e63580e5 feat: 관리자 페이지 개선
- 관리자 페이지 헤더 로고 클릭 시 어드민 대시보드로 이동
- 멤버 관리 페이지 UI 추가 (5열 그리드, 현재/이전 멤버 분리)
- 대시보드 통계 실제 데이터 연결 및 슬롯머신 애니메이션
- 멤버 카드 페이드+스케일업 애니메이션
2026-01-04 13:10:34 +09:00

251 lines
10 KiB
JavaScript

import { useState, useEffect } from 'react';
import { useNavigate, Link } from 'react-router-dom';
import { motion } from 'framer-motion';
import {
Disc3, Calendar, Users, LogOut,
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(() => {
// 로그인 상태 확인
const token = localStorage.getItem('adminToken');
const userData = localStorage.getItem('adminUser');
if (!token || !userData) {
navigate('/admin');
return;
}
setUser(JSON.parse(userData));
// 토큰 유효성 검증
fetch('/api/admin/verify', {
headers: { Authorization: `Bearer ${token}` }
})
.then(res => {
if (!res.ok) throw new Error('Invalid token');
return res.json();
})
.catch(() => {
localStorage.removeItem('adminToken');
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');
navigate('/admin');
};
// 메뉴 아이템
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/schedule',
color: 'bg-blue-500'
},
];
return (
<div className="min-h-screen bg-gray-50">
{/* 헤더 */}
<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">
<Home size={16} />
<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">fromis_9 팬사이트를 관리하세요</p>
</div>
{/* 메뉴 그리드 */}
<div className="grid grid-cols-3 gap-6">
{menuItems.map((item, index) => (
<motion.div
key={item.label}
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: index * 0.1 }}
>
<Link
to={item.path}
className="block bg-white rounded-2xl p-6 border border-gray-100 shadow-sm hover:shadow-lg hover:border-gray-200 transition-all group"
>
<div className={`w-12 h-12 ${item.color} rounded-xl flex items-center justify-center mb-4 group-hover:scale-110 transition-transform`}>
<item.icon size={24} className="text-white" />
</div>
<h3 className="text-lg font-bold text-gray-900 mb-2">{item.label}</h3>
<p className="text-gray-500 text-sm">{item.description}</p>
</Link>
</motion.div>
))}
</div>
{/* 빠른 통계 */}
<div className="mt-12">
<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"><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"><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"><AnimatedNumber value={stats.schedules} /></p>
<p className="text-gray-500 text-sm"> 일정</p>
</div>
</div>
</div>
</main>
</div>
);
}
export default AdminDashboard;