refactor(lightbox): PC AlbumDetail에서 공통 Lightbox 컴포넌트 사용
- 공통 Lightbox 컴포넌트에 비디오 지원 추가 (teasers prop) - 사진 메타데이터 표시 지원 (photos prop으로 컨셉/멤버 정보) - showCounter, showDownload props 추가 - PC AlbumDetail의 인라인 라이트박스 코드 제거 (-285줄) - 코드 중복 제거 및 유지보수성 향상 Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
97d6148280
commit
6cbe4fe6e2
2 changed files with 151 additions and 285 deletions
|
|
@ -1,13 +1,36 @@
|
||||||
import { useState, useEffect, useCallback, memo } from 'react';
|
import { useState, useEffect, useCallback, memo } from 'react';
|
||||||
import { motion, AnimatePresence } from 'framer-motion';
|
import { motion, AnimatePresence } from 'framer-motion';
|
||||||
import { X, ChevronLeft, ChevronRight } from 'lucide-react';
|
import { X, ChevronLeft, ChevronRight, Download } from 'lucide-react';
|
||||||
import LightboxIndicator from './LightboxIndicator';
|
import LightboxIndicator from './LightboxIndicator';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 라이트박스 공통 컴포넌트
|
* 라이트박스 공통 컴포넌트
|
||||||
* 이미지 갤러리를 전체 화면으로 표시
|
* 이미지/비디오 갤러리를 전체 화면으로 표시
|
||||||
|
*
|
||||||
|
* @param {string[]} images - 이미지/비디오 URL 배열
|
||||||
|
* @param {Object[]} photos - 메타데이터 포함 사진 배열 (선택적)
|
||||||
|
* @param {string} photos[].title - 컨셉 이름
|
||||||
|
* @param {string} photos[].members - 멤버 이름 (쉼표 구분)
|
||||||
|
* @param {Object[]} teasers - 티저 정보 배열 (비디오 여부 확인용)
|
||||||
|
* @param {string} teasers[].media_type - 'video' 또는 'image'
|
||||||
|
* @param {number} currentIndex - 현재 인덱스
|
||||||
|
* @param {boolean} isOpen - 열림 상태
|
||||||
|
* @param {function} onClose - 닫기 콜백
|
||||||
|
* @param {function} onIndexChange - 인덱스 변경 콜백
|
||||||
|
* @param {boolean} showCounter - 카운터 표시 여부 (기본: true)
|
||||||
|
* @param {boolean} showDownload - 다운로드 버튼 표시 여부 (기본: true)
|
||||||
*/
|
*/
|
||||||
function Lightbox({ images, currentIndex, isOpen, onClose, onIndexChange }) {
|
function Lightbox({
|
||||||
|
images,
|
||||||
|
photos,
|
||||||
|
teasers,
|
||||||
|
currentIndex,
|
||||||
|
isOpen,
|
||||||
|
onClose,
|
||||||
|
onIndexChange,
|
||||||
|
showCounter = true,
|
||||||
|
showDownload = true,
|
||||||
|
}) {
|
||||||
const [imageLoaded, setImageLoaded] = useState(false);
|
const [imageLoaded, setImageLoaded] = useState(false);
|
||||||
const [slideDirection, setSlideDirection] = useState(0);
|
const [slideDirection, setSlideDirection] = useState(0);
|
||||||
|
|
||||||
|
|
@ -36,6 +59,27 @@ function Lightbox({ images, currentIndex, isOpen, onClose, onIndexChange }) {
|
||||||
[currentIndex, onIndexChange]
|
[currentIndex, onIndexChange]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// 이미지 다운로드
|
||||||
|
const downloadImage = useCallback(async () => {
|
||||||
|
const imageUrl = images[currentIndex];
|
||||||
|
if (!imageUrl) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(imageUrl);
|
||||||
|
const blob = await response.blob();
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const link = document.createElement('a');
|
||||||
|
link.href = url;
|
||||||
|
link.download = `image_${currentIndex + 1}.jpg`;
|
||||||
|
document.body.appendChild(link);
|
||||||
|
link.click();
|
||||||
|
document.body.removeChild(link);
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('이미지 다운로드 실패:', error);
|
||||||
|
}
|
||||||
|
}, [images, currentIndex]);
|
||||||
|
|
||||||
// 라이트박스 열릴 때 body 스크롤 숨기기
|
// 라이트박스 열릴 때 body 스크롤 숨기기
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isOpen) {
|
if (isOpen) {
|
||||||
|
|
@ -80,6 +124,13 @@ function Lightbox({ images, currentIndex, isOpen, onClose, onIndexChange }) {
|
||||||
setImageLoaded(false);
|
setImageLoaded(false);
|
||||||
}, [currentIndex]);
|
}, [currentIndex]);
|
||||||
|
|
||||||
|
// 현재 사진의 메타데이터
|
||||||
|
const currentPhoto = photos?.[currentIndex];
|
||||||
|
const photoTitle = currentPhoto?.title;
|
||||||
|
const hasValidTitle = photoTitle && photoTitle.trim() && photoTitle !== 'Default';
|
||||||
|
const photoMembers = currentPhoto?.members;
|
||||||
|
const hasMembers = photoMembers && String(photoMembers).trim();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AnimatePresence>
|
<AnimatePresence>
|
||||||
{isOpen && images.length > 0 && (
|
{isOpen && images.length > 0 && (
|
||||||
|
|
@ -96,15 +147,36 @@ function Lightbox({ images, currentIndex, isOpen, onClose, onIndexChange }) {
|
||||||
onClick={onClose}
|
onClick={onClose}
|
||||||
>
|
>
|
||||||
{/* 내부 컨테이너 */}
|
{/* 내부 컨테이너 */}
|
||||||
<div className="min-w-[800px] min-h-[600px] w-full h-full relative flex items-center justify-center">
|
<div className="min-w-[1400px] min-h-[1200px] w-full h-full relative flex items-center justify-center">
|
||||||
{/* 닫기 버튼 */}
|
{/* 카운터 */}
|
||||||
<button
|
{showCounter && images.length > 1 && (
|
||||||
aria-label="닫기"
|
<div className="absolute top-6 left-6 text-white/70 text-sm z-10">
|
||||||
className="absolute top-6 right-6 text-white/70 hover:text-white transition-colors z-10"
|
{currentIndex + 1} / {images.length}
|
||||||
onClick={onClose}
|
</div>
|
||||||
>
|
)}
|
||||||
<X size={32} aria-hidden="true" />
|
|
||||||
</button>
|
{/* 상단 버튼들 */}
|
||||||
|
<div className="absolute top-6 right-6 flex gap-3 z-10">
|
||||||
|
{showDownload && (
|
||||||
|
<button
|
||||||
|
aria-label="다운로드"
|
||||||
|
className="text-white/70 hover:text-white transition-colors"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
downloadImage();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Download size={28} aria-hidden="true" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
aria-label="닫기"
|
||||||
|
className="text-white/70 hover:text-white transition-colors"
|
||||||
|
onClick={onClose}
|
||||||
|
>
|
||||||
|
<X size={32} aria-hidden="true" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* 이전 버튼 */}
|
{/* 이전 버튼 */}
|
||||||
{images.length > 1 && (
|
{images.length > 1 && (
|
||||||
|
|
@ -127,21 +199,60 @@ function Lightbox({ images, currentIndex, isOpen, onClose, onIndexChange }) {
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* 이미지 */}
|
{/* 이미지/비디오 + 메타데이터 */}
|
||||||
<div className="flex flex-col items-center mx-24">
|
<div className="flex flex-col items-center mx-24">
|
||||||
<motion.img
|
{teasers?.[currentIndex]?.media_type === 'video' ? (
|
||||||
key={currentIndex}
|
<motion.video
|
||||||
src={images[currentIndex]}
|
key={currentIndex}
|
||||||
alt={`이미지 ${currentIndex + 1}`}
|
src={images[currentIndex]}
|
||||||
className={`max-w-[90vw] max-h-[85vh] object-contain transition-opacity duration-200 ${
|
className={`max-w-[1100px] max-h-[900px] object-contain transition-opacity duration-200 ${
|
||||||
imageLoaded ? 'opacity-100' : 'opacity-0'
|
imageLoaded ? 'opacity-100' : 'opacity-0'
|
||||||
}`}
|
}`}
|
||||||
onClick={(e) => e.stopPropagation()}
|
onClick={(e) => e.stopPropagation()}
|
||||||
onLoad={() => setImageLoaded(true)}
|
onCanPlay={() => setImageLoaded(true)}
|
||||||
initial={{ x: slideDirection * 100 }}
|
initial={{ x: slideDirection * 100 }}
|
||||||
animate={{ x: 0 }}
|
animate={{ x: 0 }}
|
||||||
transition={{ duration: 0.25, ease: 'easeOut' }}
|
transition={{ duration: 0.25, ease: 'easeOut' }}
|
||||||
/>
|
controls
|
||||||
|
autoPlay
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<motion.img
|
||||||
|
key={currentIndex}
|
||||||
|
src={images[currentIndex]}
|
||||||
|
alt={`이미지 ${currentIndex + 1}`}
|
||||||
|
className={`max-w-[1100px] max-h-[900px] object-contain transition-opacity duration-200 ${
|
||||||
|
imageLoaded ? 'opacity-100' : 'opacity-0'
|
||||||
|
}`}
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
onLoad={() => setImageLoaded(true)}
|
||||||
|
initial={{ x: slideDirection * 100 }}
|
||||||
|
animate={{ x: 0 }}
|
||||||
|
transition={{ duration: 0.25, ease: 'easeOut' }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 컨셉/멤버 정보 */}
|
||||||
|
{imageLoaded && (hasValidTitle || hasMembers) && (
|
||||||
|
<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">
|
||||||
|
{photoTitle}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{hasMembers && (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{String(photoMembers)
|
||||||
|
.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>
|
||||||
|
|
||||||
{/* 다음 버튼 */}
|
{/* 다음 버튼 */}
|
||||||
|
|
|
||||||
|
|
@ -2,20 +2,10 @@ import { useState, useEffect, useCallback } from 'react';
|
||||||
import { useParams, useNavigate } from 'react-router-dom';
|
import { useParams, useNavigate } from 'react-router-dom';
|
||||||
import { useQuery } from '@tanstack/react-query';
|
import { useQuery } from '@tanstack/react-query';
|
||||||
import { motion, AnimatePresence } from 'framer-motion';
|
import { motion, AnimatePresence } from 'framer-motion';
|
||||||
import {
|
import { Calendar, Music2, Clock, X, MoreVertical, FileText } from 'lucide-react';
|
||||||
Calendar,
|
|
||||||
Music2,
|
|
||||||
Clock,
|
|
||||||
X,
|
|
||||||
ChevronLeft,
|
|
||||||
ChevronRight,
|
|
||||||
Download,
|
|
||||||
MoreVertical,
|
|
||||||
FileText,
|
|
||||||
} from 'lucide-react';
|
|
||||||
import { getAlbumByName } from '@/api/albums';
|
import { getAlbumByName } from '@/api/albums';
|
||||||
import { formatDate, calculateTotalDuration } from '@/utils';
|
import { formatDate, calculateTotalDuration } from '@/utils';
|
||||||
import { LightboxIndicator } from '@/components/common';
|
import { Lightbox } from '@/components/common';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* PC 앨범 상세 페이지
|
* PC 앨범 상세 페이지
|
||||||
|
|
@ -24,9 +14,6 @@ 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, photos: 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());
|
|
||||||
const [showDescriptionModal, setShowDescriptionModal] = useState(false);
|
const [showDescriptionModal, setShowDescriptionModal] = useState(false);
|
||||||
const [showMenu, setShowMenu] = useState(false);
|
const [showMenu, setShowMenu] = useState(false);
|
||||||
|
|
||||||
|
|
@ -37,37 +24,12 @@ function PCAlbumDetail() {
|
||||||
enabled: !!name,
|
enabled: !!name,
|
||||||
});
|
});
|
||||||
|
|
||||||
// 라이트박스 네비게이션
|
|
||||||
const goToPrev = useCallback(() => {
|
|
||||||
if (lightbox.images.length <= 1) return;
|
|
||||||
setImageLoaded(false);
|
|
||||||
setSlideDirection(-1);
|
|
||||||
setLightbox((prev) => ({
|
|
||||||
...prev,
|
|
||||||
index: (prev.index - 1 + prev.images.length) % prev.images.length,
|
|
||||||
}));
|
|
||||||
}, [lightbox.images.length]);
|
|
||||||
|
|
||||||
const goToNext = useCallback(() => {
|
|
||||||
if (lightbox.images.length <= 1) return;
|
|
||||||
setImageLoaded(false);
|
|
||||||
setSlideDirection(1);
|
|
||||||
setLightbox((prev) => ({
|
|
||||||
...prev,
|
|
||||||
index: (prev.index + 1) % prev.images.length,
|
|
||||||
}));
|
|
||||||
}, [lightbox.images.length]);
|
|
||||||
|
|
||||||
// 라이트박스 열기
|
// 라이트박스 열기
|
||||||
const openLightbox = useCallback((images, index, options = {}) => {
|
const openLightbox = useCallback((images, index, options = {}) => {
|
||||||
setLightbox({ open: true, images, index, teasers: options.teasers, photos: options.photos || null });
|
setLightbox({ open: true, images, index, teasers: options.teasers, photos: options.photos || null });
|
||||||
window.history.pushState({ lightbox: true }, '');
|
window.history.pushState({ lightbox: true }, '');
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const closeLightbox = useCallback(() => {
|
|
||||||
setLightbox((prev) => ({ ...prev, open: false }));
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
// 뒤로가기 처리
|
// 뒤로가기 처리
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handlePopState = () => {
|
const handlePopState = () => {
|
||||||
|
|
@ -82,87 +44,6 @@ function PCAlbumDetail() {
|
||||||
return () => window.removeEventListener('popstate', handlePopState);
|
return () => window.removeEventListener('popstate', handlePopState);
|
||||||
}, [showDescriptionModal, lightbox.open]);
|
}, [showDescriptionModal, lightbox.open]);
|
||||||
|
|
||||||
// 라이트박스 열릴 때 body 스크롤 숨기기
|
|
||||||
useEffect(() => {
|
|
||||||
if (lightbox.open) {
|
|
||||||
document.documentElement.style.overflow = 'hidden';
|
|
||||||
document.body.style.overflow = 'hidden';
|
|
||||||
} else {
|
|
||||||
document.documentElement.style.overflow = '';
|
|
||||||
document.body.style.overflow = '';
|
|
||||||
}
|
|
||||||
return () => {
|
|
||||||
document.documentElement.style.overflow = '';
|
|
||||||
document.body.style.overflow = '';
|
|
||||||
};
|
|
||||||
}, [lightbox.open]);
|
|
||||||
|
|
||||||
// 이미지 다운로드
|
|
||||||
const downloadImage = useCallback(async () => {
|
|
||||||
const imageUrl = lightbox.images[lightbox.index];
|
|
||||||
if (!imageUrl) return;
|
|
||||||
|
|
||||||
try {
|
|
||||||
const response = await fetch(imageUrl);
|
|
||||||
const blob = await response.blob();
|
|
||||||
const url = window.URL.createObjectURL(blob);
|
|
||||||
const link = document.createElement('a');
|
|
||||||
link.href = url;
|
|
||||||
link.download = `fromis9_photo_${String(lightbox.index + 1).padStart(2, '0')}.webp`;
|
|
||||||
document.body.appendChild(link);
|
|
||||||
link.click();
|
|
||||||
document.body.removeChild(link);
|
|
||||||
window.URL.revokeObjectURL(url);
|
|
||||||
} catch (error) {
|
|
||||||
console.error('다운로드 오류:', error);
|
|
||||||
}
|
|
||||||
}, [lightbox.images, lightbox.index]);
|
|
||||||
|
|
||||||
// 키보드 이벤트 핸들러
|
|
||||||
useEffect(() => {
|
|
||||||
if (!lightbox.open) return;
|
|
||||||
|
|
||||||
const handleKeyDown = (e) => {
|
|
||||||
switch (e.key) {
|
|
||||||
case 'ArrowLeft':
|
|
||||||
goToPrev();
|
|
||||||
break;
|
|
||||||
case 'ArrowRight':
|
|
||||||
goToNext();
|
|
||||||
break;
|
|
||||||
case 'Escape':
|
|
||||||
window.history.back();
|
|
||||||
break;
|
|
||||||
default:
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
window.addEventListener('keydown', handleKeyDown);
|
|
||||||
return () => window.removeEventListener('keydown', handleKeyDown);
|
|
||||||
}, [lightbox.open, goToPrev, goToNext]);
|
|
||||||
|
|
||||||
// 이미지 프리로딩
|
|
||||||
useEffect(() => {
|
|
||||||
if (!lightbox.open || lightbox.images.length <= 1) return;
|
|
||||||
|
|
||||||
const indicesToPreload = [];
|
|
||||||
for (let offset = -2; offset <= 2; offset++) {
|
|
||||||
if (offset === 0) continue;
|
|
||||||
const idx = (lightbox.index + offset + lightbox.images.length) % lightbox.images.length;
|
|
||||||
indicesToPreload.push(idx);
|
|
||||||
}
|
|
||||||
|
|
||||||
indicesToPreload.forEach((idx) => {
|
|
||||||
const url = lightbox.images[idx];
|
|
||||||
if (preloadedImages.has(url)) return;
|
|
||||||
|
|
||||||
const img = new Image();
|
|
||||||
img.onload = () => preloadedImages.add(url);
|
|
||||||
img.src = url;
|
|
||||||
});
|
|
||||||
}, [lightbox.open, lightbox.index, lightbox.images, preloadedImages]);
|
|
||||||
|
|
||||||
// 총 재생 시간 계산
|
// 총 재생 시간 계산
|
||||||
const totalDuration = calculateTotalDuration(album?.tracks);
|
const totalDuration = calculateTotalDuration(album?.tracks);
|
||||||
|
|
||||||
|
|
@ -445,144 +326,18 @@ function PCAlbumDetail() {
|
||||||
</div>
|
</div>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
|
|
||||||
{/* 라이트박스 모달 */}
|
{/* 라이트박스 */}
|
||||||
<AnimatePresence>
|
<Lightbox
|
||||||
{lightbox.open && (
|
images={lightbox.images}
|
||||||
<motion.div
|
photos={lightbox.photos}
|
||||||
initial={{ opacity: 0 }}
|
teasers={lightbox.teasers}
|
||||||
animate={{ opacity: 1 }}
|
currentIndex={lightbox.index}
|
||||||
exit={{ opacity: 0 }}
|
isOpen={lightbox.open}
|
||||||
transition={{ duration: 0.2 }}
|
onClose={() => window.history.back()}
|
||||||
className="fixed inset-0 bg-black/95 z-50 overflow-scroll lightbox-no-scrollbar"
|
onIndexChange={(index) => setLightbox((prev) => ({ ...prev, index }))}
|
||||||
style={{ scrollbarWidth: 'none', msOverflowStyle: 'none' }}
|
showCounter={lightbox.images.length > 1}
|
||||||
>
|
showDownload
|
||||||
<div className="min-w-[1400px] min-h-[1200px] w-full h-full relative flex items-center justify-center">
|
/>
|
||||||
{/* 상단 버튼들 */}
|
|
||||||
<div className="absolute top-6 right-6 flex gap-3 z-10">
|
|
||||||
<button
|
|
||||||
className="text-white/70 hover:text-white transition-colors"
|
|
||||||
onClick={(e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
downloadImage();
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Download size={28} />
|
|
||||||
</button>
|
|
||||||
<button className="text-white/70 hover:text-white transition-colors" onClick={() => window.history.back()}>
|
|
||||||
<X size={32} />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 이전 버튼 */}
|
|
||||||
{lightbox.images.length > 1 && (
|
|
||||||
<button
|
|
||||||
className="absolute left-6 p-2 text-white/70 hover:text-white transition-colors z-10"
|
|
||||||
onClick={(e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
goToPrev();
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<ChevronLeft size={48} />
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* 로딩 스피너 */}
|
|
||||||
{!imageLoaded && (
|
|
||||||
<div className="absolute inset-0 flex items-center justify-center">
|
|
||||||
<div className="animate-spin rounded-full h-12 w-12 border-4 border-white border-t-transparent" />
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* 이미지 또는 비디오 */}
|
|
||||||
<div className="flex flex-col items-center mx-24">
|
|
||||||
{lightbox.teasers?.[lightbox.index]?.media_type === 'video' ? (
|
|
||||||
<motion.video
|
|
||||||
key={lightbox.index}
|
|
||||||
src={lightbox.images[lightbox.index]}
|
|
||||||
className={`max-w-[1100px] max-h-[900px] object-contain transition-opacity duration-200 ${
|
|
||||||
imageLoaded ? 'opacity-100' : 'opacity-0'
|
|
||||||
}`}
|
|
||||||
onClick={(e) => e.stopPropagation()}
|
|
||||||
onCanPlay={() => setImageLoaded(true)}
|
|
||||||
initial={{ x: slideDirection * 100 }}
|
|
||||||
animate={{ x: 0 }}
|
|
||||||
transition={{ duration: 0.25, ease: 'easeOut' }}
|
|
||||||
controls
|
|
||||||
autoPlay
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<motion.img
|
|
||||||
key={lightbox.index}
|
|
||||||
src={lightbox.images[lightbox.index]}
|
|
||||||
alt="확대 이미지"
|
|
||||||
className={`max-w-[1100px] max-h-[900px] object-contain transition-opacity duration-200 ${
|
|
||||||
imageLoaded ? 'opacity-100' : 'opacity-0'
|
|
||||||
}`}
|
|
||||||
onClick={(e) => e.stopPropagation()}
|
|
||||||
onLoad={() => setImageLoaded(true)}
|
|
||||||
initial={{ x: slideDirection * 100 }}
|
|
||||||
animate={{ x: 0 }}
|
|
||||||
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>
|
|
||||||
|
|
||||||
{/* 다음 버튼 */}
|
|
||||||
{lightbox.images.length > 1 && (
|
|
||||||
<button
|
|
||||||
className="absolute right-6 p-2 text-white/70 hover:text-white transition-colors z-10"
|
|
||||||
onClick={(e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
goToNext();
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<ChevronRight size={48} />
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* 인디케이터 */}
|
|
||||||
{lightbox.images.length > 1 && (
|
|
||||||
<LightboxIndicator
|
|
||||||
count={lightbox.images.length}
|
|
||||||
currentIndex={lightbox.index}
|
|
||||||
goToIndex={(i) => setLightbox((prev) => ({ ...prev, index: i }))}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</motion.div>
|
|
||||||
)}
|
|
||||||
</AnimatePresence>
|
|
||||||
|
|
||||||
{/* 앨범 소개 다이얼로그 */}
|
{/* 앨범 소개 다이얼로그 */}
|
||||||
<AnimatePresence>
|
<AnimatePresence>
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue