feat(Mobile Album): 앨범 상세 UI 개선

- 히어로 섹션 (커버 중앙 + 블러 배경)
- 앨범 소개 바텀시트 다이얼로그 (드래그 닫기)
- 수록곡 더보기/접기 기능
- 컨셉 포토 전체보기 버튼 모바일화
- 뮤직비디오 버튼 제거
- 라이트박스 showNav 조건 추가
- 앨범 API folder_name 검색으로 수정
This commit is contained in:
caadiq 2026-01-11 22:17:28 +09:00
parent 79a3501ef1
commit de2e02fcd9
2 changed files with 193 additions and 143 deletions

View file

@ -81,13 +81,14 @@ router.get("/", async (req, res) => {
} }
}); });
// 앨범으로 조회 // 앨범 folder_name으로 조회
router.get("/by-name/:name", async (req, res) => { router.get("/by-name/:name", async (req, res) => {
try { try {
const albumName = decodeURIComponent(req.params.name); const folderName = decodeURIComponent(req.params.name);
const [albums] = await pool.query("SELECT * FROM albums WHERE title = ?", [ const [albums] = await pool.query(
albumName, "SELECT * FROM albums WHERE folder_name = ?",
]); [folderName]
);
if (albums.length === 0) { if (albums.length === 0) {
return res.status(404).json({ error: "앨범을 찾을 수 없습니다." }); return res.status(404).json({ error: "앨범을 찾을 수 없습니다." });

View file

@ -1,7 +1,7 @@
import { motion, AnimatePresence } from 'framer-motion'; import { motion, AnimatePresence } from 'framer-motion';
import { useState, useEffect, useCallback } from 'react'; import { useState, useEffect, useCallback } from 'react';
import { useParams, useNavigate } from 'react-router-dom'; import { useParams, useNavigate } from 'react-router-dom';
import { ArrowLeft, Play, Calendar, Music2, Clock, X, ChevronLeft, ChevronRight, Download } from 'lucide-react'; import { Play, Calendar, Music2, Clock, X, ChevronLeft, ChevronRight, Download, ChevronDown, ChevronUp, FileText } from 'lucide-react';
import { getAlbumByName } from '../../../api/public/albums'; import { getAlbumByName } from '../../../api/public/albums';
import { formatDate } from '../../../utils/date'; import { formatDate } from '../../../utils/date';
@ -11,8 +11,10 @@ function MobileAlbumDetail() {
const navigate = useNavigate(); const navigate = useNavigate();
const [album, setAlbum] = useState(null); const [album, setAlbum] = useState(null);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [lightbox, setLightbox] = useState({ open: false, images: [], index: 0 }); const [lightbox, setLightbox] = useState({ open: false, images: [], index: 0, showNav: true });
const [imageLoaded, setImageLoaded] = useState(false); const [imageLoaded, setImageLoaded] = useState(false);
const [showAllTracks, setShowAllTracks] = useState(false);
const [showDescriptionModal, setShowDescriptionModal] = useState(false);
// //
const goToPrev = useCallback(() => { const goToPrev = useCallback(() => {
@ -60,13 +62,13 @@ function MobileAlbumDetail() {
// body // body
useEffect(() => { useEffect(() => {
if (lightbox.open) { if (lightbox.open || showDescriptionModal) {
document.body.style.overflow = 'hidden'; document.body.style.overflow = 'hidden';
} else { } else {
document.body.style.overflow = ''; document.body.style.overflow = '';
} }
return () => { document.body.style.overflow = ''; }; return () => { document.body.style.overflow = ''; };
}, [lightbox.open]); }, [lightbox.open, showDescriptionModal]);
useEffect(() => { useEffect(() => {
getAlbumByName(name) getAlbumByName(name)
@ -113,68 +115,85 @@ function MobileAlbumDetail() {
// //
const allPhotos = album.conceptPhotos ? Object.values(album.conceptPhotos).flat() : []; const allPhotos = album.conceptPhotos ? Object.values(album.conceptPhotos).flat() : [];
const displayTracks = showAllTracks ? album.tracks : album.tracks?.slice(0, 5);
return ( return (
<> <>
<div className="pb-6"> <div className="pb-6">
{/* 헤더 */} {/* 앨범 히어로 섹션 - 커버 이미지 배경 */}
<div className="sticky top-14 z-40 bg-white/95 backdrop-blur-sm px-4 py-3 flex items-center gap-3 border-b border-gray-100"> <div className="relative">
<button onClick={() => navigate(-1)} className="p-1 -ml-1"> {/* 배경 블러 이미지 */}
<ArrowLeft size={22} /> <div className="absolute inset-0 overflow-hidden">
</button> <img
<span className="font-semibold truncate">{album.title}</span> src={album.cover_medium_url}
</div> alt=""
className="w-full h-full object-cover blur-2xl scale-110 opacity-30"
/>
<div className="absolute inset-0 bg-gradient-to-b from-white/60 via-white/80 to-white" />
</div>
{/* 앨범 정보 섹션 */} {/* 콘텐츠 */}
<div className="px-4 pt-5 pb-4"> <div className="relative px-5 pt-4 pb-5">
<div className="flex gap-4"> <div className="flex flex-col items-center">
{/* 앨범 커버 */} {/* 앨범 커버 */}
<motion.div <motion.div
initial={{ opacity: 0, scale: 0.9 }} initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, scale: 1 }} animate={{ opacity: 1, y: 0 }}
className="w-32 h-32 flex-shrink-0 rounded-2xl overflow-hidden shadow-lg" className="w-44 h-44 rounded-2xl overflow-hidden shadow-xl mb-4"
onClick={() => setLightbox({ onClick={() => setLightbox({
open: true, open: true,
images: [album.cover_original_url || album.cover_medium_url], images: [album.cover_original_url || album.cover_medium_url],
index: 0 index: 0,
})} showNav: false
> })}
{album.cover_medium_url && ( >
<img <img
src={album.cover_medium_url} src={album.cover_medium_url}
alt={album.title} alt={album.title}
className="w-full h-full object-cover" className="w-full h-full object-cover"
/> />
)} </motion.div>
</motion.div>
{/* 앨범 정보 */} {/* 앨범 정보 */}
<motion.div <motion.div
initial={{ opacity: 0, x: 10 }} initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, x: 0 }} animate={{ opacity: 1, y: 0 }}
className="flex-1 min-w-0" transition={{ delay: 0.1 }}
> className="text-center"
<span className="inline-block px-2.5 py-1 bg-primary/10 text-primary text-xs font-medium rounded-full mb-2"> >
{album.album_type} <span className="inline-block px-3 py-1 bg-primary/10 text-primary text-xs font-medium rounded-full mb-2">
</span> {album.album_type}
<h1 className="text-xl font-bold mb-2 truncate">{album.title}</h1> </span>
<h1 className="text-2xl font-bold mb-2">{album.title}</h1>
{/* 메타 정보 */} {/* 메타 정보 */}
<div className="flex flex-wrap items-center gap-x-3 gap-y-1 text-xs text-gray-500"> <div className="flex items-center justify-center gap-4 text-sm text-gray-500">
<div className="flex items-center gap-1"> <div className="flex items-center gap-1">
<Calendar size={12} /> <Calendar size={14} />
<span>{formatDate(album.release_date, 'YYYY.MM.DD')}</span> <span>{formatDate(album.release_date, 'YYYY.MM.DD')}</span>
</div>
<div className="flex items-center gap-1">
<Music2 size={14} />
<span>{album.tracks?.length || 0}</span>
</div>
<div className="flex items-center gap-1">
<Clock size={14} />
<span>{getTotalDuration()}</span>
</div>
</div> </div>
<div className="flex items-center gap-1">
<Music2 size={12} /> {/* 앨범 소개 버튼 */}
<span>{album.tracks?.length || 0}</span> {album.description && (
</div> <button
<div className="flex items-center gap-1"> onClick={() => setShowDescriptionModal(true)}
<Clock size={12} /> className="mt-3 flex items-center gap-1.5 mx-auto px-3 py-1.5 text-xs text-gray-500 bg-white/80 rounded-full shadow-sm"
<span>{getTotalDuration()}</span> >
</div> <FileText size={12} />
</div> 앨범 소개
</motion.div> </button>
)}
</motion.div>
</div>
</div> </div>
</div> </div>
@ -183,10 +202,10 @@ function MobileAlbumDetail() {
<motion.div <motion.div
initial={{ opacity: 0 }} initial={{ opacity: 0 }}
animate={{ opacity: 1 }} animate={{ opacity: 1 }}
className="px-4 mb-5" className="px-4 py-4 border-b border-gray-100"
> >
<p className="text-xs text-gray-400 mb-2">티저 포토</p> <p className="text-sm font-semibold mb-3">티저 포토</p>
<div className="flex gap-2 overflow-x-auto pb-1 -mx-4 px-4"> <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) => (
<div <div
key={index} key={index}
@ -194,9 +213,10 @@ function MobileAlbumDetail() {
open: true, open: true,
images: album.teasers.map(t => t.original_url), images: album.teasers.map(t => t.original_url),
index, index,
teasers: album.teasers teasers: album.teasers,
showNav: true
})} })}
className="w-20 h-20 flex-shrink-0 bg-gray-200 rounded-xl overflow-hidden relative" className="w-24 h-24 flex-shrink-0 bg-gray-100 rounded-2xl overflow-hidden relative shadow-sm"
> >
{teaser.media_type === 'video' ? ( {teaser.media_type === 'video' ? (
<> <>
@ -206,8 +226,8 @@ function MobileAlbumDetail() {
muted muted
/> />
<div className="absolute inset-0 flex items-center justify-center bg-black/30"> <div className="absolute inset-0 flex items-center justify-center bg-black/30">
<div className="w-7 h-7 bg-white/90 rounded-full flex items-center justify-center"> <div className="w-8 h-8 bg-white/90 rounded-full flex items-center justify-center">
<Play size={12} fill="currentColor" className="ml-0.5 text-gray-800" /> <Play size={14} fill="currentColor" className="ml-0.5 text-gray-800" />
</div> </div>
</div> </div>
</> </>
@ -229,50 +249,44 @@ 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 }}
className="px-4 mb-5" className="px-4 py-4 border-b border-gray-100"
> >
<h2 className="text-base font-bold mb-3">수록곡</h2> <p className="text-sm font-semibold mb-3">수록곡</p>
<div className="bg-white rounded-2xl shadow-sm overflow-hidden"> <div className="space-y-1">
{album.tracks.map((track, index) => ( {displayTracks?.map((track) => (
<div <div
key={track.id} key={track.id}
className={`flex items-center gap-3 px-4 py-3 ${ className="flex items-center gap-3 py-2.5 px-3 rounded-xl hover:bg-gray-50 transition-colors"
index !== album.tracks.length - 1 ? 'border-b border-gray-100' : ''
}`}
> >
<div className="w-8 h-8 flex items-center justify-center rounded-full bg-gray-100"> <span className="w-6 text-center text-sm text-gray-400 tabular-nums">
<span className="text-xs text-gray-500"> {String(track.track_number).padStart(2, '0')}
{String(track.track_number).padStart(2, '0')} </span>
</span> <div className="flex-1 min-w-0 flex items-center gap-2">
</div> <p className={`text-sm font-medium truncate ${track.is_title_track ? 'text-primary' : 'text-gray-800'}`}>
<div className="flex-1 min-w-0"> {track.title}
<div className="flex items-center gap-2"> </p>
<p className={`text-sm font-medium truncate ${track.is_title_track ? 'text-primary' : ''}`}> {track.is_title_track === 1 && (
{track.title} <span className="px-1.5 py-0.5 bg-primary text-white text-[10px] font-bold rounded flex-shrink-0">
</p> TITLE
{track.is_title_track === 1 && ( </span>
<span className="px-1.5 py-0.5 bg-primary text-white text-[10px] font-medium rounded"> )}
TITLE
</span>
)}
</div>
</div> </div>
<span className="text-xs text-gray-400 tabular-nums"> <span className="text-xs text-gray-400 tabular-nums">
{track.duration || '-'} {track.duration || '-'}
</span> </span>
{track.music_video_url && (
<a
href={track.music_video_url}
target="_blank"
rel="noopener noreferrer"
className="p-1.5 text-red-500"
>
<Play size={16} fill="currentColor" />
</a>
)}
</div> </div>
))} ))}
</div> </div>
{/* 더보기/접기 버튼 */}
{album.tracks.length > 5 && (
<button
onClick={() => setShowAllTracks(!showAllTracks)}
className="w-full mt-2 py-2 text-sm text-gray-500 flex items-center justify-center gap-1"
>
{showAllTracks ? '접기' : `${album.tracks.length - 5}곡 더보기`}
{showAllTracks ? <ChevronUp size={16} /> : <ChevronDown size={16} />}
</button>
)}
</motion.div> </motion.div>
)} )}
@ -282,27 +296,20 @@ function MobileAlbumDetail() {
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.1 }}
className="px-4" className="px-4 py-4"
> >
<div className="flex items-center justify-between mb-3"> <p className="text-sm font-semibold mb-3">컨셉 포토</p>
<h2 className="text-base font-bold">컨셉 포토</h2>
<button
onClick={() => navigate(`/album/${name}/gallery`)}
className="text-xs text-primary"
>
전체보기 ({allPhotos.length})
</button>
</div>
<div className="grid grid-cols-3 gap-2"> <div className="grid grid-cols-3 gap-2">
{allPhotos.slice(0, 6).map((photo, idx) => ( {allPhotos.slice(0, 6).map((photo, idx) => (
<div <div
key={photo.id} key={photo.id}
onClick={() => setLightbox({ onClick={() => setLightbox({
open: true, open: true,
images: allPhotos.map(p => p.original_url), images: [photo.original_url],
index: idx index: 0,
showNav: false
})} })}
className="aspect-square bg-gray-200 rounded-xl overflow-hidden" className="aspect-square bg-gray-100 rounded-xl overflow-hidden shadow-sm"
> >
<img <img
src={photo.thumb_url || photo.medium_url} src={photo.thumb_url || photo.medium_url}
@ -313,24 +320,69 @@ function MobileAlbumDetail() {
</div> </div>
))} ))}
</div> </div>
</motion.div> {/* 전체보기 버튼 - 모바일 스타일 */}
)} <button
onClick={() => navigate(`/album/${name}/gallery`)}
{/* 설명 */} className="w-full mt-3 py-3 text-sm text-primary font-medium bg-primary/5 rounded-xl flex items-center justify-center gap-1"
{album.description && ( >
<motion.div 전체 {allPhotos.length} 보기
initial={{ opacity: 0 }} <ChevronRight size={16} />
animate={{ opacity: 1 }} </button>
className="px-4 mt-5"
>
<h2 className="text-base font-bold mb-3">소개</h2>
<p className="text-sm text-gray-600 leading-relaxed bg-white p-4 rounded-2xl shadow-sm">
{album.description}
</p>
</motion.div> </motion.div>
)} )}
</div> </div>
{/* 앨범 소개 다이얼로그 */}
<AnimatePresence>
{showDescriptionModal && album?.description && (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
className="fixed inset-0 bg-black/60 z-[60] flex items-end justify-center"
onClick={() => setShowDescriptionModal(false)}
>
<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 > 200 || info.velocity.y > 500) {
setShowDescriptionModal(false);
}
}}
className="bg-white rounded-t-3xl w-full max-h-[80vh] 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={() => setShowDescriptionModal(false)}
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]">
<p className="text-sm text-gray-600 leading-relaxed whitespace-pre-line">
{album.description}
</p>
</div>
</motion.div>
</motion.div>
)}
</AnimatePresence>
{/* 라이트박스 */} {/* 라이트박스 */}
<AnimatePresence> <AnimatePresence>
{lightbox.open && ( {lightbox.open && (
@ -338,7 +390,7 @@ 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 z-50 flex items-center justify-center" className="fixed inset-0 bg-black z-[60] flex items-center justify-center"
onClick={closeLightbox} onClick={closeLightbox}
> >
{/* 상단 버튼 */} {/* 상단 버튼 */}
@ -360,8 +412,8 @@ function MobileAlbumDetail() {
</button> </button>
</div> </div>
{/* 이전 버튼 */} {/* 이전 버튼 - showNav가 true일 때만 */}
{lightbox.images.length > 1 && ( {lightbox.showNav && lightbox.images.length > 1 && (
<button <button
className="absolute left-2 p-2 text-white/70 z-10" className="absolute left-2 p-2 text-white/70 z-10"
onClick={(e) => { onClick={(e) => {
@ -400,8 +452,8 @@ function MobileAlbumDetail() {
/> />
)} )}
{/* 다음 버튼 */} {/* 다음 버튼 - showNav가 true일 때만 */}
{lightbox.images.length > 1 && ( {lightbox.showNav && lightbox.images.length > 1 && (
<button <button
className="absolute right-2 p-2 text-white/70 z-10" className="absolute right-2 p-2 text-white/70 z-10"
onClick={(e) => { onClick={(e) => {
@ -413,17 +465,14 @@ function MobileAlbumDetail() {
</button> </button>
)} )}
{/* 인디케이터 */} {/* 인디케이터 - showNav가 true일 때만 */}
{lightbox.images.length > 1 && ( {lightbox.showNav && lightbox.images.length > 1 && (
<div className="absolute bottom-6 left-0 right-0 flex justify-center gap-1.5"> <div className="absolute bottom-8 left-0 right-0 flex justify-center">
{lightbox.images.map((_, i) => ( <div className="bg-black/50 rounded-full px-3 py-1.5">
<div <span className="text-white text-sm tabular-nums">
key={i} {lightbox.index + 1} / {lightbox.images.length}
className={`w-1.5 h-1.5 rounded-full transition-colors ${ </span>
i === lightbox.index ? 'bg-white' : 'bg-white/40' </div>
}`}
/>
))}
</div> </div>
)} )}
</motion.div> </motion.div>