refactor: 모바일 UI 개선
- 멤버 페이지: 바텀시트를 가운데 다이얼로그로 변경, 닫기 버튼 추가 - 앨범 상세: 섹션별 순차 애니메이션, 앨범 소개 다이얼로그로 변경 - 앨범 갤러리: 헤더 뒤로가기 버튼/클릭 기능 제거 Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
89e346d2c6
commit
821ff64bad
3 changed files with 86 additions and 107 deletions
|
|
@ -1,4 +1,4 @@
|
|||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { useState, useEffect, useCallback, useRef } from 'react';
|
||||
import { useParams, useNavigate } from 'react-router-dom';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
|
|
@ -26,6 +26,7 @@ function MobileAlbumDetail() {
|
|||
const [lightbox, setLightbox] = useState({ open: false, images: [], index: 0, teasers: null, photos: null });
|
||||
const [showAllTracks, setShowAllTracks] = useState(false);
|
||||
const [showDescriptionModal, setShowDescriptionModal] = useState(false);
|
||||
const descriptionHistoryRef = useRef(false);
|
||||
|
||||
// 앨범 데이터 로드
|
||||
const { data: album, isLoading: loading } = useQuery({
|
||||
|
|
@ -50,12 +51,23 @@ function MobileAlbumDetail() {
|
|||
const openDescriptionModal = useCallback(() => {
|
||||
setShowDescriptionModal(true);
|
||||
window.history.pushState({ description: true }, '');
|
||||
descriptionHistoryRef.current = true;
|
||||
}, []);
|
||||
|
||||
// 앨범 소개 닫기
|
||||
const closeDescriptionModal = useCallback(() => {
|
||||
setShowDescriptionModal(false);
|
||||
if (descriptionHistoryRef.current) {
|
||||
descriptionHistoryRef.current = false;
|
||||
window.history.back();
|
||||
}
|
||||
}, []);
|
||||
|
||||
// 뒤로가기 처리 (앨범 소개만 - 라이트박스는 MobileLightbox에서 처리)
|
||||
useEffect(() => {
|
||||
const handlePopState = () => {
|
||||
if (showDescriptionModal) {
|
||||
descriptionHistoryRef.current = false;
|
||||
setShowDescriptionModal(false);
|
||||
}
|
||||
};
|
||||
|
|
@ -184,7 +196,12 @@ function MobileAlbumDetail() {
|
|||
|
||||
{/* 티저 이미지 */}
|
||||
{album.teasers && album.teasers.length > 0 && (
|
||||
<motion.div initial={{ opacity: 0 }} animate={{ opacity: 1 }} className="px-4 py-4 border-b border-gray-100">
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: 0.15 }}
|
||||
className="px-4 py-4 border-b border-gray-100"
|
||||
>
|
||||
<p className="text-sm font-semibold mb-3">티저 이미지</p>
|
||||
<div className="flex gap-3 overflow-x-auto pb-1 -mx-4 px-4 scrollbar-hide">
|
||||
{album.teasers.map((teaser, index) => (
|
||||
|
|
@ -222,6 +239,7 @@ function MobileAlbumDetail() {
|
|||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: 0.2 }}
|
||||
className="px-4 py-4 border-b border-gray-100"
|
||||
>
|
||||
<p className="text-sm font-semibold mb-3">수록곡</p>
|
||||
|
|
@ -269,7 +287,7 @@ function MobileAlbumDetail() {
|
|||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: 0.1 }}
|
||||
transition={{ delay: 0.25 }}
|
||||
className="px-4 py-4"
|
||||
>
|
||||
<p className="text-sm font-semibold mb-3">컨셉 포토</p>
|
||||
|
|
@ -314,38 +332,26 @@ function MobileAlbumDetail() {
|
|||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
className="fixed inset-0 bg-black/60 z-[60] flex items-end justify-center"
|
||||
onClick={() => window.history.back()}
|
||||
className="fixed inset-0 bg-black/60 z-[60] flex items-center justify-center p-6"
|
||||
onClick={closeDescriptionModal}
|
||||
>
|
||||
<motion.div
|
||||
initial={{ y: '100%' }}
|
||||
animate={{ y: 0 }}
|
||||
exit={{ y: '100%' }}
|
||||
initial={{ opacity: 0, scale: 0.95 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
exit={{ opacity: 0, scale: 0.95 }}
|
||||
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) {
|
||||
window.history.back();
|
||||
}
|
||||
}}
|
||||
className="bg-white rounded-t-3xl w-full max-h-[80vh] overflow-hidden"
|
||||
className="bg-white rounded-2xl w-full max-h-[70vh] 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">
|
||||
<div className="flex items-center justify-between px-5 py-4 border-b border-gray-100">
|
||||
<h3 className="text-lg font-bold">앨범 소개</h3>
|
||||
<button onClick={() => window.history.back()} className="p-1.5">
|
||||
<button onClick={closeDescriptionModal} className="p-1.5">
|
||||
<X size={20} className="text-gray-500" />
|
||||
</button>
|
||||
</div>
|
||||
{/* 내용 */}
|
||||
<div className="px-5 py-4 overflow-y-auto max-h-[60vh]">
|
||||
<div className="px-5 py-4 overflow-y-auto max-h-[55vh]">
|
||||
<p className="text-sm text-gray-600 leading-relaxed whitespace-pre-line text-justify">{album.description}</p>
|
||||
</div>
|
||||
</motion.div>
|
||||
|
|
|
|||
|
|
@ -1,7 +1,6 @@
|
|||
import { useState, useCallback, useMemo } from 'react';
|
||||
import { useParams, useNavigate } from 'react-router-dom';
|
||||
import { useParams } from 'react-router-dom';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { ChevronRight } from 'lucide-react';
|
||||
import { motion } from 'framer-motion';
|
||||
import { getAlbumByName } from '@/api';
|
||||
import { MobileLightbox } from '@/components/common';
|
||||
|
|
@ -11,7 +10,6 @@ import { MobileLightbox } from '@/components/common';
|
|||
*/
|
||||
function MobileAlbumGallery() {
|
||||
const { name } = useParams();
|
||||
const navigate = useNavigate();
|
||||
const [selectedIndex, setSelectedIndex] = useState(null);
|
||||
|
||||
// 앨범 데이터 로드
|
||||
|
|
@ -78,10 +76,7 @@ function MobileAlbumGallery() {
|
|||
<>
|
||||
<div className="pb-4">
|
||||
{/* 앨범 헤더 카드 */}
|
||||
<div
|
||||
className="mx-4 mt-4 mb-4 p-4 bg-gradient-to-r from-primary/5 to-primary/10 rounded-2xl flex items-center gap-4"
|
||||
onClick={() => navigate(-1)}
|
||||
>
|
||||
<div className="mx-4 mt-4 mb-4 p-4 bg-gradient-to-r from-primary/5 to-primary/10 rounded-2xl flex items-center gap-4">
|
||||
{album?.cover_thumb_url && (
|
||||
<img
|
||||
src={album.cover_thumb_url}
|
||||
|
|
@ -94,7 +89,6 @@ function MobileAlbumGallery() {
|
|||
<p className="font-bold truncate">{album?.title}</p>
|
||||
<p className="text-xs text-gray-500">{photos.length}장의 사진</p>
|
||||
</div>
|
||||
<ChevronRight size={20} className="text-gray-400 rotate-180" />
|
||||
</div>
|
||||
|
||||
{/* 2열 그리드 */}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import { useState, useMemo, useRef, useEffect } from 'react';
|
||||
import { Instagram, Calendar } from 'lucide-react';
|
||||
import { Calendar, X, Instagram } from 'lucide-react';
|
||||
import { useMembers } from '@/hooks';
|
||||
|
||||
/**
|
||||
|
|
@ -39,7 +39,6 @@ const MemberCard = ({ member, index, onClick, shouldAnimate }) => (
|
|||
*/
|
||||
function MobileMembers() {
|
||||
const [selectedMember, setSelectedMember] = useState(null);
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
const hasAnimated = useRef(false);
|
||||
|
||||
// 멤버 데이터 로드
|
||||
|
|
@ -81,13 +80,6 @@ function MobileMembers() {
|
|||
return age;
|
||||
};
|
||||
|
||||
// 드래그 종료 시 닫기 판단
|
||||
const handleDragEnd = (_, info) => {
|
||||
if (info.offset.y > 100 || info.velocity.y > 500) {
|
||||
setSelectedMember(null);
|
||||
}
|
||||
};
|
||||
|
||||
if (allMembers.length === 0) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-[calc(100vh-200px)]">
|
||||
|
|
@ -101,7 +93,7 @@ function MobileMembers() {
|
|||
return (
|
||||
<div className="bg-gray-50 p-4">
|
||||
{/* 현재 멤버 */}
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
{currentMembers.map((member, index) => (
|
||||
<MemberCard
|
||||
key={member.id}
|
||||
|
|
@ -121,7 +113,7 @@ function MobileMembers() {
|
|||
<span className="text-gray-400 text-sm font-medium">전 멤버</span>
|
||||
<div className="flex-1 h-px bg-gray-300" />
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
{formerMembers.map((member, index) => (
|
||||
<MemberCard
|
||||
key={member.id}
|
||||
|
|
@ -143,79 +135,66 @@ function MobileMembers() {
|
|||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
transition={{ duration: 0.2 }}
|
||||
className="fixed inset-0 z-[100] bg-black/60 flex items-end"
|
||||
className="fixed inset-0 z-[100] bg-black/60 flex items-center justify-center p-8"
|
||||
onClick={() => setSelectedMember(null)}
|
||||
>
|
||||
<motion.div
|
||||
initial={{ y: '100%' }}
|
||||
animate={{ y: 20 }}
|
||||
exit={{ y: '100%' }}
|
||||
transition={{ type: 'spring', damping: 28, stiffness: 350 }}
|
||||
drag="y"
|
||||
dragConstraints={{ top: 20, bottom: 20 }}
|
||||
dragElastic={{ top: 0, bottom: 0.6 }}
|
||||
onDragStart={() => setIsDragging(true)}
|
||||
onDragEnd={(e, info) => {
|
||||
setIsDragging(false);
|
||||
handleDragEnd(e, info);
|
||||
}}
|
||||
className="relative w-full bg-white rounded-t-3xl touch-none pb-10"
|
||||
initial={{ opacity: 0, scale: 0.95 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
exit={{ opacity: 0, scale: 0.95 }}
|
||||
transition={{ type: 'spring', damping: 25, stiffness: 300 }}
|
||||
className="w-64 bg-white rounded-2xl 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 className="relative aspect-[3/4] overflow-hidden">
|
||||
{selectedMember.image_thumb || selectedMember.image_url ? (
|
||||
<img
|
||||
src={selectedMember.image_thumb || selectedMember.image_url}
|
||||
alt={selectedMember.name}
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
) : (
|
||||
<div className="w-full h-full bg-gray-200 flex items-center justify-center text-3xl font-bold text-gray-400">
|
||||
{selectedMember.name[0]}
|
||||
</div>
|
||||
)}
|
||||
{/* 닫기 버튼 */}
|
||||
<button
|
||||
onClick={() => setSelectedMember(null)}
|
||||
className="absolute top-2 right-2 w-8 h-8 bg-black/50 rounded-full flex items-center justify-center"
|
||||
>
|
||||
<X size={18} className="text-white" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="px-5">
|
||||
<div className="flex gap-4">
|
||||
{/* 이미지 */}
|
||||
<div className="w-24 h-32 rounded-xl overflow-hidden flex-shrink-0">
|
||||
{selectedMember.image_thumb || selectedMember.image_url ? (
|
||||
<img
|
||||
src={selectedMember.image_thumb || selectedMember.image_url}
|
||||
alt={selectedMember.name}
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
) : (
|
||||
<div className="w-full h-full bg-gray-200 flex items-center justify-center text-2xl font-bold text-gray-400">
|
||||
{selectedMember.name[0]}
|
||||
</div>
|
||||
{/* 정보 영역 */}
|
||||
<div className="p-4 text-center">
|
||||
<h3 className="text-lg font-bold">{selectedMember.name}</h3>
|
||||
{selectedMember.birth_date && (
|
||||
<div className="flex items-center justify-center gap-1 text-gray-500 text-sm mt-1.5">
|
||||
<Calendar size={14} />
|
||||
<span>
|
||||
{selectedMember.birth_date?.slice(0, 10).replaceAll('-', '.')}
|
||||
</span>
|
||||
{calculateAge(selectedMember.birth_date) && (
|
||||
<span className="ml-0.5 text-primary">
|
||||
({calculateAge(selectedMember.birth_date)}세)
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 정보 영역 */}
|
||||
<div className="flex-1 flex flex-col justify-between py-1">
|
||||
<div>
|
||||
<h3 className="text-xl font-bold">{selectedMember.name}</h3>
|
||||
{selectedMember.birth_date && (
|
||||
<div className="flex items-center gap-1 text-gray-500 text-sm mt-2">
|
||||
<Calendar size={14} />
|
||||
<span>
|
||||
{selectedMember.birth_date?.slice(0, 10).replaceAll('-', '.')}
|
||||
</span>
|
||||
{calculateAge(selectedMember.birth_date) && (
|
||||
<span className="ml-1 text-primary">
|
||||
({calculateAge(selectedMember.birth_date)}세)
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{!selectedMember.is_former && selectedMember.instagram && (
|
||||
<a
|
||||
href={selectedMember.instagram}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
onClick={(e) => isDragging && e.preventDefault()}
|
||||
className="inline-flex items-center gap-1.5 self-start px-4 py-2 bg-gradient-to-r from-[#833AB4] via-[#E1306C] to-[#F77737] rounded-full"
|
||||
>
|
||||
<Instagram size={14} className="text-white" />
|
||||
<span className="text-white text-xs font-medium">Instagram</span>
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{!selectedMember.is_former && selectedMember.instagram && (
|
||||
<a
|
||||
href={selectedMember.instagram}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-flex items-center justify-center gap-1.5 mt-3 w-full py-2.5 bg-gradient-to-r from-[#833AB4] via-[#E1306C] to-[#F77737] rounded-xl"
|
||||
>
|
||||
<Instagram size={16} className="text-white" />
|
||||
<span className="text-white text-sm font-medium">Instagram</span>
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue