diff --git a/frontend-temp/src/components/common/MobileLightbox.jsx b/frontend-temp/src/components/common/MobileLightbox.jsx new file mode 100644 index 0000000..eabec8a --- /dev/null +++ b/frontend-temp/src/components/common/MobileLightbox.jsx @@ -0,0 +1,264 @@ +import { useState, useEffect, useCallback, useRef } from 'react'; +import { motion, AnimatePresence } from 'framer-motion'; +import { X, Download, Info, Users, Tag } from 'lucide-react'; +import { Swiper, SwiperSlide } from 'swiper/react'; +import { Virtual } from 'swiper/modules'; +import 'swiper/css'; +import LightboxIndicator from './LightboxIndicator'; + +/** + * 모바일 라이트박스 공통 컴포넌트 + * Swiper 기반 터치 스와이프 지원 + * + * @param {string[]} images - 이미지/비디오 URL 배열 + * @param {Object[]} photos - 메타데이터 포함 사진 배열 (선택적) + * @param {string} photos[].concept - 컨셉 이름 + * @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) + * @param {string} downloadPrefix - 다운로드 파일명 접두사 (기본: 'fromis9_photo') + */ +function MobileLightbox({ + images, + photos, + teasers, + currentIndex, + isOpen, + onClose, + onIndexChange, + showCounter = true, + showDownload = true, + downloadPrefix = 'fromis9_photo', +}) { + const [showInfo, setShowInfo] = useState(false); + const swiperRef = useRef(null); + + // 현재 사진 정보 + const currentPhoto = photos?.[currentIndex]; + const concept = currentPhoto?.concept || currentPhoto?.title; + const hasValidConcept = concept && concept.trim() && concept !== 'Default'; + const members = currentPhoto?.members; + const hasMembers = members && String(members).trim(); + const hasPhotoInfo = hasValidConcept || hasMembers; + + // 정보 시트 열기 + const openInfo = useCallback(() => { + setShowInfo(true); + window.history.pushState({ infoSheet: true }, ''); + }, []); + + // 이미지 다운로드 + const downloadImage = useCallback(async () => { + const imageUrl = images[currentIndex]; + 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 = `${downloadPrefix}_${String(currentIndex + 1).padStart(2, '0')}.webp`; + document.body.appendChild(link); + link.click(); + document.body.removeChild(link); + window.URL.revokeObjectURL(url); + } catch (error) { + console.error('다운로드 오류:', error); + } + }, [images, currentIndex, downloadPrefix]); + + // 바디 스크롤 방지 + useEffect(() => { + if (isOpen) { + document.body.style.overflow = 'hidden'; + } else { + document.body.style.overflow = ''; + } + return () => { + document.body.style.overflow = ''; + }; + }, [isOpen]); + + // 뒤로가기 처리 (정보 시트) + useEffect(() => { + if (!isOpen) return; + + const handlePopState = () => { + if (showInfo) { + setShowInfo(false); + } else { + onClose(); + } + }; + + window.addEventListener('popstate', handlePopState); + return () => window.removeEventListener('popstate', handlePopState); + }, [isOpen, showInfo, onClose]); + + // 라이트박스 닫힐 때 정보 시트도 닫기 + useEffect(() => { + if (!isOpen) { + setShowInfo(false); + } + }, [isOpen]); + + if (!isOpen || !images?.length) return null; + + return ( + + + {/* 상단 헤더 */} + + + window.history.back()} className="text-white/80 p-1"> + + + + {showCounter && images.length > 1 && ( + + {currentIndex + 1} / {images.length} + + )} + + {hasPhotoInfo && ( + + + + )} + {showDownload && ( + + + + )} + + + + {/* Swiper */} + { + swiperRef.current = swiper; + }} + onSlideChange={(swiper) => onIndexChange(swiper.activeIndex)} + className="w-full h-full" + spaceBetween={0} + slidesPerView={1} + resistance={true} + resistanceRatio={0.5} + > + {images.map((url, index) => ( + + + {teasers?.[index]?.media_type === 'video' ? ( + + ) : ( + + )} + + + ))} + + + {/* 인디케이터 */} + {images.length > 1 && ( + swiperRef.current?.slideTo(i)} + width={120} + /> + )} + + {/* 사진 정보 바텀시트 */} + + {showInfo && hasPhotoInfo && ( + window.history.back()} + > + { + if (info.offset.y > 100 || info.velocity.y > 300) { + window.history.back(); + } + }} + className="absolute bottom-0 left-0 right-0 bg-zinc-900 rounded-t-3xl" + onClick={(e) => e.stopPropagation()} + > + {/* 드래그 핸들 */} + + + + + {/* 정보 내용 */} + + 사진 정보 + + {hasMembers && ( + + + + + + 멤버 + {members} + + + )} + + {hasValidConcept && ( + + + + + + 컨셉 + {concept} + + + )} + + + + )} + + + + ); +} + +export default MobileLightbox; diff --git a/frontend-temp/src/components/common/index.js b/frontend-temp/src/components/common/index.js index 137ad55..273a39a 100644 --- a/frontend-temp/src/components/common/index.js +++ b/frontend-temp/src/components/common/index.js @@ -4,4 +4,5 @@ export { default as Toast } from './Toast'; export { default as Tooltip } from './Tooltip'; export { default as ScrollToTop } from './ScrollToTop'; export { default as Lightbox } from './Lightbox'; +export { default as MobileLightbox } from './MobileLightbox'; export { default as LightboxIndicator } from './LightboxIndicator'; diff --git a/frontend-temp/src/pages/album/mobile/AlbumDetail.jsx b/frontend-temp/src/pages/album/mobile/AlbumDetail.jsx index c956e50..149ccc0 100644 --- a/frontend-temp/src/pages/album/mobile/AlbumDetail.jsx +++ b/frontend-temp/src/pages/album/mobile/AlbumDetail.jsx @@ -1,4 +1,4 @@ -import { useState, useEffect, useCallback, useRef } from 'react'; +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'; @@ -8,21 +8,14 @@ import { Music2, Clock, X, - Download, ChevronDown, ChevronUp, FileText, ChevronRight, - Info, - Users, - Tag, } from 'lucide-react'; -import { Swiper, SwiperSlide } from 'swiper/react'; -import { Virtual } from 'swiper/modules'; -import 'swiper/css'; import { getAlbumByName } from '@/api/albums'; import { formatDate, calculateTotalDuration } from '@/utils'; -import { LightboxIndicator } from '@/components/common'; +import { MobileLightbox } from '@/components/common'; /** * Mobile 앨범 상세 페이지 @@ -30,11 +23,9 @@ import { LightboxIndicator } from '@/components/common'; function MobileAlbumDetail() { const { name } = useParams(); const navigate = useNavigate(); - const [lightbox, setLightbox] = useState({ open: false, images: [], index: 0, showNav: true, teasers: null, photos: null }); + const [lightbox, setLightbox] = useState({ open: false, images: [], index: 0, teasers: null, photos: null }); const [showAllTracks, setShowAllTracks] = useState(false); const [showDescriptionModal, setShowDescriptionModal] = useState(false); - const [showPhotoInfo, setShowPhotoInfo] = useState(false); - const swiperRef = useRef(null); // 앨범 데이터 로드 const { data: album, isLoading: loading } = useQuery({ @@ -49,8 +40,7 @@ function MobileAlbumDetail() { open: true, images, index, - showNav: options.showNav !== false, - teasers: options.teasers, + teasers: options.teasers || null, photos: options.photos || null, }); window.history.pushState({ lightbox: true }, ''); @@ -62,52 +52,21 @@ function MobileAlbumDetail() { window.history.pushState({ description: true }, ''); }, []); - // 사진 정보 바텀시트 열기 - const openPhotoInfo = useCallback(() => { - setShowPhotoInfo(true); - window.history.pushState({ photoInfo: true }, ''); - }, []); - - // 뒤로가기 처리 + // 뒤로가기 처리 (앨범 소개만 - 라이트박스는 MobileLightbox에서 처리) useEffect(() => { const handlePopState = () => { - if (showPhotoInfo) { - setShowPhotoInfo(false); - } else if (showDescriptionModal) { + if (showDescriptionModal) { setShowDescriptionModal(false); - } else if (lightbox.open) { - setLightbox((prev) => ({ ...prev, open: false })); } }; window.addEventListener('popstate', handlePopState); return () => window.removeEventListener('popstate', handlePopState); - }, [showPhotoInfo, showDescriptionModal, lightbox.open]); + }, [showDescriptionModal]); - // 이미지 다운로드 - 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]); - - // 라이트박스/모달 body 스크롤 방지 + // 앨범 소개 모달 body 스크롤 방지 (라이트박스는 MobileLightbox에서 처리) useEffect(() => { - if (lightbox.open || showDescriptionModal) { + if (showDescriptionModal) { document.body.style.overflow = 'hidden'; } else { document.body.style.overflow = ''; @@ -115,7 +74,7 @@ function MobileAlbumDetail() { return () => { document.body.style.overflow = ''; }; - }, [lightbox.open, showDescriptionModal]); + }, [showDescriptionModal]); // 총 재생 시간 계산 const totalDuration = calculateTotalDuration(album?.tracks); @@ -146,7 +105,7 @@ function MobileAlbumDetail() { originalUrl: p.original_url, mediumUrl: p.medium_url, thumbUrl: p.thumb_url, - title: concept, + concept: concept !== 'Default' ? concept : null, members: p.members || '', }) ); @@ -175,7 +134,7 @@ function MobileAlbumDetail() { initial={{ opacity: 0, y: 20 }} animate={{ opacity: 1, y: 0 }} className="w-44 h-44 rounded-2xl overflow-hidden shadow-xl mb-4" - onClick={() => openLightbox([album.cover_original_url || album.cover_medium_url], 0, { showNav: false })} + onClick={() => openLightbox([album.cover_original_url || album.cover_medium_url], 0)} > @@ -235,7 +194,7 @@ function MobileAlbumDetail() { openLightbox( album.teasers.map((t) => (t.media_type === 'video' ? t.video_url || t.original_url : t.original_url)), index, - { teasers: album.teasers, showNav: true } + { teasers: album.teasers } ) } className="w-24 h-24 flex-shrink-0 bg-gray-100 rounded-2xl overflow-hidden relative shadow-sm" @@ -322,7 +281,7 @@ function MobileAlbumDetail() { openLightbox( previewPhotos.map((p) => p.originalUrl), idx, - { showNav: true, photos: previewPhotos } + { photos: previewPhotos } ) } className="aspect-square bg-gray-100 rounded-xl overflow-hidden shadow-sm" @@ -394,162 +353,18 @@ function MobileAlbumDetail() { )} - {/* 라이트박스 - Swiper ViewPager 스타일 */} - - {lightbox.open && (() => { - const currentPhoto = lightbox.photos?.[lightbox.index]; - const concept = currentPhoto?.title; - const hasValidConcept = concept && concept.trim() && concept !== 'Default'; - const members = currentPhoto?.members; - const hasMembers = members && String(members).trim(); - const hasPhotoInfo = hasValidConcept || hasMembers; - - return ( - - {/* 상단 헤더 */} - - - window.history.back()} className="text-white/80 p-1"> - - - - {lightbox.showNav && lightbox.images.length > 1 && ( - - {lightbox.index + 1} / {lightbox.images.length} - - )} - - {hasPhotoInfo && ( - - - - )} - - - - - - - {/* Swiper */} - { - swiperRef.current = swiper; - }} - onSlideChange={(swiper) => setLightbox((prev) => ({ ...prev, index: swiper.activeIndex }))} - className="w-full h-full" - spaceBetween={0} - slidesPerView={1} - resistance={true} - resistanceRatio={0.5} - > - {lightbox.images.map((url, index) => ( - - - {lightbox.teasers?.[index]?.media_type === 'video' ? ( - - ) : ( - - )} - - - ))} - - - {/* 모바일용 인디케이터 */} - {lightbox.showNav && lightbox.images.length > 1 && ( - swiperRef.current?.slideTo(i)} - width={120} - /> - )} - - {/* 사진 정보 바텀시트 */} - - {showPhotoInfo && hasPhotoInfo && ( - window.history.back()} - > - { - if (info.offset.y > 100 || info.velocity.y > 300) { - window.history.back(); - } - }} - className="absolute bottom-0 left-0 right-0 bg-zinc-900 rounded-t-3xl" - onClick={(e) => e.stopPropagation()} - > - {/* 드래그 핸들 */} - - - - - {/* 정보 내용 */} - - 사진 정보 - - {hasMembers && ( - - - - - - 멤버 - {members} - - - )} - - {hasValidConcept && ( - - - - - - 컨셉 - {concept} - - - )} - - - - )} - - - ); - })()} - + {/* 라이트박스 */} + setLightbox((prev) => ({ ...prev, open: false }))} + onIndexChange={(index) => setLightbox((prev) => ({ ...prev, index }))} + showCounter={lightbox.images.length > 1} + downloadPrefix={`fromis9_${album?.title || 'photo'}`} + /> > ); } diff --git a/frontend-temp/src/pages/album/mobile/AlbumGallery.jsx b/frontend-temp/src/pages/album/mobile/AlbumGallery.jsx index 828bb53..62b0f7b 100644 --- a/frontend-temp/src/pages/album/mobile/AlbumGallery.jsx +++ b/frontend-temp/src/pages/album/mobile/AlbumGallery.jsx @@ -1,13 +1,10 @@ -import { useState, useEffect, useCallback, useRef, useMemo } from 'react'; +import { useState, useCallback, useMemo } from 'react'; import { useParams, useNavigate } from 'react-router-dom'; import { useQuery } from '@tanstack/react-query'; -import { X, Download, ChevronRight, Info, Users, Tag } from 'lucide-react'; -import { motion, AnimatePresence } from 'framer-motion'; -import { Swiper, SwiperSlide } from 'swiper/react'; -import { Virtual } from 'swiper/modules'; -import 'swiper/css'; +import { ChevronRight } from 'lucide-react'; +import { motion } from 'framer-motion'; import { getAlbumByName } from '@/api/albums'; -import { LightboxIndicator } from '@/components/common'; +import { MobileLightbox } from '@/components/common'; /** * Mobile 앨범 갤러리 페이지 @@ -16,8 +13,6 @@ function MobileAlbumGallery() { const { name } = useParams(); const navigate = useNavigate(); const [selectedIndex, setSelectedIndex] = useState(null); - const [showInfo, setShowInfo] = useState(false); - const swiperRef = useRef(null); // 앨범 데이터 로드 const { data: album, isLoading: loading } = useQuery({ @@ -47,59 +42,6 @@ function MobileAlbumGallery() { window.history.pushState({ lightbox: true }, ''); }, []); - // 정보 시트 열기 - const openInfo = useCallback(() => { - setShowInfo(true); - window.history.pushState({ infoSheet: true }, ''); - }, []); - - // 뒤로가기 처리 - useEffect(() => { - const handlePopState = () => { - if (showInfo) { - setShowInfo(false); - } else if (selectedIndex !== null) { - setSelectedIndex(null); - } - }; - - window.addEventListener('popstate', handlePopState); - return () => window.removeEventListener('popstate', handlePopState); - }, [showInfo, selectedIndex]); - - // 이미지 다운로드 - const downloadImage = useCallback(async () => { - const photo = photos[selectedIndex]; - if (!photo) return; - - try { - const response = await fetch(photo.original_url); - const blob = await response.blob(); - const url = window.URL.createObjectURL(blob); - const link = document.createElement('a'); - link.href = url; - link.download = `fromis9_${album?.title || 'photo'}_${String(selectedIndex + 1).padStart(2, '0')}.webp`; - document.body.appendChild(link); - link.click(); - document.body.removeChild(link); - window.URL.revokeObjectURL(url); - } catch (error) { - console.error('다운로드 오류:', error); - } - }, [photos, selectedIndex, album?.title]); - - // 바디 스크롤 방지 - useEffect(() => { - if (selectedIndex !== null) { - document.body.style.overflow = 'hidden'; - } else { - document.body.style.overflow = ''; - } - return () => { - document.body.style.overflow = ''; - }; - }, [selectedIndex]); - // 사진을 2열로 균등 분배 (높이 기반) const distributePhotos = () => { const leftColumn = []; @@ -124,17 +66,6 @@ function MobileAlbumGallery() { const { leftColumn, rightColumn } = distributePhotos(); - // 현재 사진 정보 - const currentPhoto = selectedIndex !== null ? photos[selectedIndex] : null; - const hasInfo = currentPhoto?.concept || currentPhoto?.members; - - // 정보 시트 드래그 핸들러 - const handleInfoDragEnd = (_, info) => { - if (info.offset.y > 100 || info.velocity.y > 300) { - window.history.back(); - } - }; - if (loading) { return ( @@ -209,136 +140,17 @@ function MobileAlbumGallery() { - {/* 풀스크린 라이트박스 */} - - {selectedIndex !== null && ( - - {/* 상단 헤더 */} - - - window.history.back()} className="text-white/80 p-1"> - - - - - {selectedIndex + 1} / {photos.length} - - - {hasInfo && ( - - - - )} - - - - - - - {/* Swiper */} - { - swiperRef.current = swiper; - }} - onSlideChange={(swiper) => setSelectedIndex(swiper.activeIndex)} - className="w-full h-full" - spaceBetween={0} - slidesPerView={1} - resistance={true} - resistanceRatio={0.5} - > - {photos.map((photo, index) => ( - - - - - - ))} - - - {/* 모바일용 인디케이터 */} - swiperRef.current?.slideTo(i)} - width={120} - /> - - {/* 정보 바텀시트 */} - - {showInfo && hasInfo && ( - window.history.back()} - > - e.stopPropagation()} - > - {/* 드래그 핸들 */} - - - - - {/* 정보 내용 */} - - 사진 정보 - - {currentPhoto?.members && ( - - - - - - 멤버 - {currentPhoto.members} - - - )} - - {currentPhoto?.concept && ( - - - - - - 컨셉 - {currentPhoto.concept} - - - )} - - - - )} - - - )} - + {/* 라이트박스 */} + p.medium_url || p.original_url)} + photos={photos} + currentIndex={selectedIndex ?? 0} + isOpen={selectedIndex !== null} + onClose={() => setSelectedIndex(null)} + onIndexChange={setSelectedIndex} + showCounter={photos.length > 1} + downloadPrefix={`fromis9_${album?.title || 'photo'}`} + /> > ); }
멤버
{members}
컨셉
{concept}
{currentPhoto.members}
{currentPhoto.concept}