diff --git a/backend/routes/albums.js b/backend/routes/albums.js index a58440a..f3929dc 100644 --- a/backend/routes/albums.js +++ b/backend/routes/albums.js @@ -62,7 +62,7 @@ async function getAlbumDetails(album) { router.get("/", async (req, res) => { try { const [albums] = await pool.query( - "SELECT id, title, album_type, album_type_short, release_date, cover_original_url, cover_medium_url, cover_thumb_url FROM albums ORDER BY release_date DESC" + "SELECT id, title, folder_name, album_type, album_type_short, release_date, cover_original_url, cover_medium_url, cover_thumb_url FROM albums ORDER BY release_date DESC" ); // 각 앨범에 트랙 정보 추가 diff --git a/frontend/src/pages/mobile/public/AlbumDetail.jsx b/frontend/src/pages/mobile/public/AlbumDetail.jsx index f62105c..b9adc57 100644 --- a/frontend/src/pages/mobile/public/AlbumDetail.jsx +++ b/frontend/src/pages/mobile/public/AlbumDetail.jsx @@ -1,34 +1,100 @@ -import { motion } from 'framer-motion'; -import { useState, useEffect } from 'react'; +import { motion, AnimatePresence } from 'framer-motion'; +import { useState, useEffect, useCallback } from 'react'; import { useParams, useNavigate } from 'react-router-dom'; -import { ArrowLeft, Play } from 'lucide-react'; -import { getAlbums, getAlbumTracks } from '../../../api/public/albums'; +import { ArrowLeft, Play, Calendar, Music2, Clock, X, ChevronLeft, ChevronRight, Download } from 'lucide-react'; +import { getAlbumByName } from '../../../api/public/albums'; +import { formatDate } from '../../../utils/date'; // 모바일 앨범 상세 페이지 function MobileAlbumDetail() { const { name } = useParams(); const navigate = useNavigate(); const [album, setAlbum] = useState(null); - const [tracks, setTracks] = useState([]); const [loading, setLoading] = useState(true); + const [lightbox, setLightbox] = useState({ open: false, images: [], index: 0 }); + const [imageLoaded, setImageLoaded] = useState(false); + + // 라이트박스 네비게이션 + const goToPrev = useCallback(() => { + if (lightbox.images.length <= 1) return; + setImageLoaded(false); + 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); + setLightbox(prev => ({ + ...prev, + index: (prev.index + 1) % prev.images.length + })); + }, [lightbox.images.length]); + + const closeLightbox = useCallback(() => { + setLightbox(prev => ({ ...prev, open: false })); + }, []); + + // 이미지 다운로드 + 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 스크롤 방지 + useEffect(() => { + if (lightbox.open) { + document.body.style.overflow = 'hidden'; + } else { + document.body.style.overflow = ''; + } + return () => { document.body.style.overflow = ''; }; + }, [lightbox.open]); useEffect(() => { - // 앨범 정보 로드 - getAlbums() + getAlbumByName(name) .then(data => { - const found = data.find(a => a.folder_name === name); - if (found) { - setAlbum(found); - // 트랙 정보 로드 - getAlbumTracks(found.id) - .then(setTracks) - .catch(console.error); - } + setAlbum(data); setLoading(false); }) - .catch(console.error); + .catch(error => { + console.error('앨범 로드 오류:', error); + setLoading(false); + }); }, [name]); + // 총 재생 시간 계산 + 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')}`; + }; + if (loading) { return (
@@ -45,96 +111,325 @@ function MobileAlbumDetail() { ); } + // 모든 컨셉 포토를 하나의 배열로 + const allPhotos = album.conceptPhotos ? Object.values(album.conceptPhotos).flat() : []; + return ( -
- {/* 헤더 */} -
- - {album.title} + <> +
+ {/* 헤더 */} +
+ + {album.title} +
+ + {/* 앨범 정보 섹션 */} +
+
+ {/* 앨범 커버 */} + setLightbox({ + open: true, + images: [album.cover_original_url || album.cover_medium_url], + index: 0 + })} + > + {album.cover_medium_url && ( + {album.title} + )} + + + {/* 앨범 정보 */} + + + {album.album_type} + +

{album.title}

+ + {/* 메타 정보 */} +
+
+ + {formatDate(album.release_date, 'YYYY.MM.DD')} +
+
+ + {album.tracks?.length || 0}곡 +
+
+ + {getTotalDuration()} +
+
+
+
+
+ + {/* 티저 포토 */} + {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 + })} + className="w-20 h-20 flex-shrink-0 bg-gray-200 rounded-xl overflow-hidden relative" + > + {teaser.media_type === 'video' ? ( + <> +
+ ))} +
+
+ )} + + {/* 수록곡 */} + {album.tracks && album.tracks.length > 0 && ( + +

수록곡

+
+ {album.tracks.map((track, index) => ( +
+
+ + {String(track.track_number).padStart(2, '0')} + +
+
+
+

+ {track.title} +

+ {track.is_title_track === 1 && ( + + TITLE + + )} +
+
+ + {track.duration || '-'} + + {track.music_video_url && ( + + + + )} +
+ ))} +
+
+ )} + + {/* 컨셉 포토 */} + {allPhotos.length > 0 && ( + +
+

컨셉 포토

+ +
+
+ {allPhotos.slice(0, 6).map((photo, idx) => ( +
setLightbox({ + open: true, + images: allPhotos.map(p => p.original_url), + index: idx + })} + className="aspect-square bg-gray-200 rounded-xl overflow-hidden" + > + {`컨셉 +
+ ))} +
+
+ )} + + {/* 설명 */} + {album.description && ( + +

소개

+

+ {album.description} +

+
+ )}
- {/* 앨범 정보 */} -
-
-
- {album.cover_medium_url && ( + {/* 라이트박스 */} + + {lightbox.open && ( + + {/* 상단 버튼 */} +
+ + +
+ + {/* 이전 버튼 */} + {lightbox.images.length > 1 && ( + + )} + + {/* 로딩 스피너 */} + {!imageLoaded && ( +
+
+
+ )} + + {/* 이미지/비디오 */} + {lightbox.teasers?.[lightbox.index]?.media_type === 'video' ? ( +
-
-

{album.title}

-

{album.album_type}

-

{album.release_date}

- - -
-
-
- {/* 트랙 리스트 */} - {tracks.length > 0 && ( -
-

수록곡

-
- {tracks.map((track, index) => ( - 1 && ( +
-
- )} + + + )} - {/* 설명 */} - {album.description && ( -
-

소개

-

- {album.description} -

-
- )} -
+ {/* 인디케이터 */} + {lightbox.images.length > 1 && ( +
+ {lightbox.images.map((_, i) => ( +
+ ))} +
+ )} + + )} + + ); } diff --git a/frontend/src/pages/pc/public/AlbumDetail.jsx b/frontend/src/pages/pc/public/AlbumDetail.jsx index e884dd3..c0957d9 100644 --- a/frontend/src/pages/pc/public/AlbumDetail.jsx +++ b/frontend/src/pages/pc/public/AlbumDetail.jsx @@ -289,10 +289,6 @@ function AlbumDetail() { {getTotalDuration()}
- -

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

{/* 앨범 티저 이미지/영상 */}