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.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;