feat(frontend): Phase 8 - 앨범 페이지 마이그레이션

앨범 페이지:
- Album.jsx: PC/Mobile 통합 (useIsMobile 분기)
- PC: 통계 + 4열 그리드 + 호버 오버레이
- Mobile: 2열 그리드 간소화

훅 추가:
- useAlbums: 앨범 목록 조회
- useAlbumDetail: 앨범 상세 조회
- useAlbumGallery: 앨범 갤러리 조회

라우팅:
- /album 라우트 추가

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
caadiq 2026-01-21 18:03:06 +09:00
parent 81b78be010
commit 5e03e48be5
6 changed files with 239 additions and 6 deletions

View file

@ -3,7 +3,7 @@ import { cn, getTodayKST, formatFullDate } from "@/utils";
import { useUIStore } from "@/stores"; import { useUIStore } from "@/stores";
import { useIsMobile, useCategories, useScheduleData } from "@/hooks"; import { useIsMobile, useCategories, useScheduleData } from "@/hooks";
import { ErrorBoundary, Loading, ToastContainer, ScheduleCard, Layout } from "@/components"; import { ErrorBoundary, Loading, ToastContainer, ScheduleCard, Layout } from "@/components";
import { Schedule } from "@/pages"; import { Schedule, Album } from "@/pages";
/** /**
* 페이지 (임시) * 페이지 (임시)
@ -137,10 +137,9 @@ function Home() {
/** /**
* 프로미스나인 팬사이트 메인 * 프로미스나인 팬사이트 메인
* *
* Phase 7: 스케줄 페이지 마이그레이션 * Phase 8: 앨범 페이지 마이그레이션
* - Layout 컴포넌트 (PC/Mobile 통합) * - Album 페이지 (PC/Mobile 통합)
* - Header, Footer, MobileNav 컴포넌트 * - useAlbums 추가
* - Schedule 페이지 (기본 구조)
*/ */
function App() { function App() {
return ( return (
@ -178,11 +177,13 @@ function App() {
</Layout> </Layout>
} }
/> />
{/* 앨범 */}
<Route <Route
path="/album" path="/album"
element={ element={
<Layout pageTitle="앨범"> <Layout pageTitle="앨범">
<div className="p-4 text-center text-gray-500">앨범 페이지 (준비 )</div> <Album />
<ToastContainer />
</Layout> </Layout>
} }
/> />

View file

@ -24,3 +24,6 @@ export { useCalendar } from './useCalendar';
// 인증 // 인증
export { useAdminAuth, useRedirectIfAuthenticated } from './useAdminAuth'; export { useAdminAuth, useRedirectIfAuthenticated } from './useAdminAuth';
// 앨범 데이터
export { useAlbums, useAlbumDetail, useAlbumGallery } from './useAlbumData';

View file

@ -0,0 +1,36 @@
import { useQuery } from '@tanstack/react-query';
import { albumsApi } from '@/api';
/**
* 앨범 목록 조회
*/
export function useAlbums() {
return useQuery({
queryKey: ['albums'],
queryFn: albumsApi.getAlbums,
});
}
/**
* 앨범 상세 조회
* @param {string} title - 앨범 타이틀 또는 폴더명
*/
export function useAlbumDetail(title) {
return useQuery({
queryKey: ['album', title],
queryFn: () => albumsApi.getAlbumByTitle(title),
enabled: !!title,
});
}
/**
* 앨범 갤러리 조회
* @param {string} title - 앨범 타이틀 또는 폴더명
*/
export function useAlbumGallery(title) {
return useQuery({
queryKey: ['album-gallery', title],
queryFn: () => albumsApi.getAlbumGallery(title),
enabled: !!title,
});
}

View file

@ -0,0 +1,188 @@
import { useNavigate } from 'react-router-dom';
import { motion } from 'framer-motion';
import { Calendar, Music } from 'lucide-react';
import { useIsMobile } from '@/hooks';
import { useAlbums } from '@/hooks';
import { Loading } from '@/components';
import { formatDate } from '@/utils';
/**
* 앨범 목록 페이지 (PC/Mobile 통합)
*/
function Album() {
const navigate = useNavigate();
const isMobile = useIsMobile();
// useQuery
const { data: albums = [], isLoading } = useAlbums();
//
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;
};
// (short )
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 = (album) => {
const path = isMobile ? album.folder_name : encodeURIComponent(album.title);
navigate(`/album/${path}`);
};
if (isLoading) {
return (
<div className="flex justify-center items-center min-h-[60vh]">
<Loading text="앨범 로딩 중..." />
</div>
);
}
//
if (isMobile) {
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={() => handleAlbumClick(album)}
className="bg-white rounded-2xl overflow-hidden shadow-md cursor-pointer"
>
<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">
{getAlbumType(album)} · {album.release_date?.slice(0, 4)}
</p>
</div>
</motion.div>
))}
</div>
</div>
);
}
// PC
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)}
>
{/* 앨범 커버 */}
<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 will-change-transform"
/>
{/* 호버 오버레이 */}
<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">
{getAlbumType(album)}
</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 Album;

View file

@ -0,0 +1,4 @@
/**
* 앨범 페이지 export
*/
export { default as Album } from './Album';

View file

@ -2,3 +2,4 @@
* 페이지 export * 페이지 export
*/ */
export * from './schedule'; export * from './schedule';
export * from './album';