feat(frontend-temp): Phase 10 - 앨범 목록 페이지 구현
PC 앨범 페이지: - 앨범 목록 그리드 (4열) - 앨범 타입별 통계 (정규/미니/싱글/총) - 호버 시 트랙 수 표시 - 타이틀곡 및 발매일 표시 Mobile 앨범 페이지: - 앨범 목록 그리드 (2열) - 앨범 타입 및 발매년도 표시 Note: 앨범 상세 페이지(AlbumDetail, AlbumGallery)는 추후 구현 예정 Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
7a076aaffd
commit
76e0c2ee72
4 changed files with 221 additions and 4 deletions
|
|
@ -15,6 +15,7 @@ import { Layout as MobileLayout } from '@/components/mobile';
|
|||
import { PCHome, MobileHome } from '@/pages/home';
|
||||
import { PCMembers, MobileMembers } from '@/pages/members';
|
||||
import { PCSchedule, MobileSchedule } from '@/pages/schedule';
|
||||
import { PCAlbum, MobileAlbum } from '@/pages/album';
|
||||
|
||||
/**
|
||||
* PC 환경에서 body에 클래스 추가하는 래퍼
|
||||
|
|
@ -52,8 +53,9 @@ function App() {
|
|||
<Route path="/" element={<PCHome />} />
|
||||
<Route path="/members" element={<PCMembers />} />
|
||||
<Route path="/schedule" element={<PCSchedule />} />
|
||||
{/* 추가 페이지는 Phase 10-11에서 구현 */}
|
||||
{/* <Route path="/album" element={<PCAlbum />} /> */}
|
||||
<Route path="/album" element={<PCAlbum />} />
|
||||
{/* 추가 페이지는 Phase 11에서 구현 */}
|
||||
{/* <Route path="/album/:name" element={<PCAlbumDetail />} /> */}
|
||||
{/* <Route path="*" element={<PCNotFound />} /> */}
|
||||
</Routes>
|
||||
</PCLayout>
|
||||
|
|
@ -90,8 +92,16 @@ function App() {
|
|||
</MobileLayout>
|
||||
}
|
||||
/>
|
||||
{/* 추가 페이지는 Phase 10-11에서 구현 */}
|
||||
{/* <Route path="/album" element={<MobileLayout pageTitle="앨범"><MobileAlbum /></MobileLayout>} /> */}
|
||||
<Route
|
||||
path="/album"
|
||||
element={
|
||||
<MobileLayout pageTitle="앨범">
|
||||
<MobileAlbum />
|
||||
</MobileLayout>
|
||||
}
|
||||
/>
|
||||
{/* 추가 페이지는 Phase 11에서 구현 */}
|
||||
{/* <Route path="/album/:name" element={<MobileLayout><MobileAlbumDetail /></MobileLayout>} /> */}
|
||||
</Routes>
|
||||
</MobileView>
|
||||
</BrowserRouter>
|
||||
|
|
|
|||
59
frontend-temp/src/pages/album/MobileAlbum.jsx
Normal file
59
frontend-temp/src/pages/album/MobileAlbum.jsx
Normal file
|
|
@ -0,0 +1,59 @@
|
|||
import { motion } from 'framer-motion';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { getAlbums } from '@/api/albums';
|
||||
|
||||
/**
|
||||
* Mobile 앨범 목록 페이지
|
||||
*/
|
||||
function MobileAlbum() {
|
||||
const navigate = useNavigate();
|
||||
|
||||
const { data: albums = [], isLoading: loading } = useQuery({
|
||||
queryKey: ['albums'],
|
||||
queryFn: getAlbums,
|
||||
});
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<div className="w-8 h-8 border-2 border-primary border-t-transparent rounded-full animate-spin" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="px-4 py-4">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
{albums.map((album, index) => (
|
||||
<motion.div
|
||||
key={album.id}
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: index * 0.05 }}
|
||||
onClick={() => navigate(`/album/${encodeURIComponent(album.title)}`)}
|
||||
className="bg-white rounded-2xl overflow-hidden shadow-md"
|
||||
>
|
||||
<div className="aspect-square bg-gray-200">
|
||||
{album.cover_thumb_url && (
|
||||
<img
|
||||
src={album.cover_thumb_url}
|
||||
alt={album.title}
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<div className="p-3">
|
||||
<p className="font-semibold text-sm truncate">{album.title}</p>
|
||||
<p className="text-xs text-gray-400 mt-0.5">
|
||||
{album.album_type_short} · {album.release_date?.slice(0, 4)}
|
||||
</p>
|
||||
</div>
|
||||
</motion.div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default MobileAlbum;
|
||||
146
frontend-temp/src/pages/album/PCAlbum.jsx
Normal file
146
frontend-temp/src/pages/album/PCAlbum.jsx
Normal file
|
|
@ -0,0 +1,146 @@
|
|||
import { useQuery } from '@tanstack/react-query';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { motion } from 'framer-motion';
|
||||
import { Calendar, Music } from 'lucide-react';
|
||||
import { getAlbums } from '@/api/albums';
|
||||
import { formatDate } from '@/utils';
|
||||
|
||||
/**
|
||||
* PC 앨범 목록 페이지
|
||||
*/
|
||||
function PCAlbum() {
|
||||
const navigate = useNavigate();
|
||||
|
||||
const { data: albums = [], isLoading: loading } = useQuery({
|
||||
queryKey: ['albums'],
|
||||
queryFn: getAlbums,
|
||||
});
|
||||
|
||||
// 타이틀곡 찾기
|
||||
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 getAlbumType = (album) => album.album_type_short || album.album_type;
|
||||
const albumStats = {
|
||||
정규: albums.filter((a) => getAlbumType(a) === '정규').length,
|
||||
미니: albums.filter((a) => getAlbumType(a) === '미니').length,
|
||||
싱글: albums.filter((a) => getAlbumType(a) === '싱글').length,
|
||||
총: albums.length,
|
||||
};
|
||||
|
||||
// 앨범 클릭
|
||||
const handleAlbumClick = (albumTitle) => {
|
||||
navigate(`/album/${encodeURIComponent(albumTitle)}`);
|
||||
};
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="py-16">
|
||||
<div className="max-w-7xl mx-auto px-6">
|
||||
{/* 헤더 */}
|
||||
<div className="text-center mb-8">
|
||||
<motion.h1
|
||||
initial={{ opacity: 0, y: -20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
className="text-4xl font-bold mb-4"
|
||||
>
|
||||
앨범
|
||||
</motion.h1>
|
||||
<motion.p
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
transition={{ delay: 0.2 }}
|
||||
className="text-gray-500"
|
||||
>
|
||||
프로미스나인의 음악을 만나보세요
|
||||
</motion.p>
|
||||
</div>
|
||||
|
||||
{/* 통계 */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: 0.3 }}
|
||||
className="mb-12 grid grid-cols-4 gap-4"
|
||||
>
|
||||
<div className="bg-gray-50 rounded-xl p-4 text-center">
|
||||
<p className="text-2xl font-bold text-primary mb-1">{albumStats.정규}</p>
|
||||
<p className="text-gray-500 text-sm">정규 앨범</p>
|
||||
</div>
|
||||
<div className="bg-gray-50 rounded-xl p-4 text-center">
|
||||
<p className="text-2xl font-bold text-primary mb-1">{albumStats.미니}</p>
|
||||
<p className="text-gray-500 text-sm">미니 앨범</p>
|
||||
</div>
|
||||
<div className="bg-gray-50 rounded-xl p-4 text-center">
|
||||
<p className="text-2xl font-bold text-primary mb-1">{albumStats.싱글}</p>
|
||||
<p className="text-gray-500 text-sm">싱글 앨범</p>
|
||||
</div>
|
||||
<div className="bg-gray-50 rounded-xl p-4 text-center">
|
||||
<p className="text-2xl font-bold text-primary mb-1">{albumStats.총}</p>
|
||||
<p className="text-gray-500 text-sm">총 앨범</p>
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
{/* 앨범 그리드 */}
|
||||
<div className="grid grid-cols-4 gap-8">
|
||||
{albums.map((album, index) => (
|
||||
<motion.div
|
||||
key={album.id}
|
||||
initial={{ opacity: 0, y: 30 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: index * 0.1, duration: 0.3 }}
|
||||
className="group bg-white rounded-2xl overflow-hidden shadow-lg hover:shadow-2xl transition-shadow duration-300 cursor-pointer"
|
||||
onClick={() => handleAlbumClick(album.title)}
|
||||
>
|
||||
{/* 앨범 커버 */}
|
||||
<div className="relative aspect-square bg-gray-100 overflow-hidden">
|
||||
<img
|
||||
src={album.cover_medium_url || album.cover_original_url}
|
||||
alt={album.title}
|
||||
loading="lazy"
|
||||
className="w-full h-full object-cover group-hover:scale-105 transition-transform duration-500"
|
||||
/>
|
||||
|
||||
{/* 호버 오버레이 */}
|
||||
<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">{album.tracks?.length || 0}곡 수록</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 앨범 정보 */}
|
||||
<div className="p-6">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<h3 className="font-bold text-lg truncate flex-1">{album.title}</h3>
|
||||
<span className="px-2 py-0.5 bg-primary/10 text-primary text-xs font-medium rounded-full flex-shrink-0">
|
||||
{album.album_type_short || album.album_type}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-primary text-sm font-medium mb-3">{getTitleTrack(album.tracks)}</p>
|
||||
<div className="flex items-center gap-2 text-sm text-gray-500">
|
||||
<Calendar size={14} />
|
||||
<span>{formatDate(album.release_date, 'YYYY.MM.DD')}</span>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default PCAlbum;
|
||||
2
frontend-temp/src/pages/album/index.js
Normal file
2
frontend-temp/src/pages/album/index.js
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
export { default as PCAlbum } from './PCAlbum';
|
||||
export { default as MobileAlbum } from './MobileAlbum';
|
||||
Loading…
Add table
Reference in a new issue