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 { useParams, useNavigate } from 'react-router-dom';
|
||||||
import { useQuery } from '@tanstack/react-query';
|
import { useQuery } from '@tanstack/react-query';
|
||||||
import { motion, AnimatePresence } from 'framer-motion';
|
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 [lightbox, setLightbox] = useState({ open: false, images: [], index: 0, teasers: null, photos: null });
|
||||||
const [showAllTracks, setShowAllTracks] = useState(false);
|
const [showAllTracks, setShowAllTracks] = useState(false);
|
||||||
const [showDescriptionModal, setShowDescriptionModal] = useState(false);
|
const [showDescriptionModal, setShowDescriptionModal] = useState(false);
|
||||||
|
const descriptionHistoryRef = useRef(false);
|
||||||
|
|
||||||
// 앨범 데이터 로드
|
// 앨범 데이터 로드
|
||||||
const { data: album, isLoading: loading } = useQuery({
|
const { data: album, isLoading: loading } = useQuery({
|
||||||
|
|
@ -50,12 +51,23 @@ function MobileAlbumDetail() {
|
||||||
const openDescriptionModal = useCallback(() => {
|
const openDescriptionModal = useCallback(() => {
|
||||||
setShowDescriptionModal(true);
|
setShowDescriptionModal(true);
|
||||||
window.history.pushState({ description: true }, '');
|
window.history.pushState({ description: true }, '');
|
||||||
|
descriptionHistoryRef.current = true;
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// 앨범 소개 닫기
|
||||||
|
const closeDescriptionModal = useCallback(() => {
|
||||||
|
setShowDescriptionModal(false);
|
||||||
|
if (descriptionHistoryRef.current) {
|
||||||
|
descriptionHistoryRef.current = false;
|
||||||
|
window.history.back();
|
||||||
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// 뒤로가기 처리 (앨범 소개만 - 라이트박스는 MobileLightbox에서 처리)
|
// 뒤로가기 처리 (앨범 소개만 - 라이트박스는 MobileLightbox에서 처리)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handlePopState = () => {
|
const handlePopState = () => {
|
||||||
if (showDescriptionModal) {
|
if (showDescriptionModal) {
|
||||||
|
descriptionHistoryRef.current = false;
|
||||||
setShowDescriptionModal(false);
|
setShowDescriptionModal(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
@ -184,7 +196,12 @@ function MobileAlbumDetail() {
|
||||||
|
|
||||||
{/* 티저 이미지 */}
|
{/* 티저 이미지 */}
|
||||||
{album.teasers && album.teasers.length > 0 && (
|
{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>
|
<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">
|
<div className="flex gap-3 overflow-x-auto pb-1 -mx-4 px-4 scrollbar-hide">
|
||||||
{album.teasers.map((teaser, index) => (
|
{album.teasers.map((teaser, index) => (
|
||||||
|
|
@ -222,6 +239,7 @@ function MobileAlbumDetail() {
|
||||||
<motion.div
|
<motion.div
|
||||||
initial={{ opacity: 0, y: 20 }}
|
initial={{ opacity: 0, y: 20 }}
|
||||||
animate={{ opacity: 1, y: 0 }}
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
transition={{ delay: 0.2 }}
|
||||||
className="px-4 py-4 border-b border-gray-100"
|
className="px-4 py-4 border-b border-gray-100"
|
||||||
>
|
>
|
||||||
<p className="text-sm font-semibold mb-3">수록곡</p>
|
<p className="text-sm font-semibold mb-3">수록곡</p>
|
||||||
|
|
@ -269,7 +287,7 @@ function MobileAlbumDetail() {
|
||||||
<motion.div
|
<motion.div
|
||||||
initial={{ opacity: 0, y: 20 }}
|
initial={{ opacity: 0, y: 20 }}
|
||||||
animate={{ opacity: 1, y: 0 }}
|
animate={{ opacity: 1, y: 0 }}
|
||||||
transition={{ delay: 0.1 }}
|
transition={{ delay: 0.25 }}
|
||||||
className="px-4 py-4"
|
className="px-4 py-4"
|
||||||
>
|
>
|
||||||
<p className="text-sm font-semibold mb-3">컨셉 포토</p>
|
<p className="text-sm font-semibold mb-3">컨셉 포토</p>
|
||||||
|
|
@ -314,38 +332,26 @@ function MobileAlbumDetail() {
|
||||||
initial={{ opacity: 0 }}
|
initial={{ opacity: 0 }}
|
||||||
animate={{ opacity: 1 }}
|
animate={{ opacity: 1 }}
|
||||||
exit={{ opacity: 0 }}
|
exit={{ opacity: 0 }}
|
||||||
className="fixed inset-0 bg-black/60 z-[60] flex items-end justify-center"
|
className="fixed inset-0 bg-black/60 z-[60] flex items-center justify-center p-6"
|
||||||
onClick={() => window.history.back()}
|
onClick={closeDescriptionModal}
|
||||||
>
|
>
|
||||||
<motion.div
|
<motion.div
|
||||||
initial={{ y: '100%' }}
|
initial={{ opacity: 0, scale: 0.95 }}
|
||||||
animate={{ y: 0 }}
|
animate={{ opacity: 1, scale: 1 }}
|
||||||
exit={{ y: '100%' }}
|
exit={{ opacity: 0, scale: 0.95 }}
|
||||||
transition={{ type: 'spring', damping: 25, stiffness: 300 }}
|
transition={{ type: 'spring', damping: 25, stiffness: 300 }}
|
||||||
drag="y"
|
className="bg-white rounded-2xl w-full max-h-[70vh] overflow-hidden"
|
||||||
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"
|
|
||||||
onClick={(e) => e.stopPropagation()}
|
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>
|
<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" />
|
<X size={20} className="text-gray-500" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</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>
|
<p className="text-sm text-gray-600 leading-relaxed whitespace-pre-line text-justify">{album.description}</p>
|
||||||
</div>
|
</div>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,6 @@
|
||||||
import { useState, useCallback, useMemo } from 'react';
|
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 { useQuery } from '@tanstack/react-query';
|
||||||
import { ChevronRight } from 'lucide-react';
|
|
||||||
import { motion } from 'framer-motion';
|
import { motion } from 'framer-motion';
|
||||||
import { getAlbumByName } from '@/api';
|
import { getAlbumByName } from '@/api';
|
||||||
import { MobileLightbox } from '@/components/common';
|
import { MobileLightbox } from '@/components/common';
|
||||||
|
|
@ -11,7 +10,6 @@ import { MobileLightbox } from '@/components/common';
|
||||||
*/
|
*/
|
||||||
function MobileAlbumGallery() {
|
function MobileAlbumGallery() {
|
||||||
const { name } = useParams();
|
const { name } = useParams();
|
||||||
const navigate = useNavigate();
|
|
||||||
const [selectedIndex, setSelectedIndex] = useState(null);
|
const [selectedIndex, setSelectedIndex] = useState(null);
|
||||||
|
|
||||||
// 앨범 데이터 로드
|
// 앨범 데이터 로드
|
||||||
|
|
@ -78,10 +76,7 @@ function MobileAlbumGallery() {
|
||||||
<>
|
<>
|
||||||
<div className="pb-4">
|
<div className="pb-4">
|
||||||
{/* 앨범 헤더 카드 */}
|
{/* 앨범 헤더 카드 */}
|
||||||
<div
|
<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">
|
||||||
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)}
|
|
||||||
>
|
|
||||||
{album?.cover_thumb_url && (
|
{album?.cover_thumb_url && (
|
||||||
<img
|
<img
|
||||||
src={album.cover_thumb_url}
|
src={album.cover_thumb_url}
|
||||||
|
|
@ -94,7 +89,6 @@ function MobileAlbumGallery() {
|
||||||
<p className="font-bold truncate">{album?.title}</p>
|
<p className="font-bold truncate">{album?.title}</p>
|
||||||
<p className="text-xs text-gray-500">{photos.length}장의 사진</p>
|
<p className="text-xs text-gray-500">{photos.length}장의 사진</p>
|
||||||
</div>
|
</div>
|
||||||
<ChevronRight size={20} className="text-gray-400 rotate-180" />
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 2열 그리드 */}
|
{/* 2열 그리드 */}
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
import { motion, AnimatePresence } from 'framer-motion';
|
import { motion, AnimatePresence } from 'framer-motion';
|
||||||
import { useState, useMemo, useRef, useEffect } from 'react';
|
import { useState, useMemo, useRef, useEffect } from 'react';
|
||||||
import { Instagram, Calendar } from 'lucide-react';
|
import { Calendar, X, Instagram } from 'lucide-react';
|
||||||
import { useMembers } from '@/hooks';
|
import { useMembers } from '@/hooks';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -39,7 +39,6 @@ const MemberCard = ({ member, index, onClick, shouldAnimate }) => (
|
||||||
*/
|
*/
|
||||||
function MobileMembers() {
|
function MobileMembers() {
|
||||||
const [selectedMember, setSelectedMember] = useState(null);
|
const [selectedMember, setSelectedMember] = useState(null);
|
||||||
const [isDragging, setIsDragging] = useState(false);
|
|
||||||
const hasAnimated = useRef(false);
|
const hasAnimated = useRef(false);
|
||||||
|
|
||||||
// 멤버 데이터 로드
|
// 멤버 데이터 로드
|
||||||
|
|
@ -81,13 +80,6 @@ function MobileMembers() {
|
||||||
return age;
|
return age;
|
||||||
};
|
};
|
||||||
|
|
||||||
// 드래그 종료 시 닫기 판단
|
|
||||||
const handleDragEnd = (_, info) => {
|
|
||||||
if (info.offset.y > 100 || info.velocity.y > 500) {
|
|
||||||
setSelectedMember(null);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
if (allMembers.length === 0) {
|
if (allMembers.length === 0) {
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center justify-center h-[calc(100vh-200px)]">
|
<div className="flex items-center justify-center h-[calc(100vh-200px)]">
|
||||||
|
|
@ -101,7 +93,7 @@ function MobileMembers() {
|
||||||
return (
|
return (
|
||||||
<div className="bg-gray-50 p-4">
|
<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) => (
|
{currentMembers.map((member, index) => (
|
||||||
<MemberCard
|
<MemberCard
|
||||||
key={member.id}
|
key={member.id}
|
||||||
|
|
@ -121,7 +113,7 @@ function MobileMembers() {
|
||||||
<span className="text-gray-400 text-sm font-medium">전 멤버</span>
|
<span className="text-gray-400 text-sm font-medium">전 멤버</span>
|
||||||
<div className="flex-1 h-px bg-gray-300" />
|
<div className="flex-1 h-px bg-gray-300" />
|
||||||
</div>
|
</div>
|
||||||
<div className="grid grid-cols-2 gap-3">
|
<div className="grid grid-cols-2 gap-4">
|
||||||
{formerMembers.map((member, index) => (
|
{formerMembers.map((member, index) => (
|
||||||
<MemberCard
|
<MemberCard
|
||||||
key={member.id}
|
key={member.id}
|
||||||
|
|
@ -143,34 +135,19 @@ function MobileMembers() {
|
||||||
animate={{ opacity: 1 }}
|
animate={{ opacity: 1 }}
|
||||||
exit={{ opacity: 0 }}
|
exit={{ opacity: 0 }}
|
||||||
transition={{ duration: 0.2 }}
|
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)}
|
onClick={() => setSelectedMember(null)}
|
||||||
>
|
>
|
||||||
<motion.div
|
<motion.div
|
||||||
initial={{ y: '100%' }}
|
initial={{ opacity: 0, scale: 0.95 }}
|
||||||
animate={{ y: 20 }}
|
animate={{ opacity: 1, scale: 1 }}
|
||||||
exit={{ y: '100%' }}
|
exit={{ opacity: 0, scale: 0.95 }}
|
||||||
transition={{ type: 'spring', damping: 28, stiffness: 350 }}
|
transition={{ type: 'spring', damping: 25, stiffness: 300 }}
|
||||||
drag="y"
|
className="w-64 bg-white rounded-2xl overflow-hidden"
|
||||||
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"
|
|
||||||
onClick={(e) => e.stopPropagation()}
|
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="px-5">
|
|
||||||
<div className="flex gap-4">
|
|
||||||
{/* 이미지 */}
|
{/* 이미지 */}
|
||||||
<div className="w-24 h-32 rounded-xl overflow-hidden flex-shrink-0">
|
<div className="relative aspect-[3/4] overflow-hidden">
|
||||||
{selectedMember.image_thumb || selectedMember.image_url ? (
|
{selectedMember.image_thumb || selectedMember.image_url ? (
|
||||||
<img
|
<img
|
||||||
src={selectedMember.image_thumb || selectedMember.image_url}
|
src={selectedMember.image_thumb || selectedMember.image_url}
|
||||||
|
|
@ -178,45 +155,47 @@ function MobileMembers() {
|
||||||
className="w-full h-full object-cover"
|
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">
|
<div className="w-full h-full bg-gray-200 flex items-center justify-center text-3xl font-bold text-gray-400">
|
||||||
{selectedMember.name[0]}
|
{selectedMember.name[0]}
|
||||||
</div>
|
</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>
|
||||||
|
|
||||||
{/* 정보 영역 */}
|
{/* 정보 영역 */}
|
||||||
<div className="flex-1 flex flex-col justify-between py-1">
|
<div className="p-4 text-center">
|
||||||
<div>
|
<h3 className="text-lg font-bold">{selectedMember.name}</h3>
|
||||||
<h3 className="text-xl font-bold">{selectedMember.name}</h3>
|
|
||||||
{selectedMember.birth_date && (
|
{selectedMember.birth_date && (
|
||||||
<div className="flex items-center gap-1 text-gray-500 text-sm mt-2">
|
<div className="flex items-center justify-center gap-1 text-gray-500 text-sm mt-1.5">
|
||||||
<Calendar size={14} />
|
<Calendar size={14} />
|
||||||
<span>
|
<span>
|
||||||
{selectedMember.birth_date?.slice(0, 10).replaceAll('-', '.')}
|
{selectedMember.birth_date?.slice(0, 10).replaceAll('-', '.')}
|
||||||
</span>
|
</span>
|
||||||
{calculateAge(selectedMember.birth_date) && (
|
{calculateAge(selectedMember.birth_date) && (
|
||||||
<span className="ml-1 text-primary">
|
<span className="ml-0.5 text-primary">
|
||||||
({calculateAge(selectedMember.birth_date)}세)
|
({calculateAge(selectedMember.birth_date)}세)
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
|
||||||
{!selectedMember.is_former && selectedMember.instagram && (
|
{!selectedMember.is_former && selectedMember.instagram && (
|
||||||
<a
|
<a
|
||||||
href={selectedMember.instagram}
|
href={selectedMember.instagram}
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
onClick={(e) => isDragging && e.preventDefault()}
|
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"
|
||||||
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" />
|
<Instagram size={16} className="text-white" />
|
||||||
<span className="text-white text-xs font-medium">Instagram</span>
|
<span className="text-white text-sm font-medium">Instagram</span>
|
||||||
</a>
|
</a>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</motion.div>
|
</motion.div>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue