From 6cbe4fe6e26e568b34afd56fa8f45a8b18185f55 Mon Sep 17 00:00:00 2001 From: caadiq Date: Thu, 22 Jan 2026 13:31:41 +0900 Subject: [PATCH] =?UTF-8?q?refactor(lightbox):=20PC=20AlbumDetail=EC=97=90?= =?UTF-8?q?=EC=84=9C=20=EA=B3=B5=ED=86=B5=20Lightbox=20=EC=BB=B4=ED=8F=AC?= =?UTF-8?q?=EB=84=8C=ED=8A=B8=20=EC=82=AC=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 공통 Lightbox 컴포넌트에 비디오 지원 추가 (teasers prop) - 사진 메타데이터 표시 지원 (photos prop으로 컨셉/멤버 정보) - showCounter, showDownload props 추가 - PC AlbumDetail의 인라인 라이트박스 코드 제거 (-285줄) - 코드 중복 제거 및 유지보수성 향상 Co-Authored-By: Claude Opus 4.5 --- .../src/components/common/Lightbox.jsx | 163 +++++++++-- .../src/pages/album/pc/AlbumDetail.jsx | 273 +----------------- 2 files changed, 151 insertions(+), 285 deletions(-) diff --git a/frontend-temp/src/components/common/Lightbox.jsx b/frontend-temp/src/components/common/Lightbox.jsx index 8a17f16..101be1c 100644 --- a/frontend-temp/src/components/common/Lightbox.jsx +++ b/frontend-temp/src/components/common/Lightbox.jsx @@ -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 ( {isOpen && images.length > 0 && ( @@ -96,15 +147,36 @@ function Lightbox({ images, currentIndex, isOpen, onClose, onIndexChange }) { onClick={onClose} > {/* 내부 컨테이너 */} -
- {/* 닫기 버튼 */} - +
+ {/* 카운터 */} + {showCounter && images.length > 1 && ( +
+ {currentIndex + 1} / {images.length} +
+ )} + + {/* 상단 버튼들 */} +
+ {showDownload && ( + + )} + +
{/* 이전 버튼 */} {images.length > 1 && ( @@ -127,21 +199,60 @@ function Lightbox({ images, currentIndex, isOpen, onClose, onIndexChange }) {
)} - {/* 이미지 */} + {/* 이미지/비디오 + 메타데이터 */}
- e.stopPropagation()} - onLoad={() => setImageLoaded(true)} - initial={{ x: slideDirection * 100 }} - animate={{ x: 0 }} - transition={{ duration: 0.25, ease: 'easeOut' }} - /> + {teasers?.[currentIndex]?.media_type === 'video' ? ( + e.stopPropagation()} + onCanPlay={() => setImageLoaded(true)} + initial={{ x: slideDirection * 100 }} + animate={{ x: 0 }} + transition={{ duration: 0.25, ease: 'easeOut' }} + controls + autoPlay + /> + ) : ( + e.stopPropagation()} + onLoad={() => setImageLoaded(true)} + initial={{ x: slideDirection * 100 }} + animate={{ x: 0 }} + transition={{ duration: 0.25, ease: 'easeOut' }} + /> + )} + + {/* 컨셉/멤버 정보 */} + {imageLoaded && (hasValidTitle || hasMembers) && ( +
+ {hasValidTitle && ( + + {photoTitle} + + )} + {hasMembers && ( +
+ {String(photoMembers) + .split(',') + .map((member, idx) => ( + + {member.trim()} + + ))} +
+ )} +
+ )}
{/* 다음 버튼 */} diff --git a/frontend-temp/src/pages/album/pc/AlbumDetail.jsx b/frontend-temp/src/pages/album/pc/AlbumDetail.jsx index 15028cf..8b4b7d8 100644 --- a/frontend-temp/src/pages/album/pc/AlbumDetail.jsx +++ b/frontend-temp/src/pages/album/pc/AlbumDetail.jsx @@ -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() {
- {/* 라이트박스 모달 */} - - {lightbox.open && ( - -
- {/* 상단 버튼들 */} -
- - -
- - {/* 이전 버튼 */} - {lightbox.images.length > 1 && ( - - )} - - {/* 로딩 스피너 */} - {!imageLoaded && ( -
-
-
- )} - - {/* 이미지 또는 비디오 */} -
- {lightbox.teasers?.[lightbox.index]?.media_type === 'video' ? ( - e.stopPropagation()} - onCanPlay={() => setImageLoaded(true)} - initial={{ x: slideDirection * 100 }} - animate={{ x: 0 }} - transition={{ duration: 0.25, ease: 'easeOut' }} - controls - autoPlay - /> - ) : ( - 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 ( -
- {hasValidTitle && ( - - {title} - - )} - {hasMembers && ( -
- {String(members) - .split(',') - .map((member, idx) => ( - - {member.trim()} - - ))} -
- )} -
- ); - })()} -
- - {/* 다음 버튼 */} - {lightbox.images.length > 1 && ( - - )} - - {/* 인디케이터 */} - {lightbox.images.length > 1 && ( - setLightbox((prev) => ({ ...prev, index: i }))} - /> - )} -
- - )} - + {/* 라이트박스 */} + window.history.back()} + onIndexChange={(index) => setLightbox((prev) => ({ ...prev, index }))} + showCounter={lightbox.images.length > 1} + showDownload + /> {/* 앨범 소개 다이얼로그 */}