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:
caadiq 2026-01-22 09:23:24 +09:00
parent 7a076aaffd
commit 76e0c2ee72
4 changed files with 221 additions and 4 deletions

View file

@ -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>

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

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

View file

@ -0,0 +1,2 @@
export { default as PCAlbum } from './PCAlbum';
export { default as MobileAlbum } from './MobileAlbum';