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 { motion, AnimatePresence } from 'framer-motion';
import { X, ChevronLeft, ChevronRight } from 'lucide-react';
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, currentIndex, isOpen, onClose, onIndexChange }) {
function Lightbox({
images,
photos,
teasers,
currentIndex,
isOpen,
onClose,
onIndexChange,
showCounter = true,
showDownload = true,
}) {
const [imageLoaded, setImageLoaded] = useState(false);
const [slideDirection, setSlideDirection] = useState(0);
@ -36,6 +59,27 @@ function Lightbox({ images, currentIndex, isOpen, onClose, 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
useEffect(() => {
if (isOpen) {
@ -80,6 +124,13 @@ function Lightbox({ images, currentIndex, isOpen, onClose, onIndexChange }) {
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 && (
@ -96,15 +147,36 @@ function Lightbox({ images, currentIndex, isOpen, onClose, onIndexChange }) {
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">
{/* 카운터 */}
{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="absolute top-6 right-6 text-white/70 hover:text-white transition-colors z-10"
className="text-white/70 hover:text-white transition-colors"
onClick={onClose}
>
<X size={32} aria-hidden="true" />
</button>
</div>
{/* 이전 버튼 */}
{images.length > 1 && (
@ -127,13 +199,29 @@ function Lightbox({ images, currentIndex, isOpen, onClose, onIndexChange }) {
</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-[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'
}`}
onClick={(e) => e.stopPropagation()}
@ -142,6 +230,29 @@ function Lightbox({ images, currentIndex, isOpen, onClose, onIndexChange }) {
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>
{/* 다음 버튼 */}

View file

@ -2,20 +2,10 @@ import { useState, useEffect, useCallback } from 'react';
import { useParams, useNavigate } from 'react-router-dom';
import { useQuery } from '@tanstack/react-query';
import { motion, AnimatePresence } from 'framer-motion';
import {
Calendar,
Music2,
Clock,
X,
ChevronLeft,
ChevronRight,
Download,
MoreVertical,
FileText,
} from 'lucide-react';
import { Calendar, Music2, Clock, X, MoreVertical, FileText } from 'lucide-react';
import { getAlbumByName } from '@/api/albums';
import { formatDate, calculateTotalDuration } from '@/utils';
import { LightboxIndicator } from '@/components/common';
import { Lightbox } from '@/components/common';
/**
* PC 앨범 상세 페이지
@ -24,9 +14,6 @@ function PCAlbumDetail() {
const { name } = useParams();
const navigate = useNavigate();
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 [showMenu, setShowMenu] = useState(false);
@ -37,37 +24,12 @@ function PCAlbumDetail() {
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 = {}) => {
setLightbox({ open: true, images, index, teasers: options.teasers, photos: options.photos || null });
window.history.pushState({ lightbox: true }, '');
}, []);
const closeLightbox = useCallback(() => {
setLightbox((prev) => ({ ...prev, open: false }));
}, []);
//
useEffect(() => {
const handlePopState = () => {
@ -82,87 +44,6 @@ function PCAlbumDetail() {
return () => window.removeEventListener('popstate', handlePopState);
}, [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);
@ -445,144 +326,18 @@ function PCAlbumDetail() {
</div>
</motion.div>
{/* 라이트박스 모달 */}
<AnimatePresence>
{lightbox.open && (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.2 }}
className="fixed inset-0 bg-black/95 z-50 overflow-scroll lightbox-no-scrollbar"
style={{ scrollbarWidth: 'none', msOverflowStyle: 'none' }}
>
<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}
{/* 라이트박스 */}
<Lightbox
images={lightbox.images}
photos={lightbox.photos}
teasers={lightbox.teasers}
currentIndex={lightbox.index}
goToIndex={(i) => setLightbox((prev) => ({ ...prev, index: i }))}
isOpen={lightbox.open}
onClose={() => window.history.back()}
onIndexChange={(index) => setLightbox((prev) => ({ ...prev, index }))}
showCounter={lightbox.images.length > 1}
showDownload
/>
)}
</div>
</motion.div>
)}
</AnimatePresence>
{/* 앨범 소개 다이얼로그 */}
<AnimatePresence>