import { useState, useEffect, useCallback, memo } from 'react'; import { useParams, useNavigate } from 'react-router-dom'; import { motion, AnimatePresence } from 'framer-motion'; import { Calendar, Music2, Clock, X, ChevronLeft, ChevronRight, Download, MoreVertical, FileText } from 'lucide-react'; // 인디케이터 컴포넌트 - CSS transition 사용으로 JS 블로킹에 영향받지 않음 const LightboxIndicator = memo(function LightboxIndicator({ count, currentIndex, setLightbox }) { const translateX = -(currentIndex * 18) + 100 - 6; return (
{/* 양옆 페이드 그라데이션 */}
{/* 슬라이딩 컨테이너 - CSS transition으로 GPU 가속 */}
{Array.from({ length: count }).map((_, i) => (
); }); function AlbumDetail() { const { name } = useParams(); const navigate = useNavigate(); const [album, setAlbum] = useState(null); const [loading, setLoading] = useState(true); const [lightbox, setLightbox] = useState({ open: false, images: [], index: 0 }); const [slideDirection, setSlideDirection] = useState(0); const [imageLoaded, setImageLoaded] = useState(false); const [preloadedImages] = useState(() => new Set()); // 프리로드된 이미지 URL 추적 const [showDescriptionModal, setShowDescriptionModal] = useState(false); const [showMenu, setShowMenu] = useState(false); // 라이트박스 네비게이션 함수 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 closeLightbox = useCallback(() => { setLightbox(prev => ({ ...prev, open: false })); }, []); // 라이트박스 열릴 때 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': closeLightbox(); break; default: break; } }; window.addEventListener('keydown', handleKeyDown); return () => window.removeEventListener('keydown', handleKeyDown); }, [lightbox.open, goToPrev, goToNext, closeLightbox]); // 이미지 프리로딩 (이전/다음 2개씩) - 백그라운드에서 프리로드만 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]); useEffect(() => { fetch(`/api/albums/by-name/${name}`) .then(res => res.json()) .then(data => { setAlbum(data); setLoading(false); }) .catch(error => { console.error('앨범 데이터 로드 오류:', error); setLoading(false); }); }, [name]); // URL 헬퍼 함수는 더 이상 필요 없음 - API에서 직접 제공 // 날짜 포맷팅 const formatDate = (dateStr) => { if (!dateStr) return ''; const date = new Date(dateStr); return `${date.getFullYear()}.${String(date.getMonth() + 1).padStart(2, '0')}.${String(date.getDate()).padStart(2, '0')}`; }; // 총 재생 시간 계산 const getTotalDuration = () => { if (!album?.tracks) return ''; let totalSeconds = 0; album.tracks.forEach(track => { if (track.duration) { const parts = track.duration.split(':'); totalSeconds += parseInt(parts[0]) * 60 + parseInt(parts[1]); } }); const mins = Math.floor(totalSeconds / 60); const secs = totalSeconds % 60; return `${mins}:${String(secs).padStart(2, '0')}`; }; // 뒤로가기 const handleBack = () => { navigate('/album'); }; if (loading) { return (
); } if (!album) { return (

앨범을 찾을 수 없습니다.

); } return ( <>
{/* 브레드크럼 네비게이션 */}
/ {album?.title}
{/* 앨범 정보 헤더 */}
{/* 앨범 커버 - 클릭하면 라이트박스 */} setLightbox({ open: true, images: [album.cover_original_url || album.cover_medium_url], index: 0 })} > {album.title} {/* 앨범 정보 */}
{album.album_type} {/* 점3개 메뉴 - 소개글이 있을 때만 */} {album.description && (
{showMenu && ( <>
setShowMenu(false)} /> )}
)}

{album.title}

{formatDate(album.release_date)}
{album.tracks?.length || 0}곡
{getTotalDuration()}

타이틀곡: {album.tracks?.find(t => t.is_title_track === 1)?.title || album.tracks?.[0]?.title}

{/* 앨범 티저 이미지/영상 */} {album.teasers && album.teasers.length > 0 && (

티저 포토

{album.teasers.map((teaser, index) => (
setLightbox({ open: true, images: album.teasers.map(t => t.original_url), index, teasers: album.teasers // media_type 정보 전달 })} className="w-24 h-24 bg-gray-200 rounded-lg overflow-hidden cursor-pointer transition-all duration-300 ease-out hover:scale-105 hover:shadow-xl hover:z-10 relative" > {teaser.media_type === 'video' ? ( <>
)}
{/* 수록곡 리스트 */}

수록곡

{album.tracks?.map((track, index) => (
{/* 트랙 번호 */}
{String(track.track_number).padStart(2, '0')}
{/* 트랙 정보 */}

{track.title}

{track.is_title_track === 1 && ( TITLE )}
{/* 재생 시간 */}
{track.duration || '-'}
))}
{/* 컨셉 포토 섹션 */} {album.conceptPhotos && Object.keys(album.conceptPhotos).length > 0 && ( {(() => { // 모든 컨셉 포토를 하나의 배열로 합치고 처음 4개만 표시 const allPhotos = Object.values(album.conceptPhotos).flat(); const previewPhotos = allPhotos.slice(0, 4); const totalCount = allPhotos.length; return ( <>

컨셉 포토

{previewPhotos.map((photo, idx) => (
setLightbox({ open: true, images: [photo.original_url], index: 0 })} className="aspect-square bg-gray-200 rounded-xl overflow-hidden cursor-pointer transition-all duration-300 ease-out hover:scale-[1.03] hover:shadow-xl hover:z-10" > {`컨셉
))}
); })()}
)}
{/* 라이트박스 모달 */} {lightbox.open && ( {/* 내부 컨테이너 - min-width, min-height 적용 */}
{/* 상단 버튼들 */}
{/* 다운로드 버튼 */} {/* 닫기 버튼 */}
{/* 이전 버튼 */} {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' }} /> )}
{/* 다음 버튼 */} {lightbox.images.length > 1 && ( )} {/* 인디케이터 - memo 컴포넌트로 분리 */} {lightbox.images.length > 1 && ( )}
)}
{/* 앨범 소개 다이얼로그 */} {showDescriptionModal && album?.description && ( setShowDescriptionModal(false)} > e.stopPropagation()} > {/* 헤더 */}

앨범 소개

{/* 내용 */}

{album.description}

)}
); } export default AlbumDetail;