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:
caadiq 2026-01-22 13:31:41 +09:00
parent 97d6148280
commit 6cbe4fe6e2
2 changed files with 151 additions and 285 deletions

View file

@ -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>
{/* 다음 버튼 */} {/* 다음 버튼 */}

View file

@ -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>