2026-01-04 20:50:21 +09:00
|
|
|
import { useState, useEffect, useCallback, memo } from 'react';
|
|
|
|
|
import { motion, AnimatePresence } from 'framer-motion';
|
2026-01-23 10:29:30 +09:00
|
|
|
import { X, ChevronLeft, ChevronRight, Download } from 'lucide-react';
|
|
|
|
|
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,
|
|
|
|
|
photos,
|
|
|
|
|
teasers,
|
|
|
|
|
currentIndex,
|
|
|
|
|
isOpen,
|
|
|
|
|
onClose,
|
|
|
|
|
onIndexChange,
|
|
|
|
|
showCounter = true,
|
|
|
|
|
showDownload = true,
|
|
|
|
|
}) {
|
|
|
|
|
const [imageLoaded, setImageLoaded] = useState(false);
|
|
|
|
|
const [slideDirection, setSlideDirection] = useState(0);
|
|
|
|
|
|
|
|
|
|
// 이전/다음 네비게이션
|
|
|
|
|
const goToPrev = useCallback(() => {
|
|
|
|
|
if (images.length <= 1) return;
|
|
|
|
|
setImageLoaded(false);
|
|
|
|
|
setSlideDirection(-1);
|
|
|
|
|
onIndexChange((currentIndex - 1 + images.length) % images.length);
|
|
|
|
|
}, [images.length, currentIndex, onIndexChange]);
|
|
|
|
|
|
|
|
|
|
const goToNext = useCallback(() => {
|
|
|
|
|
if (images.length <= 1) return;
|
|
|
|
|
setImageLoaded(false);
|
|
|
|
|
setSlideDirection(1);
|
|
|
|
|
onIndexChange((currentIndex + 1) % images.length);
|
|
|
|
|
}, [images.length, currentIndex, onIndexChange]);
|
|
|
|
|
|
|
|
|
|
const goToIndex = useCallback(
|
|
|
|
|
(index) => {
|
|
|
|
|
if (index === currentIndex) return;
|
|
|
|
|
setImageLoaded(false);
|
|
|
|
|
setSlideDirection(index > currentIndex ? 1 : -1);
|
|
|
|
|
onIndexChange(index);
|
|
|
|
|
},
|
|
|
|
|
[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 스크롤 숨기기
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
if (isOpen) {
|
|
|
|
|
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 = '';
|
|
|
|
|
};
|
|
|
|
|
}, [isOpen]);
|
|
|
|
|
|
|
|
|
|
// 키보드 이벤트 핸들러
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
if (!isOpen) return;
|
|
|
|
|
|
|
|
|
|
const handleKeyDown = (e) => {
|
|
|
|
|
switch (e.key) {
|
|
|
|
|
case 'ArrowLeft':
|
|
|
|
|
goToPrev();
|
|
|
|
|
break;
|
|
|
|
|
case 'ArrowRight':
|
|
|
|
|
goToNext();
|
|
|
|
|
break;
|
|
|
|
|
case 'Escape':
|
|
|
|
|
onClose();
|
|
|
|
|
break;
|
|
|
|
|
default:
|
|
|
|
|
break;
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
window.addEventListener('keydown', handleKeyDown);
|
|
|
|
|
return () => window.removeEventListener('keydown', handleKeyDown);
|
|
|
|
|
}, [isOpen, goToPrev, goToNext, onClose]);
|
|
|
|
|
|
|
|
|
|
// 이미지가 바뀔 때 로딩 상태 리셋
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
setImageLoaded(false);
|
|
|
|
|
}, [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 (
|
|
|
|
|
<AnimatePresence>
|
|
|
|
|
{isOpen && images.length > 0 && (
|
|
|
|
|
<motion.div
|
|
|
|
|
role="dialog"
|
|
|
|
|
aria-modal="true"
|
|
|
|
|
aria-label="이미지 뷰어"
|
|
|
|
|
initial={{ opacity: 0 }}
|
|
|
|
|
animate={{ opacity: 1 }}
|
|
|
|
|
exit={{ opacity: 0 }}
|
|
|
|
|
transition={{ duration: 0.2 }}
|
|
|
|
|
className="fixed inset-0 bg-black/95 z-50 overflow-scroll"
|
|
|
|
|
style={{ scrollbarWidth: 'none', msOverflowStyle: 'none' }}
|
|
|
|
|
onClick={onClose}
|
|
|
|
|
>
|
|
|
|
|
{/* 내부 컨테이너 */}
|
|
|
|
|
<div className="min-w-[1400px] min-h-[1200px] w-full h-full relative flex items-center justify-center">
|
|
|
|
|
{/* 카운터 */}
|
|
|
|
|
{showCounter && images.length > 1 && (
|
|
|
|
|
<div className="absolute top-6 left-6 text-white/70 text-sm z-10">
|
|
|
|
|
{currentIndex + 1} / {images.length}
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
{/* 상단 버튼들 */}
|
|
|
|
|
<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={(e) => {
|
|
|
|
|
e.stopPropagation();
|
|
|
|
|
onClose();
|
2026-01-04 20:50:21 +09:00
|
|
|
}}
|
2026-01-23 10:29:30 +09:00
|
|
|
>
|
|
|
|
|
<X size={32} aria-hidden="true" />
|
|
|
|
|
</button>
|
2026-01-04 20:50:21 +09:00
|
|
|
</div>
|
2026-01-23 10:29:30 +09:00
|
|
|
|
|
|
|
|
{/* 이전 버튼 */}
|
|
|
|
|
{images.length > 1 && (
|
|
|
|
|
<button
|
|
|
|
|
aria-label="이전 이미지"
|
|
|
|
|
className="absolute left-6 p-2 text-white/70 hover:text-white transition-colors z-10"
|
|
|
|
|
onClick={(e) => {
|
|
|
|
|
e.stopPropagation();
|
|
|
|
|
goToPrev();
|
|
|
|
|
}}
|
|
|
|
|
>
|
|
|
|
|
<ChevronLeft size={48} aria-hidden="true" />
|
|
|
|
|
</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>
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
{/* 이미지/비디오 + 메타데이터 */}
|
|
|
|
|
<div className="flex flex-col items-center mx-24">
|
|
|
|
|
{teasers?.[currentIndex]?.media_type === 'video' ? (
|
|
|
|
|
<motion.video
|
|
|
|
|
key={currentIndex}
|
|
|
|
|
src={images[currentIndex]}
|
|
|
|
|
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={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>
|
|
|
|
|
))}
|
2026-01-04 20:50:21 +09:00
|
|
|
</div>
|
2026-01-23 10:29:30 +09:00
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
{/* 다음 버튼 */}
|
|
|
|
|
{images.length > 1 && (
|
|
|
|
|
<button
|
|
|
|
|
aria-label="다음 이미지"
|
|
|
|
|
className="absolute right-6 p-2 text-white/70 hover:text-white transition-colors z-10"
|
|
|
|
|
onClick={(e) => {
|
|
|
|
|
e.stopPropagation();
|
|
|
|
|
goToNext();
|
|
|
|
|
}}
|
|
|
|
|
>
|
|
|
|
|
<ChevronRight size={48} aria-hidden="true" />
|
|
|
|
|
</button>
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
{/* 인디케이터 */}
|
|
|
|
|
{images.length > 1 && (
|
|
|
|
|
<LightboxIndicator
|
|
|
|
|
count={images.length}
|
|
|
|
|
currentIndex={currentIndex}
|
|
|
|
|
goToIndex={goToIndex}
|
|
|
|
|
/>
|
2026-01-04 20:50:21 +09:00
|
|
|
)}
|
2026-01-23 10:29:30 +09:00
|
|
|
</div>
|
|
|
|
|
</motion.div>
|
|
|
|
|
)}
|
|
|
|
|
</AnimatePresence>
|
|
|
|
|
);
|
2026-01-04 20:50:21 +09:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export default Lightbox;
|