feat(frontend): Phase 7 - 홈 페이지 및 App.jsx 구현
- PC/Mobile 홈 페이지 분리 구현 - App.jsx: react-device-detect (BrowserView/MobileView) 사용 - PCWrapper: body.is-pc 클래스 추가 - 커스텀 훅 사용 (useMembers, useAlbums, useUpcomingSchedules) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
86bf2359f2
commit
edde52b06e
5 changed files with 659 additions and 132 deletions
|
|
@ -1,144 +1,83 @@
|
||||||
import { BrowserRouter, Routes, Route } from "react-router-dom";
|
import { useEffect } from 'react';
|
||||||
import { cn, getTodayKST, formatFullDate } from "@/utils";
|
import { BrowserRouter, Routes, Route } from 'react-router-dom';
|
||||||
import { useAuthStore, useUIStore } from "@/stores";
|
import { BrowserView, MobileView } from 'react-device-detect';
|
||||||
import { useIsMobile, useCategories, useCalendar } from "@/hooks";
|
|
||||||
import { memberApi } from "@/api";
|
// 공통 컴포넌트
|
||||||
import { useQuery } from "@tanstack/react-query";
|
import { ScrollToTop } from '@/components/common';
|
||||||
|
|
||||||
|
// PC 레이아웃
|
||||||
|
import { Layout as PCLayout } from '@/components/pc';
|
||||||
|
|
||||||
|
// Mobile 레이아웃
|
||||||
|
import { Layout as MobileLayout } from '@/components/mobile';
|
||||||
|
|
||||||
|
// 페이지
|
||||||
|
import { PCHome, MobileHome } from '@/pages/home';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* PC 환경에서 body에 클래스 추가하는 래퍼
|
||||||
|
*/
|
||||||
|
function PCWrapper({ children }) {
|
||||||
|
useEffect(() => {
|
||||||
|
document.body.classList.add('is-pc');
|
||||||
|
return () => document.body.classList.remove('is-pc');
|
||||||
|
}, []);
|
||||||
|
return children;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 프로미스나인 팬사이트 메인 앱
|
* 프로미스나인 팬사이트 메인 앱
|
||||||
*
|
* react-device-detect를 사용한 PC/Mobile 분리
|
||||||
* Phase 5: 커스텀 훅 완료
|
|
||||||
* - useMediaQuery, useIsMobile, useIsDesktop
|
|
||||||
* - useScheduleData, useScheduleDetail, useCategories
|
|
||||||
* - useScheduleSearch (무한 스크롤)
|
|
||||||
* - useScheduleFiltering, useCategoryCounts
|
|
||||||
* - useCalendar
|
|
||||||
* - useAdminAuth
|
|
||||||
*/
|
*/
|
||||||
function App() {
|
function App() {
|
||||||
const today = getTodayKST();
|
|
||||||
const isMobile = useIsMobile();
|
|
||||||
const { isAuthenticated } = useAuthStore();
|
|
||||||
const { showSuccess, toasts } = useUIStore();
|
|
||||||
|
|
||||||
// 커스텀 훅 사용
|
|
||||||
const { data: categories, isLoading: categoriesLoading } = useCategories();
|
|
||||||
const calendar = useCalendar();
|
|
||||||
|
|
||||||
// 멤버 데이터 (기존 방식 유지)
|
|
||||||
const { data: members, isLoading: membersLoading } = useQuery({
|
|
||||||
queryKey: ["members"],
|
|
||||||
queryFn: memberApi.getMembers,
|
|
||||||
});
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<BrowserRouter>
|
<BrowserRouter future={{ v7_startTransition: true, v7_relativeSplatPath: true }}>
|
||||||
|
<ScrollToTop />
|
||||||
|
|
||||||
|
{/* PC 뷰 */}
|
||||||
|
<BrowserView>
|
||||||
|
<PCWrapper>
|
||||||
|
<Routes>
|
||||||
|
{/* 관리자 페이지 (레이아웃 없음) - 추후 추가 */}
|
||||||
|
{/* <Route path="/admin" element={<AdminLogin />} /> */}
|
||||||
|
|
||||||
|
{/* 일반 페이지 (레이아웃 포함) */}
|
||||||
|
<Route
|
||||||
|
path="/*"
|
||||||
|
element={
|
||||||
|
<PCLayout>
|
||||||
|
<Routes>
|
||||||
|
<Route path="/" element={<PCHome />} />
|
||||||
|
{/* 추가 페이지는 Phase 8-11에서 구현 */}
|
||||||
|
{/* <Route path="/members" element={<PCMembers />} /> */}
|
||||||
|
{/* <Route path="/album" element={<PCAlbum />} /> */}
|
||||||
|
{/* <Route path="/schedule" element={<PCSchedule />} /> */}
|
||||||
|
{/* <Route path="*" element={<PCNotFound />} /> */}
|
||||||
|
</Routes>
|
||||||
|
</PCLayout>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</Routes>
|
||||||
|
</PCWrapper>
|
||||||
|
</BrowserView>
|
||||||
|
|
||||||
|
{/* Mobile 뷰 */}
|
||||||
|
<MobileView>
|
||||||
<Routes>
|
<Routes>
|
||||||
<Route
|
<Route
|
||||||
path="/"
|
path="/"
|
||||||
element={
|
element={
|
||||||
<div className="min-h-screen flex items-center justify-center bg-gray-50 p-4">
|
<MobileLayout>
|
||||||
<div className="text-center space-y-4 max-w-md w-full">
|
<MobileHome />
|
||||||
<h1 className="text-2xl font-bold text-primary mb-2">
|
</MobileLayout>
|
||||||
fromis_9 Frontend Refactoring
|
|
||||||
</h1>
|
|
||||||
<p className="text-gray-600">Phase 5 완료 - 커스텀 훅</p>
|
|
||||||
<p className={cn("text-sm", isMobile ? "text-blue-500" : "text-green-500")}>
|
|
||||||
디바이스: {isMobile ? "모바일" : "PC"} (useIsMobile 훅)
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<div className="mt-6 p-4 bg-white rounded-lg shadow text-left text-sm space-y-3">
|
|
||||||
<p><strong>오늘:</strong> {formatFullDate(today)}</p>
|
|
||||||
<p><strong>인증:</strong> {isAuthenticated ? "✅" : "❌"}</p>
|
|
||||||
|
|
||||||
<div className="border-t pt-3">
|
|
||||||
<p className="font-semibold mb-2">useCategories 훅</p>
|
|
||||||
<p>
|
|
||||||
{categoriesLoading ? "로딩 중..." : `${categories?.length || 0}개 카테고리`}
|
|
||||||
</p>
|
|
||||||
{categories && (
|
|
||||||
<div className="flex flex-wrap gap-1 mt-1">
|
|
||||||
{categories.map((c) => (
|
|
||||||
<span
|
|
||||||
key={c.id}
|
|
||||||
className="px-2 py-0.5 rounded text-xs text-white"
|
|
||||||
style={{ backgroundColor: c.color }}
|
|
||||||
>
|
|
||||||
{c.name}
|
|
||||||
</span>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="border-t pt-3">
|
|
||||||
<p className="font-semibold mb-2">useCalendar 훅</p>
|
|
||||||
<p><strong>현재:</strong> {calendar.year}년 {calendar.monthName}</p>
|
|
||||||
<p><strong>선택:</strong> {calendar.selectedDate}</p>
|
|
||||||
<div className="flex gap-2 mt-2">
|
|
||||||
<button
|
|
||||||
onClick={calendar.goToPrevMonth}
|
|
||||||
disabled={!calendar.canGoPrevMonth}
|
|
||||||
className={cn(
|
|
||||||
"px-3 py-1 rounded text-xs",
|
|
||||||
calendar.canGoPrevMonth ? "bg-primary text-white" : "bg-gray-200 text-gray-400"
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
이전
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={calendar.goToToday}
|
|
||||||
className="px-3 py-1 bg-gray-500 text-white rounded text-xs"
|
|
||||||
>
|
|
||||||
오늘
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={calendar.goToNextMonth}
|
|
||||||
className="px-3 py-1 bg-primary text-white rounded text-xs"
|
|
||||||
>
|
|
||||||
다음
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="border-t pt-3">
|
|
||||||
<p className="font-semibold mb-2">멤버 데이터</p>
|
|
||||||
<p>{membersLoading ? "로딩 중..." : `${members?.length || 0}명`}</p>
|
|
||||||
{members && (
|
|
||||||
<div className="flex flex-wrap gap-1 mt-1">
|
|
||||||
{members.map((m) => (
|
|
||||||
<span key={m.id} className="px-2 py-0.5 bg-gray-100 rounded text-xs">
|
|
||||||
{m.name}
|
|
||||||
</span>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 토스트 표시 */}
|
|
||||||
<div className="fixed bottom-4 right-4 space-y-2">
|
|
||||||
{toasts.map((toast) => (
|
|
||||||
<div
|
|
||||||
key={toast.id}
|
|
||||||
className={cn(
|
|
||||||
"px-4 py-2 rounded shadow-lg text-white text-sm",
|
|
||||||
toast.type === "success" && "bg-green-500",
|
|
||||||
toast.type === "error" && "bg-red-500",
|
|
||||||
toast.type === "warning" && "bg-yellow-500",
|
|
||||||
toast.type === "info" && "bg-blue-500"
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{toast.message}
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
{/* 추가 페이지는 Phase 8-11에서 구현 */}
|
||||||
|
{/* <Route path="/members" element={<MobileLayout pageTitle="멤버"><MobileMembers /></MobileLayout>} /> */}
|
||||||
|
{/* <Route path="/album" element={<MobileLayout pageTitle="앨범"><MobileAlbum /></MobileLayout>} /> */}
|
||||||
|
{/* <Route path="/schedule" element={<MobileLayout useCustomLayout><MobileSchedule /></MobileLayout>} /> */}
|
||||||
</Routes>
|
</Routes>
|
||||||
|
</MobileView>
|
||||||
</BrowserRouter>
|
</BrowserRouter>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
2
frontend-temp/src/pages/home/index.js
Normal file
2
frontend-temp/src/pages/home/index.js
Normal file
|
|
@ -0,0 +1,2 @@
|
||||||
|
export { default as PCHome } from './pc/Home';
|
||||||
|
export { default as MobileHome } from './mobile/Home';
|
||||||
266
frontend-temp/src/pages/home/mobile/Home.jsx
Normal file
266
frontend-temp/src/pages/home/mobile/Home.jsx
Normal file
|
|
@ -0,0 +1,266 @@
|
||||||
|
import { motion } from 'framer-motion';
|
||||||
|
import { ChevronRight, Clock, Tag } from 'lucide-react';
|
||||||
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
import { useMembers, useAlbums, useUpcomingSchedules } from '@/hooks';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mobile 홈 페이지
|
||||||
|
*/
|
||||||
|
function MobileHome() {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
|
// 멤버 데이터 (활동 중인 멤버만)
|
||||||
|
const { data: allMembers = [] } = useMembers();
|
||||||
|
const members = allMembers.filter((m) => !m.is_former);
|
||||||
|
|
||||||
|
// 앨범 데이터 (최신 2개)
|
||||||
|
const { data: allAlbums = [] } = useAlbums();
|
||||||
|
const albums = allAlbums.slice(0, 2);
|
||||||
|
|
||||||
|
// 다가오는 일정 (3개)
|
||||||
|
const { data: schedules = [] } = useUpcomingSchedules(3);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
{/* 히어로 섹션 */}
|
||||||
|
<motion.section
|
||||||
|
className="relative bg-gradient-to-br from-primary to-primary-dark py-12 px-4 overflow-hidden"
|
||||||
|
initial={{ opacity: 0 }}
|
||||||
|
animate={{ opacity: 1 }}
|
||||||
|
transition={{ duration: 0.5 }}
|
||||||
|
>
|
||||||
|
<div className="absolute inset-0 bg-black/10" />
|
||||||
|
<motion.div
|
||||||
|
className="relative text-center text-white"
|
||||||
|
initial={{ opacity: 0, y: 20 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
transition={{ delay: 0.2, duration: 0.5 }}
|
||||||
|
>
|
||||||
|
<h1 className="text-3xl font-bold mb-1">fromis_9</h1>
|
||||||
|
<p className="text-lg font-light mb-3">프로미스나인</p>
|
||||||
|
<p className="text-sm opacity-80 leading-relaxed">
|
||||||
|
인사드리겠습니다. 둘, 셋!
|
||||||
|
<br />
|
||||||
|
이제는 약속해 소중히 간직해,
|
||||||
|
<br />
|
||||||
|
당신의 아이돌로 성장하겠습니다!
|
||||||
|
</p>
|
||||||
|
</motion.div>
|
||||||
|
{/* 장식 */}
|
||||||
|
<div className="absolute right-0 top-0 w-32 h-32 rounded-full bg-white/10 -translate-y-1/2 translate-x-1/2" />
|
||||||
|
<div className="absolute left-0 bottom-0 w-24 h-24 rounded-full bg-white/5 translate-y-1/2 -translate-x-1/2" />
|
||||||
|
</motion.section>
|
||||||
|
|
||||||
|
{/* 멤버 섹션 */}
|
||||||
|
<motion.section
|
||||||
|
className="px-4 py-6"
|
||||||
|
initial={{ opacity: 0, y: 20 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
transition={{ delay: 0.3, duration: 0.5 }}
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-between mb-4">
|
||||||
|
<h2 className="text-lg font-bold">멤버</h2>
|
||||||
|
<button
|
||||||
|
onClick={() => navigate('/members')}
|
||||||
|
className="text-primary text-sm flex items-center gap-1"
|
||||||
|
>
|
||||||
|
전체보기 <ChevronRight size={16} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-5 gap-2">
|
||||||
|
{members.map((member, index) => (
|
||||||
|
<motion.div
|
||||||
|
key={member.id}
|
||||||
|
className="text-center"
|
||||||
|
initial={{ opacity: 0, scale: 0.8 }}
|
||||||
|
animate={{ opacity: 1, scale: 1 }}
|
||||||
|
transition={{ delay: 0.4 + index * 0.05, duration: 0.3 }}
|
||||||
|
whileTap={{ scale: 0.95 }}
|
||||||
|
>
|
||||||
|
<div className="aspect-square rounded-full overflow-hidden bg-gray-200 mb-1">
|
||||||
|
{member.image_url && (
|
||||||
|
<img
|
||||||
|
src={member.image_url}
|
||||||
|
alt={member.name}
|
||||||
|
className="w-full h-full object-cover"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<p className="text-xs font-medium truncate">{member.name}</p>
|
||||||
|
</motion.div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</motion.section>
|
||||||
|
|
||||||
|
{/* 앨범 섹션 */}
|
||||||
|
<motion.section
|
||||||
|
className="px-4 py-6"
|
||||||
|
initial={{ opacity: 0, y: 20 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
transition={{ delay: 0.5, duration: 0.5 }}
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-between mb-4">
|
||||||
|
<h2 className="text-lg font-bold">앨범</h2>
|
||||||
|
<button
|
||||||
|
onClick={() => navigate('/album')}
|
||||||
|
className="text-primary text-sm flex items-center gap-1"
|
||||||
|
>
|
||||||
|
전체보기 <ChevronRight size={16} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
{albums.map((album, index) => (
|
||||||
|
<motion.div
|
||||||
|
key={album.id}
|
||||||
|
onClick={() => navigate(`/album/${album.folder_name}`)}
|
||||||
|
className="bg-white rounded-xl overflow-hidden shadow-md"
|
||||||
|
initial={{ opacity: 0, y: 20 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
transition={{ delay: 0.6 + index * 0.1, duration: 0.3 }}
|
||||||
|
whileTap={{ scale: 0.98 }}
|
||||||
|
>
|
||||||
|
<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-medium text-sm truncate">{album.title}</p>
|
||||||
|
<p className="text-xs text-gray-400">
|
||||||
|
{album.release_date?.slice(0, 4)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</motion.section>
|
||||||
|
|
||||||
|
{/* 일정 섹션 */}
|
||||||
|
<motion.section
|
||||||
|
className="px-4 py-4"
|
||||||
|
initial={{ opacity: 0, y: 20 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
transition={{ delay: 0.7, duration: 0.5 }}
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-between mb-4">
|
||||||
|
<h2 className="text-lg font-bold">다가오는 일정</h2>
|
||||||
|
<button
|
||||||
|
onClick={() => navigate('/schedule')}
|
||||||
|
className="text-primary text-sm flex items-center gap-1"
|
||||||
|
>
|
||||||
|
전체보기 <ChevronRight size={16} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{schedules.length > 0 ? (
|
||||||
|
<div className="space-y-3">
|
||||||
|
{schedules.map((schedule, index) => {
|
||||||
|
const scheduleDate = new Date(schedule.date);
|
||||||
|
const today = new Date();
|
||||||
|
const currentYear = today.getFullYear();
|
||||||
|
const currentMonth = today.getMonth();
|
||||||
|
|
||||||
|
const scheduleYear = scheduleDate.getFullYear();
|
||||||
|
const scheduleMonth = scheduleDate.getMonth();
|
||||||
|
const isCurrentYear = scheduleYear === currentYear;
|
||||||
|
const isCurrentMonth =
|
||||||
|
isCurrentYear && scheduleMonth === currentMonth;
|
||||||
|
|
||||||
|
// 멤버 처리
|
||||||
|
const memberList = schedule.member_names
|
||||||
|
? schedule.member_names
|
||||||
|
.split(',')
|
||||||
|
.map((n) => n.trim())
|
||||||
|
.filter(Boolean)
|
||||||
|
: schedule.members?.map((m) => m.name) || [];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<motion.div
|
||||||
|
key={schedule.id}
|
||||||
|
className="flex gap-4 bg-white p-4 rounded-xl shadow-sm border border-gray-100 overflow-hidden"
|
||||||
|
initial={{ opacity: 0, x: -20 }}
|
||||||
|
animate={{ opacity: 1, x: 0 }}
|
||||||
|
transition={{ delay: 0.8 + index * 0.1, duration: 0.3 }}
|
||||||
|
whileTap={{ scale: 0.98 }}
|
||||||
|
onClick={() => navigate('/schedule')}
|
||||||
|
>
|
||||||
|
{/* 날짜 영역 */}
|
||||||
|
<div className="flex flex-col items-center justify-center min-w-[50px]">
|
||||||
|
{!isCurrentYear && (
|
||||||
|
<span className="text-[10px] text-gray-400 font-medium">
|
||||||
|
{scheduleYear}.{scheduleMonth + 1}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{isCurrentYear && !isCurrentMonth && (
|
||||||
|
<span className="text-[10px] text-gray-400 font-medium">
|
||||||
|
{scheduleMonth + 1}월
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<span className="text-2xl font-bold text-primary">
|
||||||
|
{scheduleDate.getDate()}
|
||||||
|
</span>
|
||||||
|
<span className="text-xs text-gray-400 font-medium">
|
||||||
|
{
|
||||||
|
['일', '월', '화', '수', '목', '금', '토'][
|
||||||
|
scheduleDate.getDay()
|
||||||
|
]
|
||||||
|
}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 세로 구분선 */}
|
||||||
|
<div className="w-px bg-gray-100" />
|
||||||
|
|
||||||
|
{/* 내용 영역 */}
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<p className="font-semibold text-sm text-gray-800 line-clamp-2 leading-snug">
|
||||||
|
{schedule.title}
|
||||||
|
</p>
|
||||||
|
{/* 시간 + 카테고리 */}
|
||||||
|
<div className="flex items-center gap-3 mt-2 text-xs text-gray-400">
|
||||||
|
{schedule.time && (
|
||||||
|
<span className="flex items-center gap-1">
|
||||||
|
<Clock size={12} />
|
||||||
|
{schedule.time.slice(0, 5)}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{schedule.category_name && (
|
||||||
|
<span className="flex items-center gap-1">
|
||||||
|
<Tag size={12} />
|
||||||
|
{schedule.category_name}
|
||||||
|
{schedule.source?.name && ` · ${schedule.source.name}`}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{/* 멤버 */}
|
||||||
|
{memberList.length > 0 && (
|
||||||
|
<div className="flex flex-wrap gap-1 mt-2">
|
||||||
|
{memberList.map((name, i) => (
|
||||||
|
<span
|
||||||
|
key={i}
|
||||||
|
className="px-2 py-0.5 bg-primary/10 text-primary text-[10px] rounded-full font-medium"
|
||||||
|
>
|
||||||
|
{name.trim()}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="text-center py-8 text-gray-400">
|
||||||
|
<p>다가오는 일정이 없습니다</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</motion.section>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default MobileHome;
|
||||||
318
frontend-temp/src/pages/home/pc/Home.jsx
Normal file
318
frontend-temp/src/pages/home/pc/Home.jsx
Normal file
|
|
@ -0,0 +1,318 @@
|
||||||
|
import { motion } from 'framer-motion';
|
||||||
|
import { Link } from 'react-router-dom';
|
||||||
|
import { Calendar, ArrowRight, Clock, Tag, Music } from 'lucide-react';
|
||||||
|
import { useMembers, useAlbums, useUpcomingSchedules } from '@/hooks';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* PC 홈 페이지
|
||||||
|
*/
|
||||||
|
function Home() {
|
||||||
|
// 멤버 데이터
|
||||||
|
const { data: members = [] } = useMembers();
|
||||||
|
|
||||||
|
// 앨범 데이터 (최신 4개)
|
||||||
|
const { data: allAlbums = [] } = useAlbums();
|
||||||
|
const albums = allAlbums.slice(0, 4);
|
||||||
|
|
||||||
|
// 다가오는 일정 (3개)
|
||||||
|
const { data: upcomingSchedules = [] } = useUpcomingSchedules(3);
|
||||||
|
|
||||||
|
// D+Day 계산
|
||||||
|
const debutDate = new Date('2018-01-24');
|
||||||
|
const today = new Date();
|
||||||
|
const dDay = Math.floor((today - debutDate) / (1000 * 60 * 60 * 24)) + 1;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
{/* 히어로 섹션 */}
|
||||||
|
<section className="relative h-[600px] bg-gradient-to-br from-primary to-primary-dark overflow-hidden">
|
||||||
|
<div className="absolute inset-0 bg-black/20" />
|
||||||
|
<div className="relative max-w-7xl mx-auto px-6 h-full flex items-center">
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, y: 30 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
transition={{ duration: 0.8 }}
|
||||||
|
className="text-white"
|
||||||
|
>
|
||||||
|
<h1 className="text-6xl font-bold mb-4">fromis_9</h1>
|
||||||
|
<p className="text-2xl font-light mb-2">프로미스나인</p>
|
||||||
|
<p className="text-lg opacity-80 leading-relaxed">
|
||||||
|
인사드리겠습니다. 둘, 셋!
|
||||||
|
<br />
|
||||||
|
이제는 약속해 소중히 간직해,
|
||||||
|
<br />
|
||||||
|
당신의 아이돌로 성장하겠습니다!
|
||||||
|
</p>
|
||||||
|
</motion.div>
|
||||||
|
</div>
|
||||||
|
{/* 장식 */}
|
||||||
|
<div className="absolute right-0 bottom-0 w-1/2 h-full opacity-10">
|
||||||
|
<div className="absolute right-10 top-20 w-64 h-64 rounded-full bg-white/30" />
|
||||||
|
<div className="absolute right-40 bottom-20 w-48 h-48 rounded-full bg-white/20" />
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* 그룹 통계 */}
|
||||||
|
<section className="py-16 bg-gray-50">
|
||||||
|
<div className="max-w-7xl mx-auto px-6">
|
||||||
|
<motion.div
|
||||||
|
className="grid grid-cols-4 gap-6"
|
||||||
|
initial="hidden"
|
||||||
|
whileInView="visible"
|
||||||
|
viewport={{ once: true, amount: 0.1 }}
|
||||||
|
variants={{
|
||||||
|
hidden: { opacity: 1 },
|
||||||
|
visible: { opacity: 1, transition: { staggerChildren: 0.1 } },
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{[
|
||||||
|
{ value: '2018.01.24', label: '데뷔일' },
|
||||||
|
{ value: `D+${dDay.toLocaleString()}`, label: 'D+Day' },
|
||||||
|
{ value: '5', label: '멤버 수' },
|
||||||
|
{ value: 'flover', label: '팬덤명' },
|
||||||
|
].map((stat, index) => (
|
||||||
|
<motion.div
|
||||||
|
key={index}
|
||||||
|
variants={{
|
||||||
|
hidden: { opacity: 0, y: 20 },
|
||||||
|
visible: {
|
||||||
|
opacity: 1,
|
||||||
|
y: 0,
|
||||||
|
transition: { duration: 0.4, ease: 'easeOut' },
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
className="bg-gradient-to-br from-primary to-primary-dark rounded-2xl p-6 text-white text-center"
|
||||||
|
>
|
||||||
|
<p className="text-3xl font-bold mb-1">{stat.value}</p>
|
||||||
|
<p className="text-white/70 text-sm">{stat.label}</p>
|
||||||
|
</motion.div>
|
||||||
|
))}
|
||||||
|
</motion.div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* 멤버 미리보기 */}
|
||||||
|
<section className="py-16 bg-gray-50">
|
||||||
|
<div className="max-w-7xl mx-auto px-6">
|
||||||
|
<div className="flex justify-between items-center mb-8">
|
||||||
|
<h2 className="text-3xl font-bold">멤버</h2>
|
||||||
|
<Link
|
||||||
|
to="/members"
|
||||||
|
className="text-primary hover:underline flex items-center gap-1"
|
||||||
|
>
|
||||||
|
전체보기 <ArrowRight size={16} />
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-5 gap-6">
|
||||||
|
{members
|
||||||
|
.filter((m) => !m.is_former)
|
||||||
|
.map((member, index) => (
|
||||||
|
<motion.div
|
||||||
|
key={member.id}
|
||||||
|
initial={{ opacity: 0, y: 30 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
transition={{
|
||||||
|
delay: 0.3 + index * 0.1,
|
||||||
|
duration: 0.5,
|
||||||
|
ease: 'easeOut',
|
||||||
|
}}
|
||||||
|
className="group relative rounded-2xl overflow-hidden shadow-sm hover:shadow-xl transition-all duration-300"
|
||||||
|
>
|
||||||
|
<div className="aspect-[3/4] overflow-hidden">
|
||||||
|
<img
|
||||||
|
src={member.image_url}
|
||||||
|
alt={member.name}
|
||||||
|
className="w-full h-full object-cover transition-transform duration-500 group-hover:scale-110"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="absolute inset-0 bg-gradient-to-t from-black/80 via-black/20 to-transparent opacity-60 group-hover:opacity-90 transition-opacity duration-300" />
|
||||||
|
<div className="absolute bottom-0 left-0 right-0 p-5 text-white">
|
||||||
|
<h3 className="font-bold text-xl drop-shadow-lg">
|
||||||
|
{member.name}
|
||||||
|
</h3>
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* 앨범 미리보기 */}
|
||||||
|
<section className="py-16 bg-gray-50">
|
||||||
|
<div className="max-w-7xl mx-auto px-6">
|
||||||
|
<div className="flex justify-between items-center mb-8">
|
||||||
|
<h2 className="text-3xl font-bold">앨범</h2>
|
||||||
|
<Link
|
||||||
|
to="/album"
|
||||||
|
className="text-primary hover:underline flex items-center gap-1"
|
||||||
|
>
|
||||||
|
전체보기 <ArrowRight size={16} />
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-4 gap-6">
|
||||||
|
{albums.map((album, index) => (
|
||||||
|
<motion.div
|
||||||
|
key={album.id}
|
||||||
|
initial={{ opacity: 0, y: 30 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
transition={{
|
||||||
|
delay: 0.3 + index * 0.1,
|
||||||
|
duration: 0.5,
|
||||||
|
ease: 'easeOut',
|
||||||
|
}}
|
||||||
|
className="group cursor-pointer"
|
||||||
|
onClick={() =>
|
||||||
|
(window.location.href = `/album/${encodeURIComponent(album.title)}`)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<div className="relative aspect-square rounded-2xl overflow-hidden shadow-md hover:shadow-xl transition-all duration-300">
|
||||||
|
<img
|
||||||
|
src={album.cover_medium_url || album.cover_original_url}
|
||||||
|
alt={album.title}
|
||||||
|
className="w-full h-full object-cover transition-transform duration-500 group-hover:scale-105"
|
||||||
|
/>
|
||||||
|
<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={32} className="mx-auto mb-2" />
|
||||||
|
<p className="text-sm">{album.tracks?.length || 0}곡 수록</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="mt-3">
|
||||||
|
<h3 className="font-bold text-lg truncate">{album.title}</h3>
|
||||||
|
<p className="text-gray-500 text-sm">
|
||||||
|
{album.release_date?.slice(0, 4)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* 일정 미리보기 */}
|
||||||
|
<section className="py-16 bg-gray-50">
|
||||||
|
<div className="max-w-7xl mx-auto px-6">
|
||||||
|
<div className="flex justify-between items-center mb-8">
|
||||||
|
<h2 className="text-3xl font-bold">다가오는 일정</h2>
|
||||||
|
<Link
|
||||||
|
to="/schedule"
|
||||||
|
className="text-primary hover:underline flex items-center gap-1"
|
||||||
|
>
|
||||||
|
전체보기 <ArrowRight size={16} />
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
{upcomingSchedules.length === 0 ? (
|
||||||
|
<div className="text-center py-12 text-gray-400">
|
||||||
|
<Calendar size={48} className="mx-auto mb-4 opacity-30" />
|
||||||
|
<p>예정된 일정이 없습니다</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<motion.div
|
||||||
|
className="space-y-4"
|
||||||
|
initial="hidden"
|
||||||
|
whileInView="visible"
|
||||||
|
viewport={{ once: true, amount: 0.1 }}
|
||||||
|
variants={{
|
||||||
|
hidden: { opacity: 1 },
|
||||||
|
visible: { opacity: 1, transition: { staggerChildren: 0.1 } },
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{upcomingSchedules.map((schedule) => {
|
||||||
|
const scheduleDate = new Date(schedule.date);
|
||||||
|
const todayDate = new Date();
|
||||||
|
const currentYear = todayDate.getFullYear();
|
||||||
|
const currentMonth = todayDate.getMonth();
|
||||||
|
|
||||||
|
const scheduleYear = scheduleDate.getFullYear();
|
||||||
|
const scheduleMonth = scheduleDate.getMonth();
|
||||||
|
const isCurrentYear = scheduleYear === currentYear;
|
||||||
|
const isCurrentMonth =
|
||||||
|
isCurrentYear && scheduleMonth === currentMonth;
|
||||||
|
|
||||||
|
const day = scheduleDate.getDate();
|
||||||
|
const weekdays = ['일', '월', '화', '수', '목', '금', '토'];
|
||||||
|
const weekday = weekdays[scheduleDate.getDay()];
|
||||||
|
|
||||||
|
// 멤버 처리
|
||||||
|
const memberList = schedule.member_names
|
||||||
|
? schedule.member_names.split(',')
|
||||||
|
: schedule.members?.map((m) => m.name) || [];
|
||||||
|
|
||||||
|
const categoryColor = schedule.category_color || '#6366f1';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<motion.div
|
||||||
|
key={schedule.id}
|
||||||
|
variants={{
|
||||||
|
hidden: { opacity: 0, x: -30 },
|
||||||
|
visible: {
|
||||||
|
opacity: 1,
|
||||||
|
x: 0,
|
||||||
|
transition: { duration: 0.4, ease: 'easeOut' },
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
className="flex items-stretch bg-white rounded-2xl shadow-sm hover:shadow-md transition-shadow overflow-hidden cursor-pointer"
|
||||||
|
>
|
||||||
|
{/* 날짜 영역 */}
|
||||||
|
<div
|
||||||
|
className="w-24 flex flex-col items-center justify-center text-white py-6"
|
||||||
|
style={{ backgroundColor: categoryColor }}
|
||||||
|
>
|
||||||
|
{!isCurrentYear && (
|
||||||
|
<span className="text-xs font-medium opacity-70">
|
||||||
|
{scheduleYear}.{scheduleMonth + 1}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{isCurrentYear && !isCurrentMonth && (
|
||||||
|
<span className="text-xs font-medium opacity-70">
|
||||||
|
{scheduleMonth + 1}월
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<span className="text-3xl font-bold">{day}</span>
|
||||||
|
<span className="text-sm font-medium opacity-80">
|
||||||
|
{weekday}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 내용 영역 */}
|
||||||
|
<div className="flex-1 p-6 flex flex-col justify-center">
|
||||||
|
<h3 className="font-bold text-lg mb-2">{schedule.title}</h3>
|
||||||
|
<div className="flex flex-wrap gap-3 text-base text-gray-500">
|
||||||
|
{schedule.time && (
|
||||||
|
<span className="flex items-center gap-1">
|
||||||
|
<Clock size={16} className="opacity-60" />
|
||||||
|
{schedule.time.slice(0, 5)}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<span className="flex items-center gap-1">
|
||||||
|
<Tag size={16} className="opacity-60" />
|
||||||
|
{schedule.category_name}
|
||||||
|
{schedule.source?.name && ` · ${schedule.source.name}`}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{memberList.length > 0 && (
|
||||||
|
<div className="flex flex-wrap gap-1.5 mt-2">
|
||||||
|
{memberList.map((name, i) => (
|
||||||
|
<span
|
||||||
|
key={i}
|
||||||
|
className="px-2 py-0.5 bg-primary/10 text-primary text-sm font-medium rounded-full"
|
||||||
|
>
|
||||||
|
{name.trim()}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default Home;
|
||||||
2
frontend-temp/src/pages/index.js
Normal file
2
frontend-temp/src/pages/index.js
Normal file
|
|
@ -0,0 +1,2 @@
|
||||||
|
// 홈 페이지
|
||||||
|
export * from './home';
|
||||||
Loading…
Add table
Reference in a new issue