From f6ba4c8183bb050fee54dd35d1adef9029710e7e Mon Sep 17 00:00:00 2001 From: caadiq Date: Mon, 1 Jun 2026 14:47:37 +0900 Subject: [PATCH] =?UTF-8?q?feat(mobile-members):=20=EB=A7=A4=EA=B1=B0?= =?UTF-8?q?=EC=A7=84=20=ED=92=80=EB=B8=94=EB=A6=AC=EB=93=9C=20=EC=8A=A4?= =?UTF-8?q?=ED=8B=B0=ED=82=A4=20=EC=8A=A4=ED=83=9D=20=EB=94=94=EC=9E=90?= =?UTF-8?q?=EC=9D=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 멤버를 화면 폭 전체 화보 카드로 (한글명 + 생일/나이 + 인스타) - 스티키 스택: 스크롤 시 다음 카드가 이전 카드를 덮으며 흐려짐/축소 - 스크롤 멈추면 가장 가까운 카드를 상단에 자석처럼 스냅 - 마지막 카드도 상단까지 올라오도록 동적 하단 여유 공간 Co-Authored-By: Claude Opus 4.7 --- frontend/src/pages/mobile/members/Members.jsx | 286 +++++++++--------- 1 file changed, 136 insertions(+), 150 deletions(-) diff --git a/frontend/src/pages/mobile/members/Members.jsx b/frontend/src/pages/mobile/members/Members.jsx index a06b384..6e600eb 100644 --- a/frontend/src/pages/mobile/members/Members.jsx +++ b/frontend/src/pages/mobile/members/Members.jsx @@ -1,80 +1,143 @@ -import { motion, AnimatePresence } from 'framer-motion'; -import { useState, useMemo, useRef, useEffect } from 'react'; -import { Cake, X, Instagram } from 'lucide-react'; +import { useMemo, useRef, useEffect, forwardRef } from 'react'; +import { Cake, Instagram } from 'lucide-react'; import { useMembers } from '@/hooks'; -/** - * 멤버 카드 컴포넌트 (외부 정의로 리렌더링 방지) - */ -const MemberCard = ({ member, index, onClick, shouldAnimate }) => ( - - {member.image_medium || member.image_url ? ( - {member.name} - ) : ( -
- - {member.name[0]} - -
- )} -
-

{member.name}

-
-
-); +// 생일/나이 계산 +const formatBirth = (birthDate) => { + if (!birthDate) return null; + return birthDate.slice(0, 10).replaceAll('-', '.'); // YYYY.MM.DD +}; +const calcAge = (birthDate) => { + if (!birthDate) return null; + const b = new Date(birthDate); + const t = new Date(); + let age = t.getFullYear() - b.getFullYear(); + const m = t.getMonth() - b.getMonth(); + if (m < 0 || (m === 0 && t.getDate() < b.getDate())) age--; + return age; +}; /** - * Mobile 멤버 페이지 - 그리드 레이아웃 + * 현재 멤버 화보 카드 (스티키 스택) + */ +const MemberHeroCard = forwardRef(function MemberHeroCard({ member }, ref) { + const birth = formatBirth(member.birth_date); + const age = calcAge(member.birth_date); + + return ( +
+ {member.image_medium || member.image_url ? ( + {member.name} + ) : ( +
+ {member.name[0]} +
+ )} + + {/* 하단 그라데이션 + 정보 */} +
+

+ {member.name} +

+ {birth && ( +
+ + {birth}{age != null && ` · ${age}세`} +
+ )} + + {member.instagram && ( + + + Instagram + + )} +
+
+ ); +}); + +/** + * Mobile 멤버 페이지 - 스티키 스택 (덮이며 흐려짐) */ function MobileMembers() { - const [selectedMember, setSelectedMember] = useState(null); - const hasAnimated = useRef(false); - - // 멤버 데이터 로드 const { data: allMembers = [] } = useMembers(); + const currentMembers = useMemo(() => allMembers.filter((m) => !m.is_former), [allMembers]); - // 초기 애니메이션 완료 추적 + const cardRefs = useRef([]); + const spacerRef = useRef(null); useEffect(() => { - if (allMembers.length > 0 && !hasAnimated.current) { - const timer = setTimeout(() => { - hasAnimated.current = true; - }, allMembers.length * 50 + 300); - return () => clearTimeout(timer); - } - }, [allMembers.length]); + const container = document.querySelector('.mobile-content'); + if (!container) return; - // 현재 멤버만 표시 - const currentMembers = useMemo( - () => allMembers.filter((m) => !m.is_former), - [allMembers] - ); + // 마지막 카드가 top-3에서 딱 멈추도록 하단 여유 공간 계산 + const setSpacer = () => { + const cards = cardRefs.current.filter(Boolean); + if (!cards.length || !spacerRef.current) return; + const cardH = cards[cards.length - 1].getBoundingClientRect().height; + spacerRef.current.style.height = `${Math.max(0, container.clientHeight - cardH - 16)}px`; + }; - // 나이 계산 - 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; - }; + const onScroll = () => { + const cards = cardRefs.current.filter(Boolean); + if (cards.length === 0) return; + const rects = cards.map((c) => c.getBoundingClientRect()); + for (let i = 0; i < cards.length; i++) { + let coverage = 0; + if (i < cards.length - 1) { + const cur = rects[i]; + const next = rects[i + 1]; + coverage = Math.min(1, Math.max(0, (cur.bottom - next.top) / cur.height)); + } + cards[i].style.opacity = String(1 - coverage * 0.55); + cards[i].style.transform = `scale(${(1 - coverage * 0.05).toFixed(3)})`; + } + }; + + // 스크롤 멈추면, 올라오는 카드를 상단(top-4)에 덮이거나 되돌리도록 자석 스냅 + let snapTimer; + const snapToNearest = () => { + const cards = cardRefs.current.filter(Boolean); + if (cards.length < 2) return; + const line = container.getBoundingClientRect().top + 16; // top-4 + // 상단 라인 아래에서 올라오는 중인(=delta>0) 가장 가까운 카드 + let riser = null; + let riserDelta = Infinity; + for (const c of cards) { + const d = c.getBoundingClientRect().top - line; + if (d > 1 && d < riserDelta) { riserDelta = d; riser = c; } + } + if (!riser) return; + const trigger = riser.getBoundingClientRect().height + 16; // rest(아래) → 덮임(상단) 이동량 + // 절반 기준: 가까운 쪽(덮임 0 / 되돌림 trigger)으로 스냅 + const target = riserDelta < trigger / 2 ? riserDelta : riserDelta - trigger; + if (Math.abs(target) > 1) { + container.scrollBy({ top: target, behavior: 'smooth' }); + } + }; + + const handleScroll = () => { + onScroll(); + clearTimeout(snapTimer); + snapTimer = setTimeout(snapToNearest, 90); + }; + + setSpacer(); + onScroll(); + container.addEventListener('scroll', handleScroll, { passive: true }); + window.addEventListener('resize', setSpacer); + return () => { + container.removeEventListener('scroll', handleScroll); + window.removeEventListener('resize', setSpacer); + clearTimeout(snapTimer); + }; + }, [currentMembers.length]); if (allMembers.length === 0) { return ( @@ -84,96 +147,19 @@ function MobileMembers() { ); } - const shouldAnimate = !hasAnimated.current; - return ( -
- {/* 현재 멤버 */} -
- {currentMembers.map((member, index) => ( - +
+ {currentMembers.map((member, i) => ( + setSelectedMember(member)} - shouldAnimate={shouldAnimate} + ref={(el) => { cardRefs.current[i] = el; }} /> ))}
- - {/* 선택된 멤버 모달 */} - - {selectedMember && ( - setSelectedMember(null)} - > - e.stopPropagation()} - > - {/* 이미지 */} -
- {selectedMember.image_thumb || selectedMember.image_url ? ( - {selectedMember.name} - ) : ( -
- {selectedMember.name[0]} -
- )} - {/* 닫기 버튼 */} - -
- - {/* 정보 영역 */} -
-

{selectedMember.name}

- {selectedMember.birth_date && ( -
- - - {selectedMember.birth_date?.slice(0, 10).replaceAll('-', '.')} - - {calculateAge(selectedMember.birth_date) && ( - - ({calculateAge(selectedMember.birth_date)}세) - - )} -
- )} - {!selectedMember.is_former && selectedMember.instagram && ( - - - Instagram - - )} -
-
-
- )} -
+ {/* 마지막 카드가 top-3에서 멈추도록 동적 여유 공간 */} +
); }