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:
parent
81b78be010
commit
5e03e48be5
6 changed files with 239 additions and 6 deletions
|
|
@ -3,7 +3,7 @@ import { cn, getTodayKST, formatFullDate } from "@/utils";
|
|||
import { useUIStore } from "@/stores";
|
||||
import { useIsMobile, useCategories, useScheduleData } from "@/hooks";
|
||||
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: 스케줄 페이지 마이그레이션
|
||||
* - Layout 컴포넌트 (PC/Mobile 통합)
|
||||
* - Header, Footer, MobileNav 컴포넌트
|
||||
* - Schedule 페이지 (기본 구조)
|
||||
* Phase 8: 앨범 페이지 마이그레이션
|
||||
* - Album 페이지 (PC/Mobile 통합)
|
||||
* - useAlbums 훅 추가
|
||||
*/
|
||||
function App() {
|
||||
return (
|
||||
|
|
@ -178,11 +177,13 @@ function App() {
|
|||
</Layout>
|
||||
}
|
||||
/>
|
||||
{/* 앨범 */}
|
||||
<Route
|
||||
path="/album"
|
||||
element={
|
||||
<Layout pageTitle="앨범">
|
||||
<div className="p-4 text-center text-gray-500">앨범 페이지 (준비 중)</div>
|
||||
<Album />
|
||||
<ToastContainer />
|
||||
</Layout>
|
||||
}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -24,3 +24,6 @@ export { useCalendar } from './useCalendar';
|
|||
|
||||
// 인증
|
||||
export { useAdminAuth, useRedirectIfAuthenticated } from './useAdminAuth';
|
||||
|
||||
// 앨범 데이터
|
||||
export { useAlbums, useAlbumDetail, useAlbumGallery } from './useAlbumData';
|
||||
|
|
|
|||
36
frontend-temp/src/hooks/useAlbumData.js
Normal file
36
frontend-temp/src/hooks/useAlbumData.js
Normal 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,
|
||||
});
|
||||
}
|
||||
188
frontend-temp/src/pages/album/Album.jsx
Normal file
188
frontend-temp/src/pages/album/Album.jsx
Normal 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;
|
||||
4
frontend-temp/src/pages/album/index.js
Normal file
4
frontend-temp/src/pages/album/index.js
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
/**
|
||||
* 앨범 페이지 export
|
||||
*/
|
||||
export { default as Album } from './Album';
|
||||
|
|
@ -2,3 +2,4 @@
|
|||
* 페이지 export
|
||||
*/
|
||||
export * from './schedule';
|
||||
export * from './album';
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue