feat: 앨범 상세 페이지 컨셉 포토 라이트박스 개선

- PC/Mobile 앨범 상세 페이지 컨셉 포토 라이트박스에서 좌우 네비게이션 지원
- 현재 보이는 미리보기 사진 내에서만 네비게이션 (PC 4장, Mobile 6장)
- 컨셉 이름과 멤버 정보 표시 (AlbumGallery와 동일한 UX)
- lightbox state에 photos 배열 추가하여 메타데이터 관리

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
caadiq 2026-01-22 13:19:36 +09:00
parent 57d4f1dd5c
commit 97d6148280
2 changed files with 122 additions and 17 deletions

View file

@ -27,7 +27,7 @@ import { LightboxIndicator } from '@/components/common';
function MobileAlbumDetail() {
const { name } = useParams();
const navigate = useNavigate();
const [lightbox, setLightbox] = useState({ open: false, images: [], index: 0, showNav: true, teasers: null });
const [lightbox, setLightbox] = useState({ open: false, images: [], index: 0, showNav: true, teasers: null, photos: null });
const [showAllTracks, setShowAllTracks] = useState(false);
const [showDescriptionModal, setShowDescriptionModal] = useState(false);
const swiperRef = useRef(null);
@ -47,6 +47,7 @@ function MobileAlbumDetail() {
index,
showNav: options.showNav !== false,
teasers: options.teasers,
photos: options.photos || null,
});
window.history.pushState({ lightbox: true }, '');
}, []);
@ -123,7 +124,24 @@ function MobileAlbumDetail() {
);
}
const allPhotos = album.conceptPhotos ? Object.values(album.conceptPhotos).flat() : [];
//
const allPhotosWithInfo = [];
if (album.conceptPhotos) {
Object.entries(album.conceptPhotos).forEach(([concept, conceptPhotos]) => {
conceptPhotos.forEach((p) =>
allPhotosWithInfo.push({
...p,
originalUrl: p.original_url,
mediumUrl: p.medium_url,
thumbUrl: p.thumb_url,
title: concept,
members: p.members || '',
})
);
});
}
const previewCount = 6;
const previewPhotos = allPhotosWithInfo.slice(0, previewCount);
const displayTracks = showAllTracks ? album.tracks : album.tracks?.slice(0, 5);
return (
@ -276,7 +294,7 @@ function MobileAlbumDetail() {
)}
{/* 컨셉 포토 */}
{allPhotos.length > 0 && (
{previewPhotos.length > 0 && (
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
@ -285,14 +303,20 @@ function MobileAlbumDetail() {
>
<p className="text-sm font-semibold mb-3">컨셉 포토</p>
<div className="grid grid-cols-3 gap-2">
{allPhotos.slice(0, 6).map((photo, idx) => (
{previewPhotos.map((photo, idx) => (
<div
key={photo.id}
onClick={() => openLightbox([photo.original_url], 0, { showNav: false })}
onClick={() =>
openLightbox(
previewPhotos.map((p) => p.originalUrl),
idx,
{ showNav: true, photos: previewPhotos }
)
}
className="aspect-square bg-gray-100 rounded-xl overflow-hidden shadow-sm"
>
<img
src={photo.thumb_url || photo.medium_url}
src={photo.thumbUrl || photo.mediumUrl}
alt={`컨셉 포토 ${idx + 1}`}
loading="lazy"
className="w-full h-full object-cover"
@ -305,7 +329,7 @@ function MobileAlbumDetail() {
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"
>
전체 {allPhotos.length} 보기
전체 {allPhotosWithInfo.length} 보기
<ChevronRight size={16} />
</button>
</motion.div>
@ -403,11 +427,11 @@ function MobileAlbumDetail() {
>
{lightbox.images.map((url, index) => (
<SwiperSlide key={index} virtualIndex={index}>
<div className="w-full h-full flex items-center justify-center">
<div className="w-full h-full flex flex-col items-center justify-center">
{lightbox.teasers?.[index]?.media_type === 'video' ? (
<video
src={url}
className="max-w-full max-h-full object-contain"
className="max-w-full max-h-[70vh] object-contain"
controls
autoPlay={index === lightbox.index}
/>
@ -415,10 +439,41 @@ function MobileAlbumDetail() {
<img
src={url}
alt=""
className="max-w-full max-h-full object-contain"
className="max-w-full max-h-[70vh] object-contain"
loading={Math.abs(index - lightbox.index) <= 2 ? 'eager' : 'lazy'}
/>
)}
{/* 컨셉 포토 정보 (멤버 + 컨셉) */}
{lightbox.photos && (() => {
const photo = lightbox.photos[index];
const title = photo?.title;
const hasValidTitle = title && title.trim() && title !== 'Default';
const members = photo?.members;
const hasMembers = members && String(members).trim();
if (!hasValidTitle && !hasMembers) return null;
return (
<div className="mt-4 flex flex-col items-center gap-2 px-4">
{hasValidTitle && (
<span className="px-3 py-1.5 bg-white/10 backdrop-blur-sm rounded-full text-white font-medium text-sm">
{title}
</span>
)}
{hasMembers && (
<div className="flex flex-wrap items-center justify-center gap-1.5">
{String(members)
.split(',')
.map((member, idx) => (
<span key={idx} className="px-2.5 py-1 bg-primary/80 rounded-full text-white text-xs">
{member.trim()}
</span>
))}
</div>
)}
</div>
);
})()}
</div>
</SwiperSlide>
))}

View file

@ -23,7 +23,7 @@ import { LightboxIndicator } from '@/components/common';
function PCAlbumDetail() {
const { name } = useParams();
const navigate = useNavigate();
const [lightbox, setLightbox] = useState({ open: false, images: [], index: 0, teasers: null });
const [lightbox, setLightbox] = useState({ open: false, images: [], index: 0, teasers: null, photos: null });
const [slideDirection, setSlideDirection] = useState(0);
const [imageLoaded, setImageLoaded] = useState(false);
const [preloadedImages] = useState(() => new Set());
@ -60,7 +60,7 @@ function PCAlbumDetail() {
//
const openLightbox = useCallback((images, index, options = {}) => {
setLightbox({ open: true, images, index, teasers: options.teasers });
setLightbox({ open: true, images, index, teasers: options.teasers, photos: options.photos || null });
window.history.pushState({ lightbox: true }, '');
}, []);
@ -387,9 +387,22 @@ function PCAlbumDetail() {
className="mt-10"
>
{(() => {
const allPhotos = Object.values(album.conceptPhotos).flat();
const previewPhotos = allPhotos.slice(0, 4);
const totalCount = allPhotos.length;
//
const allPhotosWithInfo = [];
Object.entries(album.conceptPhotos).forEach(([concept, conceptPhotos]) => {
conceptPhotos.forEach((p) =>
allPhotosWithInfo.push({
...p,
originalUrl: p.original_url,
mediumUrl: p.medium_url,
title: concept,
members: p.members || '',
})
);
});
const previewCount = 4;
const previewPhotos = allPhotosWithInfo.slice(0, previewCount);
const totalCount = allPhotosWithInfo.length;
return (
<>
@ -406,11 +419,17 @@ function PCAlbumDetail() {
{previewPhotos.map((photo, idx) => (
<div
key={photo.id}
onClick={() => openLightbox([photo.original_url], 0)}
onClick={() =>
openLightbox(
previewPhotos.map((p) => p.originalUrl),
idx,
{ photos: previewPhotos }
)
}
className="aspect-square bg-gray-200 rounded-xl overflow-hidden cursor-pointer transition-all duration-300 ease-out hover:scale-[1.03] hover:shadow-xl hover:z-10"
>
<img
src={photo.medium_url}
src={photo.mediumUrl}
alt={`컨셉 포토 ${idx + 1}`}
loading="lazy"
className="w-full h-full object-cover"
@ -506,6 +525,37 @@ function PCAlbumDetail() {
transition={{ duration: 0.25, ease: 'easeOut' }}
/>
)}
{/* 컨셉 포토 정보 (멤버 + 컨셉) */}
{imageLoaded && lightbox.photos && (() => {
const photo = lightbox.photos[lightbox.index];
const title = photo?.title;
const hasValidTitle = title && title.trim() && title !== 'Default';
const members = photo?.members;
const hasMembers = members && String(members).trim();
if (!hasValidTitle && !hasMembers) return null;
return (
<div className="mt-6 flex flex-col items-center gap-2">
{hasValidTitle && (
<span className="px-4 py-2 bg-white/10 backdrop-blur-sm rounded-full text-white font-medium text-base">
{title}
</span>
)}
{hasMembers && (
<div className="flex items-center gap-2">
{String(members)
.split(',')
.map((member, idx) => (
<span key={idx} className="px-3 py-1.5 bg-primary/80 rounded-full text-white text-sm">
{member.trim()}
</span>
))}
</div>
)}
</div>
);
})()}
</div>
{/* 다음 버튼 */}