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() {
|
function MobileAlbumDetail() {
|
||||||
const { name } = useParams();
|
const { name } = useParams();
|
||||||
const navigate = useNavigate();
|
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 [showAllTracks, setShowAllTracks] = useState(false);
|
||||||
const [showDescriptionModal, setShowDescriptionModal] = useState(false);
|
const [showDescriptionModal, setShowDescriptionModal] = useState(false);
|
||||||
const swiperRef = useRef(null);
|
const swiperRef = useRef(null);
|
||||||
|
|
@ -47,6 +47,7 @@ function MobileAlbumDetail() {
|
||||||
index,
|
index,
|
||||||
showNav: options.showNav !== false,
|
showNav: options.showNav !== false,
|
||||||
teasers: options.teasers,
|
teasers: options.teasers,
|
||||||
|
photos: options.photos || null,
|
||||||
});
|
});
|
||||||
window.history.pushState({ lightbox: true }, '');
|
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);
|
const displayTracks = showAllTracks ? album.tracks : album.tracks?.slice(0, 5);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
@ -276,7 +294,7 @@ function MobileAlbumDetail() {
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* 컨셉 포토 */}
|
{/* 컨셉 포토 */}
|
||||||
{allPhotos.length > 0 && (
|
{previewPhotos.length > 0 && (
|
||||||
<motion.div
|
<motion.div
|
||||||
initial={{ opacity: 0, y: 20 }}
|
initial={{ opacity: 0, y: 20 }}
|
||||||
animate={{ opacity: 1, y: 0 }}
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
|
@ -285,14 +303,20 @@ function MobileAlbumDetail() {
|
||||||
>
|
>
|
||||||
<p className="text-sm font-semibold mb-3">컨셉 포토</p>
|
<p className="text-sm font-semibold mb-3">컨셉 포토</p>
|
||||||
<div className="grid grid-cols-3 gap-2">
|
<div className="grid grid-cols-3 gap-2">
|
||||||
{allPhotos.slice(0, 6).map((photo, idx) => (
|
{previewPhotos.map((photo, idx) => (
|
||||||
<div
|
<div
|
||||||
key={photo.id}
|
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"
|
className="aspect-square bg-gray-100 rounded-xl overflow-hidden shadow-sm"
|
||||||
>
|
>
|
||||||
<img
|
<img
|
||||||
src={photo.thumb_url || photo.medium_url}
|
src={photo.thumbUrl || photo.mediumUrl}
|
||||||
alt={`컨셉 포토 ${idx + 1}`}
|
alt={`컨셉 포토 ${idx + 1}`}
|
||||||
loading="lazy"
|
loading="lazy"
|
||||||
className="w-full h-full object-cover"
|
className="w-full h-full object-cover"
|
||||||
|
|
@ -305,7 +329,7 @@ function MobileAlbumDetail() {
|
||||||
onClick={() => navigate(`/album/${name}/gallery`)}
|
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"
|
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} />
|
<ChevronRight size={16} />
|
||||||
</button>
|
</button>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
|
|
@ -403,11 +427,11 @@ function MobileAlbumDetail() {
|
||||||
>
|
>
|
||||||
{lightbox.images.map((url, index) => (
|
{lightbox.images.map((url, index) => (
|
||||||
<SwiperSlide key={index} virtualIndex={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' ? (
|
{lightbox.teasers?.[index]?.media_type === 'video' ? (
|
||||||
<video
|
<video
|
||||||
src={url}
|
src={url}
|
||||||
className="max-w-full max-h-full object-contain"
|
className="max-w-full max-h-[70vh] object-contain"
|
||||||
controls
|
controls
|
||||||
autoPlay={index === lightbox.index}
|
autoPlay={index === lightbox.index}
|
||||||
/>
|
/>
|
||||||
|
|
@ -415,10 +439,41 @@ function MobileAlbumDetail() {
|
||||||
<img
|
<img
|
||||||
src={url}
|
src={url}
|
||||||
alt=""
|
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'}
|
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>
|
</div>
|
||||||
</SwiperSlide>
|
</SwiperSlide>
|
||||||
))}
|
))}
|
||||||
|
|
|
||||||
|
|
@ -23,7 +23,7 @@ import { LightboxIndicator } from '@/components/common';
|
||||||
function PCAlbumDetail() {
|
function PCAlbumDetail() {
|
||||||
const { name } = useParams();
|
const { name } = useParams();
|
||||||
const navigate = useNavigate();
|
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 [slideDirection, setSlideDirection] = useState(0);
|
||||||
const [imageLoaded, setImageLoaded] = useState(false);
|
const [imageLoaded, setImageLoaded] = useState(false);
|
||||||
const [preloadedImages] = useState(() => new Set());
|
const [preloadedImages] = useState(() => new Set());
|
||||||
|
|
@ -60,7 +60,7 @@ function PCAlbumDetail() {
|
||||||
|
|
||||||
// 라이트박스 열기
|
// 라이트박스 열기
|
||||||
const openLightbox = useCallback((images, index, options = {}) => {
|
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 }, '');
|
window.history.pushState({ lightbox: true }, '');
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
|
@ -387,9 +387,22 @@ function PCAlbumDetail() {
|
||||||
className="mt-10"
|
className="mt-10"
|
||||||
>
|
>
|
||||||
{(() => {
|
{(() => {
|
||||||
const allPhotos = Object.values(album.conceptPhotos).flat();
|
// 컨셉 정보를 포함한 사진 배열 생성
|
||||||
const previewPhotos = allPhotos.slice(0, 4);
|
const allPhotosWithInfo = [];
|
||||||
const totalCount = allPhotos.length;
|
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 (
|
return (
|
||||||
<>
|
<>
|
||||||
|
|
@ -406,11 +419,17 @@ function PCAlbumDetail() {
|
||||||
{previewPhotos.map((photo, idx) => (
|
{previewPhotos.map((photo, idx) => (
|
||||||
<div
|
<div
|
||||||
key={photo.id}
|
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"
|
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
|
<img
|
||||||
src={photo.medium_url}
|
src={photo.mediumUrl}
|
||||||
alt={`컨셉 포토 ${idx + 1}`}
|
alt={`컨셉 포토 ${idx + 1}`}
|
||||||
loading="lazy"
|
loading="lazy"
|
||||||
className="w-full h-full object-cover"
|
className="w-full h-full object-cover"
|
||||||
|
|
@ -506,6 +525,37 @@ function PCAlbumDetail() {
|
||||||
transition={{ duration: 0.25, ease: 'easeOut' }}
|
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>
|
</div>
|
||||||
|
|
||||||
{/* 다음 버튼 */}
|
{/* 다음 버튼 */}
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue