feat(frontend): Phase 8 - 멤버 페이지 구현
- PC 멤버 페이지: 5열 그리드, 전 멤버 섹션 (grayscale) - Mobile 멤버 페이지: Swiper 카드 스타일, 썸네일 인디케이터 - App.jsx에 멤버 라우트 추가 Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
edde52b06e
commit
8ec7aa0e60
5 changed files with 407 additions and 4 deletions
|
|
@ -13,6 +13,7 @@ import { Layout as MobileLayout } from '@/components/mobile';
|
|||
|
||||
// 페이지
|
||||
import { PCHome, MobileHome } from '@/pages/home';
|
||||
import { PCMembers, MobileMembers } from '@/pages/members';
|
||||
|
||||
/**
|
||||
* PC 환경에서 body에 클래스 추가하는 래퍼
|
||||
|
|
@ -48,8 +49,8 @@ function App() {
|
|||
<PCLayout>
|
||||
<Routes>
|
||||
<Route path="/" element={<PCHome />} />
|
||||
{/* 추가 페이지는 Phase 8-11에서 구현 */}
|
||||
{/* <Route path="/members" element={<PCMembers />} /> */}
|
||||
<Route path="/members" element={<PCMembers />} />
|
||||
{/* 추가 페이지는 Phase 9-11에서 구현 */}
|
||||
{/* <Route path="/album" element={<PCAlbum />} /> */}
|
||||
{/* <Route path="/schedule" element={<PCSchedule />} /> */}
|
||||
{/* <Route path="*" element={<PCNotFound />} /> */}
|
||||
|
|
@ -72,8 +73,15 @@ function App() {
|
|||
</MobileLayout>
|
||||
}
|
||||
/>
|
||||
{/* 추가 페이지는 Phase 8-11에서 구현 */}
|
||||
{/* <Route path="/members" element={<MobileLayout pageTitle="멤버"><MobileMembers /></MobileLayout>} /> */}
|
||||
<Route
|
||||
path="/members"
|
||||
element={
|
||||
<MobileLayout pageTitle="멤버" noShadow>
|
||||
<MobileMembers />
|
||||
</MobileLayout>
|
||||
}
|
||||
/>
|
||||
{/* 추가 페이지는 Phase 9-11에서 구현 */}
|
||||
{/* <Route path="/album" element={<MobileLayout pageTitle="앨범"><MobileAlbum /></MobileLayout>} /> */}
|
||||
{/* <Route path="/schedule" element={<MobileLayout useCustomLayout><MobileSchedule /></MobileLayout>} /> */}
|
||||
</Routes>
|
||||
|
|
|
|||
|
|
@ -1,2 +1,5 @@
|
|||
// 홈 페이지
|
||||
export * from './home';
|
||||
|
||||
// 멤버 페이지
|
||||
export * from './members';
|
||||
|
|
|
|||
2
frontend-temp/src/pages/members/index.js
Normal file
2
frontend-temp/src/pages/members/index.js
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
export { default as PCMembers } from './pc/Members';
|
||||
export { default as MobileMembers } from './mobile/Members';
|
||||
238
frontend-temp/src/pages/members/mobile/Members.jsx
Normal file
238
frontend-temp/src/pages/members/mobile/Members.jsx
Normal file
|
|
@ -0,0 +1,238 @@
|
|||
import { motion } from 'framer-motion';
|
||||
import { useState, useMemo, useRef, useEffect } from 'react';
|
||||
import { Instagram, Calendar } from 'lucide-react';
|
||||
import { Swiper, SwiperSlide } from 'swiper/react';
|
||||
import 'swiper/css';
|
||||
import { useMembers } from '@/hooks';
|
||||
|
||||
/**
|
||||
* Mobile 멤버 페이지 - 카드 스와이프 스타일
|
||||
*/
|
||||
function MobileMembers() {
|
||||
const [currentIndex, setCurrentIndex] = useState(0);
|
||||
const swiperRef = useRef(null);
|
||||
const indicatorRef = useRef(null);
|
||||
|
||||
// 멤버 데이터 로드
|
||||
const { data: allMembers = [] } = useMembers();
|
||||
|
||||
// 현재/전 멤버 정렬 (현재 멤버 먼저, 전 멤버 나중)
|
||||
const members = useMemo(() => {
|
||||
return [...allMembers].sort((a, b) => {
|
||||
if (a.is_former !== b.is_former) {
|
||||
return a.is_former ? 1 : -1;
|
||||
}
|
||||
return 0;
|
||||
});
|
||||
}, [allMembers]);
|
||||
|
||||
// 나이 계산
|
||||
const calculateAge = (birthDate) => {
|
||||
if (!birthDate) return null;
|
||||
const birth = new Date(birthDate);
|
||||
const today = new Date();
|
||||
let age = today.getFullYear() - birth.getFullYear();
|
||||
const monthDiff = today.getMonth() - birth.getMonth();
|
||||
if (
|
||||
monthDiff < 0 ||
|
||||
(monthDiff === 0 && today.getDate() < birth.getDate())
|
||||
) {
|
||||
age--;
|
||||
}
|
||||
return age;
|
||||
};
|
||||
|
||||
// 인디케이터 자동 스크롤
|
||||
useEffect(() => {
|
||||
if (indicatorRef.current && members.length > 0) {
|
||||
const container = indicatorRef.current;
|
||||
const itemWidth = 64; // 52px 썸네일 + 12px 간격
|
||||
const containerWidth = container.offsetWidth;
|
||||
const paddingLeft = 16; // px-4
|
||||
const targetScroll =
|
||||
paddingLeft + currentIndex * itemWidth + 26 - containerWidth / 2;
|
||||
|
||||
container.scrollTo({
|
||||
left: Math.max(0, targetScroll),
|
||||
behavior: 'smooth',
|
||||
});
|
||||
}
|
||||
}, [currentIndex, members.length]);
|
||||
|
||||
// 인디케이터 클릭 핸들러
|
||||
const handleIndicatorClick = (index) => {
|
||||
if (swiperRef.current) {
|
||||
swiperRef.current.slideTo(index);
|
||||
}
|
||||
};
|
||||
|
||||
if (members.length === 0) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-[calc(100vh-200px)]">
|
||||
<p className="text-gray-400">멤버 정보가 없습니다</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-[calc(100dvh-120px)] overflow-hidden overscroll-none touch-none">
|
||||
{/* 상단 썸네일 인디케이터 */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: -20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.4, ease: 'easeOut' }}
|
||||
className="bg-white shadow-sm"
|
||||
>
|
||||
<div
|
||||
ref={indicatorRef}
|
||||
className="flex gap-3 px-4 py-4 overflow-x-auto scrollbar-hide"
|
||||
style={{ scrollbarWidth: 'none', msOverflowStyle: 'none' }}
|
||||
>
|
||||
{members.map((member, index) => {
|
||||
const isSelected = index === currentIndex;
|
||||
const isFormer = member.is_former;
|
||||
|
||||
return (
|
||||
<button
|
||||
key={member.id}
|
||||
onClick={() => handleIndicatorClick(index)}
|
||||
className={`flex-shrink-0 w-[52px] h-[52px] rounded-full p-[2px] transition-all duration-200
|
||||
${
|
||||
isSelected
|
||||
? 'ring-[2.5px] ring-primary shadow-[0_0_8px_rgba(var(--primary-rgb),0.35)]'
|
||||
: 'ring-[1.5px] ring-gray-300'
|
||||
}`}
|
||||
>
|
||||
<div
|
||||
className={`w-full h-full rounded-full overflow-hidden bg-gray-200
|
||||
${isFormer ? 'grayscale' : ''}`}
|
||||
>
|
||||
{member.image_url ? (
|
||||
<img
|
||||
src={member.image_url}
|
||||
alt={member.name}
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
) : (
|
||||
<div className="w-full h-full flex items-center justify-center bg-gray-300 text-white font-bold">
|
||||
{member.name[0]}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
{/* 메인 카드 영역 */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 40 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.5, delay: 0.2, ease: 'easeOut' }}
|
||||
className="flex-1 overflow-visible"
|
||||
>
|
||||
<Swiper
|
||||
onSwiper={(swiper) => {
|
||||
swiperRef.current = swiper;
|
||||
}}
|
||||
onSlideChange={(swiper) => setCurrentIndex(swiper.activeIndex)}
|
||||
slidesPerView={1.12}
|
||||
centeredSlides={true}
|
||||
spaceBetween={0}
|
||||
className="h-full !overflow-visible [&>.swiper-wrapper]:!overflow-visible"
|
||||
style={{ padding: '8px 0' }}
|
||||
>
|
||||
{members.map((member) => {
|
||||
const isFormer = member.is_former;
|
||||
const age = calculateAge(member.birth_date);
|
||||
|
||||
return (
|
||||
<SwiperSlide
|
||||
key={member.id}
|
||||
className="!flex items-center justify-center"
|
||||
>
|
||||
{({ isActive }) => (
|
||||
<div
|
||||
className={`relative w-full h-full max-h-[calc(100%-16px)] rounded-3xl overflow-hidden shadow-xl
|
||||
transition-transform duration-300
|
||||
${isActive ? 'scale-100' : 'scale-[0.92]'}`}
|
||||
>
|
||||
{/* 배경 이미지 */}
|
||||
{member.image_url ? (
|
||||
<img
|
||||
src={member.image_url}
|
||||
alt={member.name}
|
||||
className={`absolute inset-0 w-full h-full object-cover
|
||||
${isFormer ? 'grayscale' : ''}`}
|
||||
/>
|
||||
) : (
|
||||
<div className="absolute inset-0 bg-gradient-to-br from-gray-300 to-gray-400" />
|
||||
)}
|
||||
|
||||
{/* 하단 그라데이션 오버레이 */}
|
||||
<div className="absolute inset-x-0 bottom-0 h-[220px] bg-gradient-to-t from-black/80 via-black/30 to-transparent" />
|
||||
|
||||
{/* 전 멤버 라벨 */}
|
||||
{isFormer && (
|
||||
<div className="absolute top-4 right-4 px-3 py-1.5 bg-black/60 rounded-full">
|
||||
<span className="text-white/70 text-xs font-medium">
|
||||
전 멤버
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 멤버 정보 */}
|
||||
<div className="absolute inset-x-0 bottom-0 p-6">
|
||||
{/* 이름 */}
|
||||
<h2 className="text-[32px] font-bold text-white drop-shadow-lg">
|
||||
{member.name}
|
||||
</h2>
|
||||
|
||||
{/* 생일 정보 */}
|
||||
{member.birth_date && (
|
||||
<div className="flex items-center gap-1.5 mt-1.5 text-white/80">
|
||||
<Calendar size={16} className="text-white/70" />
|
||||
<span className="text-sm">
|
||||
{member.birth_date
|
||||
?.slice(0, 10)
|
||||
.replaceAll('-', '.')}
|
||||
</span>
|
||||
{age && (
|
||||
<span className="ml-2 px-2 py-0.5 bg-white/20 rounded-lg text-xs text-white font-medium">
|
||||
{age}세
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 인스타그램 버튼 */}
|
||||
{!isFormer && member.instagram && (
|
||||
<a
|
||||
href={member.instagram}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-flex items-center gap-2 mt-4 px-4 py-2.5
|
||||
bg-gradient-to-r from-[#833AB4] via-[#E1306C] to-[#F77737]
|
||||
rounded-full shadow-lg shadow-[#E1306C]/40
|
||||
active:scale-95 transition-transform"
|
||||
>
|
||||
<Instagram size={18} className="text-white" />
|
||||
<span className="text-white text-sm font-semibold">
|
||||
Instagram
|
||||
</span>
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</SwiperSlide>
|
||||
);
|
||||
})}
|
||||
</Swiper>
|
||||
</motion.div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default MobileMembers;
|
||||
152
frontend-temp/src/pages/members/pc/Members.jsx
Normal file
152
frontend-temp/src/pages/members/pc/Members.jsx
Normal file
|
|
@ -0,0 +1,152 @@
|
|||
import { motion } from 'framer-motion';
|
||||
import { Instagram, Calendar } from 'lucide-react';
|
||||
import { useMembers } from '@/hooks';
|
||||
import { Loading } from '@/components/common';
|
||||
import { formatDate } from '@/utils';
|
||||
|
||||
/**
|
||||
* PC 멤버 페이지
|
||||
*/
|
||||
function Members() {
|
||||
const { data: members = [], isLoading: loading } = useMembers();
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="py-16 flex justify-center items-center min-h-[60vh]">
|
||||
<Loading />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="py-16">
|
||||
<div className="max-w-7xl mx-auto px-6">
|
||||
{/* 헤더 */}
|
||||
<div className="text-center mb-12">
|
||||
<motion.h1
|
||||
initial={{ opacity: 0, y: -20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
className="text-4xl font-bold mb-4"
|
||||
>
|
||||
멤버
|
||||
</motion.h1>
|
||||
<motion.p
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
transition={{ delay: 0.2 }}
|
||||
className="text-gray-500"
|
||||
>
|
||||
프로미스나인의 멤버를 소개합니다
|
||||
</motion.p>
|
||||
</div>
|
||||
|
||||
{/* 현재 멤버 그리드 */}
|
||||
<div className="grid grid-cols-5 gap-8">
|
||||
{members
|
||||
.filter((m) => !m.is_former)
|
||||
.map((member, index) => (
|
||||
<motion.div
|
||||
key={member.id}
|
||||
initial={{ opacity: 0, y: 30 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: index * 0.1 }}
|
||||
className="group h-full"
|
||||
>
|
||||
<div className="relative bg-white rounded-3xl overflow-hidden shadow-lg hover:shadow-2xl transition-all duration-300 h-full flex flex-col">
|
||||
{/* 이미지 */}
|
||||
<div className="aspect-[3/4] bg-gray-100 overflow-hidden flex-shrink-0">
|
||||
<img
|
||||
src={member.image_url}
|
||||
alt={member.name}
|
||||
className="w-full h-full object-cover group-hover:scale-105 transition-transform duration-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 정보 */}
|
||||
<div className="p-6 flex-1 flex flex-col">
|
||||
<h3 className="text-xl font-bold mb-3">{member.name}</h3>
|
||||
|
||||
<div className="flex items-center gap-2 text-sm text-gray-500 mb-2">
|
||||
<Calendar size={14} />
|
||||
<span>{formatDate(member.birth_date, 'YYYY.MM.DD')}</span>
|
||||
</div>
|
||||
|
||||
{/* 인스타그램 링크 */}
|
||||
{member.instagram && (
|
||||
<a
|
||||
href={member.instagram}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-flex items-center gap-2 text-sm text-gray-500 hover:text-pink-500 transition-colors mt-auto"
|
||||
>
|
||||
<Instagram size={16} />
|
||||
<span>Instagram</span>
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 호버 효과 - 컬러 바 */}
|
||||
<div className="absolute bottom-0 left-0 right-0 h-1 bg-primary transform scale-x-0 group-hover:scale-x-100 transition-transform duration-300" />
|
||||
</div>
|
||||
</motion.div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* 전 멤버 섹션 */}
|
||||
{members.filter((m) => m.is_former).length > 0 && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 30 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: 0.5 }}
|
||||
className="mt-16"
|
||||
>
|
||||
<h2 className="text-2xl font-bold mb-8 text-gray-400">전 멤버</h2>
|
||||
<div className="grid grid-cols-5 gap-8">
|
||||
{members
|
||||
.filter((m) => m.is_former)
|
||||
.map((member, index) => (
|
||||
<motion.div
|
||||
key={member.id}
|
||||
initial={{ opacity: 0, y: 30 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: 0.6 + index * 0.1 }}
|
||||
className="group h-full"
|
||||
>
|
||||
<div className="relative bg-white rounded-3xl overflow-hidden shadow-lg hover:shadow-2xl transition-all duration-300 h-full flex flex-col">
|
||||
{/* 이미지 - grayscale */}
|
||||
<div className="aspect-[3/4] bg-gray-100 overflow-hidden flex-shrink-0">
|
||||
<img
|
||||
src={member.image_url}
|
||||
alt={member.name}
|
||||
className="w-full h-full object-cover grayscale group-hover:grayscale-0 transition-all duration-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 정보 */}
|
||||
<div className="p-6 flex-1 flex flex-col">
|
||||
<h3 className="text-xl font-bold mb-3 text-gray-500">
|
||||
{member.name}
|
||||
</h3>
|
||||
|
||||
<div className="flex items-center gap-2 text-sm text-gray-400">
|
||||
<Calendar size={14} />
|
||||
<span>
|
||||
{formatDate(member.birth_date, 'YYYY.MM.DD')}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 호버 효과 - 컬러 바 */}
|
||||
<div className="absolute bottom-0 left-0 right-0 h-1 bg-gray-400 transform scale-x-0 group-hover:scale-x-100 transition-transform duration-300" />
|
||||
</div>
|
||||
</motion.div>
|
||||
))}
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default Members;
|
||||
Loading…
Add table
Reference in a new issue