diff --git a/backend/routes/albums.js b/backend/routes/albums.js index 99d1bba..b57c168 100644 --- a/backend/routes/albums.js +++ b/backend/routes/albums.js @@ -122,4 +122,59 @@ router.get("/:id", async (req, res) => { } }); +// 앨범명과 트랙명으로 트랙 상세 조회 +router.get("/by-name/:albumName/track/:trackTitle", async (req, res) => { + try { + const albumName = decodeURIComponent(req.params.albumName); + const trackTitle = decodeURIComponent(req.params.trackTitle); + + // 앨범 조회 + const [albums] = await pool.query( + "SELECT * FROM albums WHERE folder_name = ? OR title = ?", + [albumName, albumName] + ); + + if (albums.length === 0) { + return res.status(404).json({ error: "앨범을 찾을 수 없습니다." }); + } + + const album = albums[0]; + + // 해당 앨범의 트랙 조회 + const [tracks] = await pool.query( + "SELECT * FROM tracks WHERE album_id = ? AND title = ?", + [album.id, trackTitle] + ); + + if (tracks.length === 0) { + return res.status(404).json({ error: "트랙을 찾을 수 없습니다." }); + } + + const track = tracks[0]; + + // 앨범의 다른 트랙 목록 조회 + const [otherTracks] = await pool.query( + "SELECT id, track_number, title, is_title_track, duration FROM tracks WHERE album_id = ? ORDER BY track_number", + [album.id] + ); + + res.json({ + ...track, + album: { + id: album.id, + title: album.title, + folder_name: album.folder_name, + cover_thumb_url: album.cover_thumb_url, + cover_medium_url: album.cover_medium_url, + release_date: album.release_date, + album_type: album.album_type, + }, + otherTracks, + }); + } catch (error) { + console.error("트랙 조회 오류:", error); + res.status(500).json({ error: "트랙 정보를 가져오는데 실패했습니다." }); + } +}); + export default router; diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index 673fad6..80eea25 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -11,6 +11,7 @@ import PCMembers from './pages/pc/public/Members'; import PCAlbum from './pages/pc/public/Album'; import PCAlbumDetail from './pages/pc/public/AlbumDetail'; import PCAlbumGallery from './pages/pc/public/AlbumGallery'; +import PCTrackDetail from './pages/pc/public/TrackDetail'; import PCSchedule from './pages/pc/public/Schedule'; // 모바일 페이지 @@ -78,6 +79,7 @@ function App() { } /> } /> } /> + } /> } /> diff --git a/frontend/src/api/public/albums.js b/frontend/src/api/public/albums.js index 1418622..7df0c2f 100644 --- a/frontend/src/api/public/albums.js +++ b/frontend/src/api/public/albums.js @@ -27,3 +27,12 @@ export async function getAlbumPhotos(albumId) { export async function getAlbumTracks(albumId) { return fetchApi(`/api/albums/${albumId}/tracks`); } + +// 트랙 상세 조회 (앨범명, 트랙명으로) +export async function getTrack(albumName, trackTitle) { + return fetchApi( + `/api/albums/by-name/${encodeURIComponent( + albumName + )}/track/${encodeURIComponent(trackTitle)}` + ); +} diff --git a/frontend/src/pages/pc/public/AlbumDetail.jsx b/frontend/src/pages/pc/public/AlbumDetail.jsx index 16d04fd..4d6856a 100644 --- a/frontend/src/pages/pc/public/AlbumDetail.jsx +++ b/frontend/src/pages/pc/public/AlbumDetail.jsx @@ -341,6 +341,7 @@ function AlbumDetail() { {album.tracks?.map((track, index) => (
navigate(`/album/${encodeURIComponent(album.title)}/track/${encodeURIComponent(track.title)}`)} className={`group flex items-center gap-4 p-4 hover:bg-primary/5 transition-all duration-200 cursor-pointer ${ index !== album.tracks.length - 1 ? 'border-b border-gray-100' : '' }`} diff --git a/frontend/src/pages/pc/public/TrackDetail.jsx b/frontend/src/pages/pc/public/TrackDetail.jsx new file mode 100644 index 0000000..e6d08ae --- /dev/null +++ b/frontend/src/pages/pc/public/TrackDetail.jsx @@ -0,0 +1,251 @@ +import { useState, useMemo } from 'react'; +import { useParams, useNavigate, Link } from 'react-router-dom'; +import { useQuery } from '@tanstack/react-query'; +import { motion } from 'framer-motion'; +import { Clock, User, Music, Mic2, Play, ExternalLink, ChevronRight } from 'lucide-react'; +import { getTrack } from '../../../api/public/albums'; +import { formatDate } from '../../../utils/date'; + +// PC 곡 상세 페이지 +function TrackDetail() { + const { name: albumName, trackTitle } = useParams(); + const navigate = useNavigate(); + + // useQuery로 트랙 데이터 로드 + const { data: track, isLoading: loading, error } = useQuery({ + queryKey: ['track', albumName, trackTitle], + queryFn: () => getTrack(albumName, trackTitle), + enabled: !!albumName && !!trackTitle, + }); + + if (loading) { + return ( + +
+
+ ); + } + + if (error || !track) { + return ( +
+

트랙을 찾을 수 없습니다.

+
+ ); + } + + return ( + +
+ {/* 브레드크럼 네비게이션 */} +
+ + 앨범 + + / + + {track.album?.title} + + / + {track.title} +
+ + {/* 트랙 정보 헤더 */} +
+ {/* 앨범 커버 */} + navigate(`/album/${encodeURIComponent(track.album?.title || albumName)}`)} + > + {track.album?.title} + + + {/* 트랙 정보 */} + +
+ {track.is_title_track === 1 && ( + + TITLE + + )} + + Track {String(track.track_number).padStart(2, '0')} + +
+ +

{track.title}

+ + {/* 메타 정보 */} +
+ + + {track.album?.title} + + {track.duration && ( +
+ + {track.duration} +
+ )} +
+ + {/* 뮤직비디오 링크 */} + {track.music_video_url && ( + + + 뮤직비디오 보기 + + + )} +
+
+ + {/* 크레딧 & 가사 영역 */} +
+ {/* 왼쪽: 크레딧 + 수록곡 */} +
+ {/* 크레딧 */} + {(track.lyricist || track.composer || track.arranger) && ( + +

크레딧

+
+ {track.lyricist && ( +
+
+ +
+
+

작사

+

{track.lyricist}

+
+
+ )} + {track.composer && ( +
+
+ +
+
+

작곡

+

{track.composer}

+
+
+ )} + {track.arranger && ( +
+
+ +
+
+

편곡

+

{track.arranger}

+
+
+ )} +
+
+ )} + + {/* 수록곡 */} + +

수록곡

+
+ {track.otherTracks?.map((t) => ( + + + {String(t.track_number).padStart(2, '0')} + + + {t.title} + + {t.is_title_track === 1 && ( + + T + + )} + + ))} +
+
+
+ + {/* 오른쪽: 가사 */} + +
+

가사

+ {track.lyrics ? ( +
+ {track.lyrics} +
+ ) : ( +
+ +

가사 정보가 없습니다

+
+ )} +
+
+
+
+
+ ); +} + +export default TrackDetail;