feat: 앨범 데이터 API 연동
- 백엔드: /api/albums 라우트 추가 (routes/albums.js) - 앨범별 트랙 정보 포함 조회 - 프론트엔드: Discography 페이지 API 연동 - 앨범 타입별 통계 동적 계산 - 타이틀곡 자동 표시
This commit is contained in:
parent
6ee8e3598a
commit
1ca5640a67
5 changed files with 165 additions and 27 deletions
57
backend/routes/albums.js
Normal file
57
backend/routes/albums.js
Normal file
|
|
@ -0,0 +1,57 @@
|
|||
import express from "express";
|
||||
import pool from "../lib/db.js";
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
// 전체 앨범 조회 (트랙 포함)
|
||||
router.get("/", async (req, res) => {
|
||||
try {
|
||||
// 앨범 목록 조회
|
||||
const [albums] = await pool.query(
|
||||
"SELECT id, title, album_type, release_date, cover_url FROM albums ORDER BY release_date DESC"
|
||||
);
|
||||
|
||||
// 각 앨범에 트랙 정보 추가
|
||||
for (const album of albums) {
|
||||
const [tracks] = await pool.query(
|
||||
"SELECT id, track_number, title, is_title_track, duration, lyricist, composer, arranger FROM tracks WHERE album_id = ? ORDER BY track_number",
|
||||
[album.id]
|
||||
);
|
||||
album.tracks = tracks;
|
||||
}
|
||||
|
||||
res.json(albums);
|
||||
} catch (error) {
|
||||
console.error("앨범 조회 오류:", error);
|
||||
res.status(500).json({ error: "앨범 정보를 가져오는데 실패했습니다." });
|
||||
}
|
||||
});
|
||||
|
||||
// 특정 앨범 조회 (트랙 및 상세 정보 포함)
|
||||
router.get("/:id", async (req, res) => {
|
||||
try {
|
||||
const [albums] = await pool.query("SELECT * FROM albums WHERE id = ?", [
|
||||
req.params.id,
|
||||
]);
|
||||
|
||||
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 = ? ORDER BY track_number",
|
||||
[album.id]
|
||||
);
|
||||
album.tracks = tracks;
|
||||
|
||||
res.json(album);
|
||||
} catch (error) {
|
||||
console.error("앨범 조회 오류:", error);
|
||||
res.status(500).json({ error: "앨범 정보를 가져오는데 실패했습니다." });
|
||||
}
|
||||
});
|
||||
|
||||
export default router;
|
||||
28
backend/routes/stats.js
Normal file
28
backend/routes/stats.js
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
import express from "express";
|
||||
import pool from "../lib/db.js";
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
// 통계 조회 (멤버 수, 앨범 수)
|
||||
router.get("/", async (req, res) => {
|
||||
try {
|
||||
const [memberCount] = await pool.query(
|
||||
"SELECT COUNT(*) as count FROM members"
|
||||
);
|
||||
const [albumCount] = await pool.query(
|
||||
"SELECT COUNT(*) as count FROM albums"
|
||||
);
|
||||
|
||||
res.json({
|
||||
memberCount: memberCount[0].count,
|
||||
albumCount: albumCount[0].count,
|
||||
debutYear: 2018,
|
||||
fandomName: "flover",
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("통계 조회 오류:", error);
|
||||
res.status(500).json({ error: "통계 정보를 가져오는데 실패했습니다." });
|
||||
}
|
||||
});
|
||||
|
||||
export default router;
|
||||
|
|
@ -2,6 +2,8 @@ import express from "express";
|
|||
import path from "path";
|
||||
import { fileURLToPath } from "url";
|
||||
import membersRouter from "./routes/members.js";
|
||||
import albumsRouter from "./routes/albums.js";
|
||||
import statsRouter from "./routes/stats.js";
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = path.dirname(__filename);
|
||||
|
|
@ -21,6 +23,8 @@ app.get("/api/health", (req, res) => {
|
|||
});
|
||||
|
||||
app.use("/api/members", membersRouter);
|
||||
app.use("/api/albums", albumsRouter);
|
||||
app.use("/api/stats", statsRouter);
|
||||
|
||||
// SPA 폴백 - 모든 요청을 index.html로
|
||||
app.get("*", (req, res) => {
|
||||
|
|
|
|||
|
|
@ -1,8 +1,54 @@
|
|||
import { useState, useEffect } from 'react';
|
||||
import { motion } from 'framer-motion';
|
||||
import { Calendar, Music } from 'lucide-react';
|
||||
import { albums } from '../../data/dummy';
|
||||
|
||||
function Discography() {
|
||||
const [albums, setAlbums] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
fetch('/api/albums')
|
||||
.then(res => res.json())
|
||||
.then(data => {
|
||||
setAlbums(data);
|
||||
setLoading(false);
|
||||
})
|
||||
.catch(error => {
|
||||
console.error('앨범 데이터 로드 오류:', error);
|
||||
setLoading(false);
|
||||
});
|
||||
}, []);
|
||||
|
||||
// 날짜 포맷팅
|
||||
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 getTitleTrack = (tracks) => {
|
||||
if (!tracks || tracks.length === 0) return '';
|
||||
const titleTrack = tracks.find(t => t.is_title_track);
|
||||
return titleTrack ? titleTrack.title : tracks[0].title;
|
||||
};
|
||||
|
||||
// 앨범 타입별 개수 계산
|
||||
const albumStats = {
|
||||
정규: albums.filter(a => a.album_type === '정규').length,
|
||||
미니: albums.filter(a => a.album_type === '미니').length,
|
||||
싱글: albums.filter(a => a.album_type === '싱글').length,
|
||||
총: albums.length
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="py-16 flex justify-center items-center min-h-[60vh]">
|
||||
<div className="animate-spin rounded-full h-12 w-12 border-4 border-primary border-t-transparent"></div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="py-16">
|
||||
<div className="max-w-7xl mx-auto px-6">
|
||||
|
|
@ -38,21 +84,21 @@ function Discography() {
|
|||
{/* 앨범 커버 */}
|
||||
<div className="relative aspect-square bg-gray-100 overflow-hidden">
|
||||
<img
|
||||
src={album.coverUrl}
|
||||
src={album.cover_url}
|
||||
alt={album.title}
|
||||
className="w-full h-full object-cover group-hover:scale-105 transition-transform duration-500"
|
||||
/>
|
||||
|
||||
{/* 앨범 타입 배지 */}
|
||||
<span className="absolute top-4 left-4 px-3 py-1 bg-primary text-white text-xs font-medium rounded-full">
|
||||
{album.albumType}
|
||||
{album.album_type}
|
||||
</span>
|
||||
|
||||
{/* 호버 오버레이 */}
|
||||
<div className="absolute inset-0 bg-black/50 opacity-0 group-hover:opacity-100 transition-opacity duration-300 flex items-center justify-center">
|
||||
<div className="text-center text-white">
|
||||
<Music size={40} className="mx-auto mb-2" />
|
||||
<p className="text-sm">앨범 상세보기</p>
|
||||
<p className="text-sm">{album.tracks?.length || 0}곡 수록</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -61,11 +107,11 @@ function Discography() {
|
|||
<div className="p-6">
|
||||
<h3 className="font-bold text-lg mb-1 truncate">{album.title}</h3>
|
||||
<p className="text-primary text-sm font-medium mb-3">
|
||||
{album.titleTrack}
|
||||
{getTitleTrack(album.tracks)}
|
||||
</p>
|
||||
<div className="flex items-center gap-2 text-sm text-gray-500">
|
||||
<Calendar size={14} />
|
||||
<span>{album.releaseDate}</span>
|
||||
<span>{formatDate(album.release_date)}</span>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
|
|
@ -80,19 +126,19 @@ function Discography() {
|
|||
className="mt-16 grid grid-cols-4 gap-6"
|
||||
>
|
||||
<div className="bg-gray-50 rounded-2xl p-6 text-center">
|
||||
<p className="text-3xl font-bold text-primary mb-1">1</p>
|
||||
<p className="text-3xl font-bold text-primary mb-1">{albumStats.정규}</p>
|
||||
<p className="text-gray-500 text-sm">정규 앨범</p>
|
||||
</div>
|
||||
<div className="bg-gray-50 rounded-2xl p-6 text-center">
|
||||
<p className="text-3xl font-bold text-primary mb-1">2</p>
|
||||
<p className="text-3xl font-bold text-primary mb-1">{albumStats.미니}</p>
|
||||
<p className="text-gray-500 text-sm">미니 앨범</p>
|
||||
</div>
|
||||
<div className="bg-gray-50 rounded-2xl p-6 text-center">
|
||||
<p className="text-3xl font-bold text-primary mb-1">1</p>
|
||||
<p className="text-3xl font-bold text-primary mb-1">{albumStats.싱글}</p>
|
||||
<p className="text-gray-500 text-sm">싱글 앨범</p>
|
||||
</div>
|
||||
<div className="bg-gray-50 rounded-2xl p-6 text-center">
|
||||
<p className="text-3xl font-bold text-primary mb-1">4+</p>
|
||||
<p className="text-3xl font-bold text-primary mb-1">{albumStats.총}</p>
|
||||
<p className="text-gray-500 text-sm">총 앨범</p>
|
||||
</div>
|
||||
</motion.div>
|
||||
|
|
|
|||
|
|
@ -4,17 +4,21 @@ import { Instagram, Calendar } from 'lucide-react';
|
|||
|
||||
function Members() {
|
||||
const [members, setMembers] = useState([]);
|
||||
const [stats, setStats] = useState({ memberCount: 0, albumCount: 0, debutYear: 2018, fandomName: 'flover' });
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
fetch('/api/members')
|
||||
.then(res => res.json())
|
||||
.then(data => {
|
||||
setMembers(data);
|
||||
Promise.all([
|
||||
fetch('/api/members').then(res => res.json()),
|
||||
fetch('/api/stats').then(res => res.json())
|
||||
])
|
||||
.then(([membersData, statsData]) => {
|
||||
setMembers(membersData);
|
||||
setStats(statsData);
|
||||
setLoading(false);
|
||||
})
|
||||
.catch(error => {
|
||||
console.error('멤버 데이터 로드 오류:', error);
|
||||
console.error('데이터 로드 오류:', error);
|
||||
setLoading(false);
|
||||
});
|
||||
}, []);
|
||||
|
|
@ -52,7 +56,7 @@ function Members() {
|
|||
transition={{ delay: 0.2 }}
|
||||
className="text-gray-500"
|
||||
>
|
||||
프로미스나인의 5명의 멤버를 소개합니다
|
||||
프로미스나인의 {stats.memberCount}명의 멤버를 소개합니다
|
||||
</motion.p>
|
||||
</div>
|
||||
|
||||
|
|
@ -64,11 +68,11 @@ function Members() {
|
|||
initial={{ opacity: 0, y: 30 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: index * 0.1 }}
|
||||
className="group"
|
||||
className="group h-full"
|
||||
>
|
||||
<div className="relative bg-white rounded-3xl overflow-hidden shadow-lg hover:shadow-2xl transition-all duration-300">
|
||||
<div className="relative bg-white rounded-3xl overflow-hidden shadow-lg hover:shadow-2xl transition-all duration-300 h-full flex flex-col">
|
||||
{/* 이미지 */}
|
||||
<div className="aspect-[3/4] bg-gray-100 overflow-hidden">
|
||||
<div className="aspect-[3/4] bg-gray-100 overflow-hidden flex-shrink-0">
|
||||
<img
|
||||
src={member.image_url}
|
||||
alt={member.name}
|
||||
|
|
@ -77,9 +81,9 @@ function Members() {
|
|||
</div>
|
||||
|
||||
{/* 정보 */}
|
||||
<div className="p-6">
|
||||
<div className="p-6 flex-1 flex flex-col">
|
||||
<h3 className="text-xl font-bold mb-1">{member.name}</h3>
|
||||
<p className="text-primary text-sm font-medium mb-3">{member.position}</p>
|
||||
<p className="text-primary text-sm font-medium mb-3 min-h-[20px]">{member.position || '\u00A0'}</p>
|
||||
|
||||
<div className="flex items-center gap-2 text-sm text-gray-500 mb-4">
|
||||
<Calendar size={14} />
|
||||
|
|
@ -91,7 +95,7 @@ function Members() {
|
|||
href={member.instagram}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-flex items-center gap-2 text-sm text-gray-500 hover:text-pink-500 transition-colors"
|
||||
className="inline-flex items-center gap-2 text-sm text-gray-500 hover:text-pink-500 transition-colors mt-auto"
|
||||
>
|
||||
<Instagram size={16} />
|
||||
<span>Instagram</span>
|
||||
|
|
@ -114,19 +118,19 @@ function Members() {
|
|||
>
|
||||
<div className="grid grid-cols-4 gap-8 text-center">
|
||||
<div>
|
||||
<p className="text-4xl font-bold mb-2">2018</p>
|
||||
<p className="text-4xl font-bold mb-2">{stats.debutYear}</p>
|
||||
<p className="text-white/70">데뷔 연도</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-4xl font-bold mb-2">5</p>
|
||||
<p className="text-4xl font-bold mb-2">{stats.memberCount}</p>
|
||||
<p className="text-white/70">멤버 수</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-4xl font-bold mb-2">7+</p>
|
||||
<p className="text-4xl font-bold mb-2">{stats.albumCount}</p>
|
||||
<p className="text-white/70">앨범 수</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-4xl font-bold mb-2">flover</p>
|
||||
<p className="text-4xl font-bold mb-2">{stats.fandomName}</p>
|
||||
<p className="text-white/70">팬덤명</p>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -137,4 +141,3 @@ function Members() {
|
|||
}
|
||||
|
||||
export default Members;
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue