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:
parent
57d4f1dd5c
commit
97d6148280
2 changed files with 122 additions and 17 deletions
|
|
@ -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>
|
||||
))}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
||||
{/* 다음 버튼 */}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue