모바일 웹: 멤버 페이지 UI 개선 (앱 디자인 적용)

- 그리드 레이아웃에서 카드 스와이프 스타일로 변경
- 상단 썸네일 인디케이터 추가 (자동 스크롤, 클릭 이동)
- 멤버 페이지 헤더 그림자 제거 (noShadow prop)
- Swiper 라이브러리로 좌우 스와이프 구현
- 카드 그림자 및 레이아웃 최적화

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
caadiq 2026-01-13 14:38:59 +09:00
parent 3b5f8a93ca
commit 7a1e04b6ae
3 changed files with 193 additions and 172 deletions

View file

@ -91,7 +91,7 @@ function App() {
<MobileView> <MobileView>
<Routes> <Routes>
<Route path="/" element={<MobileLayout><MobileHome /></MobileLayout>} /> <Route path="/" element={<MobileLayout><MobileHome /></MobileLayout>} />
<Route path="/members" element={<MobileLayout pageTitle="멤버"><MobileMembers /></MobileLayout>} /> <Route path="/members" element={<MobileLayout pageTitle="멤버" noShadow><MobileMembers /></MobileLayout>} />
<Route path="/album" element={<MobileLayout pageTitle="앨범"><MobileAlbum /></MobileLayout>} /> <Route path="/album" element={<MobileLayout pageTitle="앨범"><MobileAlbum /></MobileLayout>} />
<Route path="/album/:name" element={<MobileLayout pageTitle="앨범"><MobileAlbumDetail /></MobileLayout>} /> <Route path="/album/:name" element={<MobileLayout pageTitle="앨범"><MobileAlbumDetail /></MobileLayout>} />
<Route path="/album/:name/gallery" element={<MobileLayout pageTitle="앨범"><MobileAlbumGallery /></MobileLayout>} /> <Route path="/album/:name/gallery" element={<MobileLayout pageTitle="앨범"><MobileAlbumGallery /></MobileLayout>} />

View file

@ -4,9 +4,9 @@ import { useEffect } from 'react';
import '../../mobile.css'; import '../../mobile.css';
// //
function MobileHeader({ title }) { function MobileHeader({ title, noShadow = false }) {
return ( return (
<header className="bg-white shadow-sm sticky top-0 z-50"> <header className={`bg-white sticky top-0 z-50 ${noShadow ? '' : 'shadow-sm'}`}>
<div className="flex items-center justify-center h-14 px-4"> <div className="flex items-center justify-center h-14 px-4">
{title ? ( {title ? (
<span className="text-xl font-bold text-primary">{title}</span> <span className="text-xl font-bold text-primary">{title}</span>
@ -62,7 +62,7 @@ function MobileBottomNav() {
// pageTitle: ( fromis_9) // pageTitle: ( fromis_9)
// hideHeader: true ( ) // hideHeader: true ( )
// useCustomLayout: true (mobile-layout-container ) // useCustomLayout: true (mobile-layout-container )
function MobileLayout({ children, pageTitle, hideHeader = false, useCustomLayout = false }) { function MobileLayout({ children, pageTitle, hideHeader = false, useCustomLayout = false, noShadow = false }) {
// (body ) // (body )
useEffect(() => { useEffect(() => {
document.documentElement.classList.add('mobile-layout'); document.documentElement.classList.add('mobile-layout');
@ -83,7 +83,7 @@ function MobileLayout({ children, pageTitle, hideHeader = false, useCustomLayout
return ( return (
<div className="mobile-layout-container bg-white"> <div className="mobile-layout-container bg-white">
{!hideHeader && <MobileHeader title={pageTitle} />} {!hideHeader && <MobileHeader title={pageTitle} noShadow={noShadow} />}
<main className="mobile-content">{children}</main> <main className="mobile-content">{children}</main>
<MobileBottomNav /> <MobileBottomNav />
</div> </div>

View file

@ -1,12 +1,16 @@
import { motion, AnimatePresence } from 'framer-motion'; import { motion } from 'framer-motion';
import { useState, useMemo } from 'react'; import { useState, useMemo, useRef, useEffect } from 'react';
import { useQuery } from '@tanstack/react-query'; import { useQuery } from '@tanstack/react-query';
import { Instagram, Calendar, X } from 'lucide-react'; import { Instagram, Calendar } from 'lucide-react';
import { Swiper, SwiperSlide } from 'swiper/react';
import 'swiper/css';
import { getMembers } from '../../../api/public/members'; import { getMembers } from '../../../api/public/members';
// // -
function MobileMembers() { function MobileMembers() {
const [selectedMember, setSelectedMember] = useState(null); const [currentIndex, setCurrentIndex] = useState(0);
const swiperRef = useRef(null);
const indicatorRef = useRef(null);
// useQuery // useQuery
const { data: allMembers = [] } = useQuery({ const { data: allMembers = [] } = useQuery({
@ -14,10 +18,15 @@ function MobileMembers() {
queryFn: getMembers, queryFn: getMembers,
}); });
// useMemo / // useMemo / ( , )
const members = useMemo(() => allMembers.filter(m => !m.is_former), [allMembers]); const members = useMemo(() => {
const formerMembers = useMemo(() => allMembers.filter(m => m.is_former), [allMembers]); return [...allMembers].sort((a, b) => {
if (a.is_former !== b.is_former) {
return a.is_former ? 1 : -1;
}
return 0;
});
}, [allMembers]);
// //
const calculateAge = (birthDate) => { const calculateAge = (birthDate) => {
@ -32,176 +41,188 @@ function MobileMembers() {
return age; return age;
}; };
// //
const closeModal = () => setSelectedMember(null); 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({
const renderMemberCard = (member, index, isFormer = false) => ( 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 <motion.div
key={member.id} initial={{ opacity: 0, y: -20 }}
onClick={() => setSelectedMember(member)}
className="cursor-pointer group"
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }} animate={{ opacity: 1, y: 0 }}
transition={{ delay: index * 0.05, duration: 0.3 }} transition={{ duration: 0.4, ease: 'easeOut' }}
whileTap={{ scale: 0.95 }} className="bg-white shadow-sm"
> >
{/* 카드 컨테이너 */} <div
<div className={`relative overflow-hidden rounded-2xl bg-white shadow-sm ref={indicatorRef}
transition-shadow duration-300 group-hover:shadow-lg 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' : ''}`} ${isFormer ? 'grayscale' : ''}`}
> >
{/* 이미지 영역 - 3:4 비율 */} {member.image_url ? (
<div className="aspect-[3/4] bg-gradient-to-br from-gray-100 to-gray-200 overflow-hidden">
{member.image_url && (
<img <img
src={member.image_url} src={member.image_url}
alt={member.name} alt={member.name}
className="w-full h-full object-cover transition-transform duration-500 group-hover:scale-105"
/>
)}
</div>
{/* 정보 영역 - 하단 그라데이션 오버레이 */}
<div className="absolute bottom-0 left-0 right-0 bg-gradient-to-t from-black/70 via-black/30 to-transparent p-3 pt-10">
<p className="font-bold text-white text-sm drop-shadow-md">
{member.name}
</p>
</div>
{/* 호버시 반짝이 효과 */}
{!isFormer && (
<div className="absolute inset-0 bg-gradient-to-tr from-primary/0 via-white/0 to-white/20
opacity-0 group-hover:opacity-100 transition-opacity duration-300 pointer-events-none" />
)}
</div>
</motion.div>
);
return (
<div className="pb-4">
{/* 현재 멤버 그리드 */}
<div className="px-4 pt-4">
<div className="grid grid-cols-3 gap-3">
{members.map((member, index) => renderMemberCard(member, index))}
</div>
</div>
{/* 전 멤버 */}
{formerMembers.length > 0 && (
<div className="px-4 mt-8">
<div className="flex items-center gap-2 mb-4">
<div className="h-px flex-1 bg-gray-200" />
<h2 className="text-sm font-medium text-gray-400 px-2"> 멤버</h2>
<div className="h-px flex-1 bg-gray-200" />
</div>
<div className="grid grid-cols-3 gap-3">
{formerMembers.map((member, index) => renderMemberCard(member, index, true))}
</div>
</div>
)}
{/* 멤버 상세 모달 - 드래그로 닫기 가능 */}
<AnimatePresence>
{selectedMember && (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
className="fixed inset-0 bg-black/60 backdrop-blur-sm z-[100] flex items-end"
onClick={closeModal}
>
<motion.div
initial={{ y: '100%' }}
animate={{ y: 0 }}
exit={{ y: '100%' }}
transition={{ type: 'spring', damping: 25, stiffness: 300 }}
drag="y"
dragConstraints={{ top: 0, bottom: 0 }}
dragElastic={{ top: 0, bottom: 0.5 }}
onDragEnd={(_, info) => {
if (info.offset.y > 100 || info.velocity.y > 300) {
closeModal();
}
}}
className="bg-white w-full rounded-t-3xl overflow-hidden"
onClick={e => e.stopPropagation()}
>
{/* 드래그 핸들 */}
<div className="flex justify-center pt-3 pb-2 cursor-grab active:cursor-grabbing">
<div className="w-10 h-1 bg-gray-300 rounded-full" />
</div>
{/* 헤더 */}
<div className="flex items-center justify-between px-5 pb-3 border-b border-gray-100">
<h3 className="text-lg font-bold">멤버 정보</h3>
<button onClick={closeModal} className="p-1.5">
<X size={20} className="text-gray-500" />
</button>
</div>
{/* 모달 콘텐츠 */}
<div className="px-5 py-4 pb-5">
<div className="flex gap-4">
{/* 프로필 이미지 - 원본 비율 */}
<div className={`w-28 flex-shrink-0 ${selectedMember.is_former ? 'grayscale' : ''}`}>
<div className="aspect-[3/4] rounded-2xl overflow-hidden bg-gradient-to-br from-gray-100 to-gray-200 shadow-lg">
{selectedMember.image_url && (
<img
src={selectedMember.image_url}
alt={selectedMember.name}
className="w-full h-full object-cover" 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> </div>
</button>
);
})}
</div> </div>
</motion.div>
{/* 정보 */} {/* 메인 카드 영역 */}
<div className="flex-1 flex flex-col justify-between py-1"> <motion.div
<div> initial={{ opacity: 0, y: 40 }}
<h2 className="text-2xl font-bold text-gray-900">{selectedMember.name}</h2> 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, index) => {
const isFormer = member.is_former;
const age = calculateAge(member.birth_date);
{selectedMember.position && ( return (
<p className="text-gray-500 text-sm mt-1">{selectedMember.position}</p> <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" />
)} )}
{selectedMember.birth_date && ( {/* 하단 그라데이션 오버레이 */}
<div className="flex items-center gap-1.5 mt-2 text-gray-400 text-sm"> <div className="absolute inset-x-0 bottom-0 h-[220px] bg-gradient-to-t from-black/80 via-black/30 to-transparent" />
<Calendar size={14} />
<span> {/* 전 멤버 라벨 */}
{selectedMember.birth_date?.slice(0, 10).replaceAll('-', '.')} {isFormer && (
{calculateAge(selectedMember.birth_date) && ( <div className="absolute top-4 right-4 px-3 py-1.5 bg-black/60 rounded-full">
<span className="ml-1 text-gray-300"> <span className="text-white/70 text-xs font-medium"> 멤버</span>
({calculateAge(selectedMember.birth_date)}) </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.position && (
<p className="mt-2 text-base text-white/90 font-medium">
{member.position}
</p>
)}
{/* 생일 정보 */}
{member.birth_date && (
<div className="flex items-center gap-1.5 mt-3 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> </span>
)} )}
</span>
</div> </div>
)} )}
</div>
{/* 인스타그램 버튼 - 정보 영역 아래쪽 */} {/* 인스타그램 버튼 */}
{!selectedMember.is_former && selectedMember.instagram && ( {!isFormer && member.instagram && (
<a <a
href={selectedMember.instagram} href={member.instagram}
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
className="inline-flex items-center gap-1.5 mt-3 px-4 py-2 className="inline-flex items-center gap-2 mt-4 px-4 py-2.5
bg-gradient-to-r from-purple-500 via-pink-500 to-orange-500 bg-gradient-to-r from-[#833AB4] via-[#E1306C] to-[#F77737]
text-white text-sm rounded-full font-medium shadow-sm rounded-full shadow-lg shadow-[#E1306C]/40
hover:shadow-md transition-shadow w-fit" active:scale-95 transition-transform"
> >
<Instagram size={14} /> <Instagram size={18} className="text-white" />
<span>Instagram</span> <span className="text-white text-sm font-semibold">Instagram</span>
</a> </a>
)} )}
</div> </div>
</div> </div>
</div>
</motion.div>
</motion.div>
)} )}
</AnimatePresence> </SwiperSlide>
);
})}
</Swiper>
</motion.div>
</div> </div>
); );
} }