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 { 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">
|
||||
{/* 닫기 버튼 */}
|
||||
<button
|
||||
aria-label="닫기"
|
||||
className="absolute top-6 right-6 text-white/70 hover:text-white transition-colors z-10"
|
||||
onClick={onClose}
|
||||
>
|
||||
<X size={32} aria-hidden="true" />
|
||||
</button>
|
||||
<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={onClose}
|
||||
>
|
||||
<X size={32} aria-hidden="true" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* 이전 버튼 */}
|
||||
{images.length > 1 && (
|
||||
|
|
@ -127,21 +199,60 @@ function Lightbox({ images, currentIndex, isOpen, onClose, onIndexChange }) {
|
|||
</div>
|
||||
)}
|
||||
|
||||
{/* 이미지 */}
|
||||
{/* 이미지/비디오 + 메타데이터 */}
|
||||
<div className="flex flex-col items-center mx-24">
|
||||
<motion.img
|
||||
key={currentIndex}
|
||||
src={images[currentIndex]}
|
||||
alt={`이미지 ${currentIndex + 1}`}
|
||||
className={`max-w-[90vw] max-h-[85vh] 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' }}
|
||||
/>
|
||||
{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>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 다음 버튼 */}
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
currentIndex={lightbox.index}
|
||||
goToIndex={(i) => setLightbox((prev) => ({ ...prev, index: i }))}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
{/* 라이트박스 */}
|
||||
<Lightbox
|
||||
images={lightbox.images}
|
||||
photos={lightbox.photos}
|
||||
teasers={lightbox.teasers}
|
||||
currentIndex={lightbox.index}
|
||||
isOpen={lightbox.open}
|
||||
onClose={() => window.history.back()}
|
||||
onIndexChange={(index) => setLightbox((prev) => ({ ...prev, index }))}
|
||||
showCounter={lightbox.images.length > 1}
|
||||
showDownload
|
||||
/>
|
||||
|
||||
{/* 앨범 소개 다이얼로그 */}
|
||||
<AnimatePresence>
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue