refactor: 모바일 UI 개선

- 멤버 페이지: 바텀시트를 가운데 다이얼로그로 변경, 닫기 버튼 추가
- 앨범 상세: 섹션별 순차 애니메이션, 앨범 소개 다이얼로그로 변경
- 앨범 갤러리: 헤더 뒤로가기 버튼/클릭 기능 제거

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
caadiq 2026-01-24 10:36:27 +09:00
parent 89e346d2c6
commit 821ff64bad
3 changed files with 86 additions and 107 deletions

View file

@ -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>

View file

@ -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열 그리드 */}

View file

@ -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>