feat(frontend): Phase 7 - 레이아웃 및 스케줄 페이지 마이그레이션
레이아웃 컴포넌트: - Header: PC용 헤더 (네비게이션 + SNS 링크) - MobileNav: 모바일 하단 네비게이션 - Footer: PC용 푸터 - Layout: PC/Mobile 통합 레이아웃 (useIsMobile 기반 분기) 스케줄 페이지 (기본 구조): - PC: 좌측 캘린더 + 우측 일정 목록 - Mobile: 상단 네비게이션 + 일정 목록 - 월 변경, 날짜 선택, 일정 표시 기능 App.jsx 업데이트: - 라우팅 설정 (/, /schedule, /members, /album) - Layout 컴포넌트 적용 상수 추가: - NAV_ITEMS: 네비게이션 메뉴 항목 Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
84030019cd
commit
64fc07044d
11 changed files with 760 additions and 114 deletions
|
|
@ -1,25 +1,17 @@
|
||||||
import { BrowserRouter, Routes, Route } from "react-router-dom";
|
import { BrowserRouter, Routes, Route, NavLink } from "react-router-dom";
|
||||||
import { cn, getTodayKST, formatFullDate } from "@/utils";
|
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 } from "@/components";
|
import { ErrorBoundary, Loading, ToastContainer, ScheduleCard, Layout } from "@/components";
|
||||||
|
import { Schedule } from "@/pages";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 프로미스나인 팬사이트 메인 앱
|
* 홈 페이지 (임시)
|
||||||
*
|
|
||||||
* Phase 6: 공통 컴포넌트 완료
|
|
||||||
* - ErrorBoundary: 에러 경계
|
|
||||||
* - Loading, FullPageLoading, InlineLoading: 로딩 스피너
|
|
||||||
* - Toast, ToastContainer: 토스트 알림
|
|
||||||
* - Lightbox: 이미지 라이트박스
|
|
||||||
* - ScheduleCard: 스케줄 카드 (list/card/compact)
|
|
||||||
*/
|
*/
|
||||||
function App() {
|
function Home() {
|
||||||
const today = getTodayKST();
|
const today = getTodayKST();
|
||||||
const isMobile = useIsMobile();
|
const isMobile = useIsMobile();
|
||||||
const { showSuccess, showError } = useUIStore();
|
const { showSuccess, showError } = useUIStore();
|
||||||
|
|
||||||
// 커스텀 훅 사용
|
|
||||||
const { data: categories, isLoading: categoriesLoading } = useCategories();
|
const { data: categories, isLoading: categoriesLoading } = useCategories();
|
||||||
const currentDate = new Date();
|
const currentDate = new Date();
|
||||||
const { data: schedules, isLoading: schedulesLoading } = useScheduleData(
|
const { data: schedules, isLoading: schedulesLoading } = useScheduleData(
|
||||||
|
|
@ -28,19 +20,13 @@ function App() {
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<BrowserRouter>
|
<div className={cn("p-4", isMobile ? "pb-20" : "")}>
|
||||||
<ErrorBoundary>
|
|
||||||
<Routes>
|
|
||||||
<Route
|
|
||||||
path="/"
|
|
||||||
element={
|
|
||||||
<div className="min-h-screen bg-gray-50 p-4">
|
|
||||||
<div className="max-w-2xl mx-auto space-y-4">
|
<div className="max-w-2xl mx-auto space-y-4">
|
||||||
<div className="text-center">
|
<div className="text-center">
|
||||||
<h1 className="text-2xl font-bold text-primary mb-2">
|
<h1 className="text-2xl font-bold text-primary mb-2">
|
||||||
fromis_9 Frontend Refactoring
|
fromis_9 Frontend Refactoring
|
||||||
</h1>
|
</h1>
|
||||||
<p className="text-gray-600">Phase 6 완료 - 공통 컴포넌트</p>
|
<p className="text-gray-600">Phase 7 진행 중 - 스케줄 페이지</p>
|
||||||
<p className={cn("text-sm", isMobile ? "text-blue-500" : "text-green-500")}>
|
<p className={cn("text-sm", isMobile ? "text-blue-500" : "text-green-500")}>
|
||||||
디바이스: {isMobile ? "모바일" : "PC"}
|
디바이스: {isMobile ? "모바일" : "PC"}
|
||||||
</p>
|
</p>
|
||||||
|
|
@ -85,6 +71,18 @@ function App() {
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="border-t pt-3">
|
||||||
|
<p className="font-semibold mb-2">페이지 이동</p>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<NavLink
|
||||||
|
to="/schedule"
|
||||||
|
className="px-3 py-1 bg-primary text-white rounded text-xs"
|
||||||
|
>
|
||||||
|
일정 페이지
|
||||||
|
</NavLink>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* ScheduleCard 컴포넌트 테스트 */}
|
{/* ScheduleCard 컴포넌트 테스트 */}
|
||||||
|
|
@ -132,10 +130,60 @@ function App() {
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 토스트 컨테이너 */}
|
|
||||||
<ToastContainer />
|
|
||||||
</div>
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 프로미스나인 팬사이트 메인 앱
|
||||||
|
*
|
||||||
|
* Phase 7: 스케줄 페이지 마이그레이션
|
||||||
|
* - Layout 컴포넌트 (PC/Mobile 통합)
|
||||||
|
* - Header, Footer, MobileNav 컴포넌트
|
||||||
|
* - Schedule 페이지 (기본 구조)
|
||||||
|
*/
|
||||||
|
function App() {
|
||||||
|
return (
|
||||||
|
<BrowserRouter>
|
||||||
|
<ErrorBoundary>
|
||||||
|
<Routes>
|
||||||
|
{/* 홈 */}
|
||||||
|
<Route
|
||||||
|
path="/"
|
||||||
|
element={
|
||||||
|
<Layout>
|
||||||
|
<Home />
|
||||||
|
<ToastContainer />
|
||||||
|
</Layout>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* 스케줄 */}
|
||||||
|
<Route
|
||||||
|
path="/schedule"
|
||||||
|
element={
|
||||||
|
<Layout useCustomLayout>
|
||||||
|
<Schedule />
|
||||||
|
<ToastContainer />
|
||||||
|
</Layout>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* 임시 페이지들 */}
|
||||||
|
<Route
|
||||||
|
path="/members"
|
||||||
|
element={
|
||||||
|
<Layout pageTitle="멤버">
|
||||||
|
<div className="p-4 text-center text-gray-500">멤버 페이지 (준비 중)</div>
|
||||||
|
</Layout>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path="/album"
|
||||||
|
element={
|
||||||
|
<Layout pageTitle="앨범">
|
||||||
|
<div className="p-4 text-center text-gray-500">앨범 페이지 (준비 중)</div>
|
||||||
|
</Layout>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
</Routes>
|
</Routes>
|
||||||
|
|
|
||||||
|
|
@ -5,5 +5,8 @@
|
||||||
// 공통 컴포넌트
|
// 공통 컴포넌트
|
||||||
export * from './common';
|
export * from './common';
|
||||||
|
|
||||||
|
// 레이아웃 컴포넌트
|
||||||
|
export * from './layout';
|
||||||
|
|
||||||
// 스케줄 컴포넌트
|
// 스케줄 컴포넌트
|
||||||
export * from './schedule';
|
export * from './schedule';
|
||||||
|
|
|
||||||
18
frontend-temp/src/components/layout/Footer.jsx
Normal file
18
frontend-temp/src/components/layout/Footer.jsx
Normal file
|
|
@ -0,0 +1,18 @@
|
||||||
|
/**
|
||||||
|
* PC용 푸터 컴포넌트
|
||||||
|
*/
|
||||||
|
function Footer() {
|
||||||
|
const currentYear = new Date().getFullYear();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<footer className="bg-gray-900 text-white py-8">
|
||||||
|
<div className="max-w-7xl mx-auto px-6">
|
||||||
|
<div className="text-center text-sm text-gray-500">
|
||||||
|
<p>© {currentYear} fromis_9 Fan Site. This is an unofficial fan-made website.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</footer>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default Footer;
|
||||||
77
frontend-temp/src/components/layout/Header.jsx
Normal file
77
frontend-temp/src/components/layout/Header.jsx
Normal file
|
|
@ -0,0 +1,77 @@
|
||||||
|
import { NavLink } from 'react-router-dom';
|
||||||
|
import { Instagram, Youtube } from 'lucide-react';
|
||||||
|
import { SOCIAL_LINKS, NAV_ITEMS } from '@/constants';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* X (Twitter) 아이콘 컴포넌트
|
||||||
|
*/
|
||||||
|
const XIcon = ({ size = 20 }) => (
|
||||||
|
<svg width={size} height={size} viewBox="0 0 24 24" fill="currentColor">
|
||||||
|
<path d="M18.244 2.25h3.308l-7.227 8.26 8.502 11.24H16.17l-5.214-6.817L4.99 21.75H1.68l7.73-8.835L1.254 2.25H8.08l4.713 6.231zm-1.161 17.52h1.833L7.084 4.126H5.117z" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* PC용 헤더 컴포넌트
|
||||||
|
*/
|
||||||
|
function Header() {
|
||||||
|
return (
|
||||||
|
<header className="bg-white shadow-sm sticky top-0 z-50">
|
||||||
|
<div className="px-24">
|
||||||
|
<div className="flex items-center justify-between h-16">
|
||||||
|
{/* 로고 */}
|
||||||
|
<NavLink to="/" className="flex items-center gap-2">
|
||||||
|
<span className="text-2xl font-bold text-primary">fromis_9</span>
|
||||||
|
</NavLink>
|
||||||
|
|
||||||
|
{/* 네비게이션 */}
|
||||||
|
<nav className="flex items-center gap-8">
|
||||||
|
{NAV_ITEMS.map((item) => (
|
||||||
|
<NavLink
|
||||||
|
key={item.path}
|
||||||
|
to={item.path}
|
||||||
|
className={({ isActive }) =>
|
||||||
|
`text-sm font-medium transition-colors hover:text-primary ${
|
||||||
|
isActive ? 'text-primary' : 'text-gray-600'
|
||||||
|
}`
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{item.label}
|
||||||
|
</NavLink>
|
||||||
|
))}
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
{/* SNS 링크 */}
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<a
|
||||||
|
href={SOCIAL_LINKS.youtube}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="text-gray-500 hover:text-red-600 transition-colors"
|
||||||
|
>
|
||||||
|
<Youtube size={20} />
|
||||||
|
</a>
|
||||||
|
<a
|
||||||
|
href={SOCIAL_LINKS.instagram}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="text-gray-500 hover:text-pink-600 transition-colors"
|
||||||
|
>
|
||||||
|
<Instagram size={20} />
|
||||||
|
</a>
|
||||||
|
<a
|
||||||
|
href={SOCIAL_LINKS.twitter}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="text-gray-500 hover:text-black transition-colors"
|
||||||
|
>
|
||||||
|
<XIcon size={20} />
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default Header;
|
||||||
104
frontend-temp/src/components/layout/Layout.jsx
Normal file
104
frontend-temp/src/components/layout/Layout.jsx
Normal file
|
|
@ -0,0 +1,104 @@
|
||||||
|
import { useEffect } from 'react';
|
||||||
|
import { useLocation, NavLink } from 'react-router-dom';
|
||||||
|
import { useIsMobile } from '@/hooks';
|
||||||
|
import Header from './Header';
|
||||||
|
import Footer from './Footer';
|
||||||
|
import MobileNav from './MobileNav';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 모바일 헤더 컴포넌트
|
||||||
|
*/
|
||||||
|
function MobileHeader({ title, noShadow = false }) {
|
||||||
|
return (
|
||||||
|
<header className={`bg-white sticky top-0 z-50 ${noShadow ? '' : 'shadow-sm'}`}>
|
||||||
|
<div className="flex items-center justify-center h-14 px-4">
|
||||||
|
{title ? (
|
||||||
|
<span className="text-xl font-bold text-primary">{title}</span>
|
||||||
|
) : (
|
||||||
|
<NavLink to="/" className="text-xl font-bold text-primary">
|
||||||
|
fromis_9
|
||||||
|
</NavLink>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 통합 레이아웃 컴포넌트
|
||||||
|
* PC/Mobile 자동 분기
|
||||||
|
*
|
||||||
|
* @param {object} props
|
||||||
|
* @param {React.ReactNode} props.children - 페이지 컨텐츠
|
||||||
|
* @param {string} props.pageTitle - 모바일 헤더 타이틀 (없으면 fromis_9)
|
||||||
|
* @param {boolean} props.hideHeader - 헤더 숨김 여부 (자체 헤더가 있는 페이지)
|
||||||
|
* @param {boolean} props.hideFooter - 푸터 숨김 여부
|
||||||
|
* @param {boolean} props.useCustomLayout - 자체 레이아웃 사용 (모바일)
|
||||||
|
* @param {boolean} props.noShadow - 모바일 헤더 그림자 숨김
|
||||||
|
*/
|
||||||
|
function Layout({
|
||||||
|
children,
|
||||||
|
pageTitle,
|
||||||
|
hideHeader = false,
|
||||||
|
hideFooter = false,
|
||||||
|
useCustomLayout = false,
|
||||||
|
noShadow = false,
|
||||||
|
}) {
|
||||||
|
const isMobile = useIsMobile();
|
||||||
|
const location = useLocation();
|
||||||
|
|
||||||
|
// 모바일 레이아웃 활성화 (body 스크롤 방지)
|
||||||
|
useEffect(() => {
|
||||||
|
if (isMobile) {
|
||||||
|
document.documentElement.classList.add('mobile-layout');
|
||||||
|
return () => {
|
||||||
|
document.documentElement.classList.remove('mobile-layout');
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}, [isMobile]);
|
||||||
|
|
||||||
|
// PC 레이아웃
|
||||||
|
if (!isMobile) {
|
||||||
|
// Footer 숨김 페이지 (화면 고정 레이아웃)
|
||||||
|
const hideFooterPages = ['/schedule', '/members', '/album'];
|
||||||
|
const shouldHideFooter = hideFooter || hideFooterPages.some(
|
||||||
|
(path) => location.pathname === path || location.pathname.startsWith(path + '/')
|
||||||
|
);
|
||||||
|
|
||||||
|
// 일정 페이지에서는 스크롤바도 숨김 (내부에서 자체 스크롤 처리)
|
||||||
|
const isSchedulePage = location.pathname === '/schedule';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="h-screen overflow-hidden flex flex-col">
|
||||||
|
{!hideHeader && <Header />}
|
||||||
|
<main className={`flex-1 min-h-0 flex flex-col ${isSchedulePage ? 'overflow-hidden' : 'overflow-y-auto'}`}>
|
||||||
|
<div className="flex-1 flex flex-col">
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
{!shouldHideFooter && <Footer />}
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 모바일 레이아웃 - 자체 레이아웃 사용 시 (Schedule 페이지 등)
|
||||||
|
if (useCustomLayout) {
|
||||||
|
return (
|
||||||
|
<div className="mobile-layout-container bg-white">
|
||||||
|
{children}
|
||||||
|
<MobileNav />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 모바일 레이아웃 - 기본
|
||||||
|
return (
|
||||||
|
<div className="mobile-layout-container bg-white">
|
||||||
|
{!hideHeader && <MobileHeader title={pageTitle} noShadow={noShadow} />}
|
||||||
|
<main className="mobile-content">{children}</main>
|
||||||
|
<MobileNav />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default Layout;
|
||||||
44
frontend-temp/src/components/layout/MobileNav.jsx
Normal file
44
frontend-temp/src/components/layout/MobileNav.jsx
Normal file
|
|
@ -0,0 +1,44 @@
|
||||||
|
import { NavLink, useLocation } from 'react-router-dom';
|
||||||
|
import { Home, Users, Disc3, Calendar } from 'lucide-react';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 모바일 하단 네비게이션 컴포넌트
|
||||||
|
*/
|
||||||
|
function MobileNav() {
|
||||||
|
const location = useLocation();
|
||||||
|
|
||||||
|
const navItems = [
|
||||||
|
{ path: '/', label: '홈', icon: Home },
|
||||||
|
{ path: '/members', label: '멤버', icon: Users },
|
||||||
|
{ path: '/album', label: '앨범', icon: Disc3 },
|
||||||
|
{ path: '/schedule', label: '일정', icon: Calendar },
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<nav className="flex-shrink-0 bg-white border-t border-gray-200 z-50 safe-area-bottom">
|
||||||
|
<div className="flex items-center justify-around h-16">
|
||||||
|
{navItems.map((item) => {
|
||||||
|
const Icon = item.icon;
|
||||||
|
const isActive = location.pathname === item.path ||
|
||||||
|
(item.path !== '/' && location.pathname.startsWith(item.path));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<NavLink
|
||||||
|
key={item.path}
|
||||||
|
to={item.path}
|
||||||
|
onClick={() => window.scrollTo(0, 0)}
|
||||||
|
className={`flex flex-col items-center justify-center gap-1 w-full h-full transition-colors ${
|
||||||
|
isActive ? 'text-primary' : 'text-gray-400'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<Icon size={22} strokeWidth={isActive ? 2.5 : 2} />
|
||||||
|
<span className="text-xs font-medium">{item.label}</span>
|
||||||
|
</NavLink>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default MobileNav;
|
||||||
7
frontend-temp/src/components/layout/index.js
Normal file
7
frontend-temp/src/components/layout/index.js
Normal file
|
|
@ -0,0 +1,7 @@
|
||||||
|
/**
|
||||||
|
* 레이아웃 컴포넌트 export
|
||||||
|
*/
|
||||||
|
export { default as Layout } from './Layout';
|
||||||
|
export { default as Header } from './Header';
|
||||||
|
export { default as Footer } from './Footer';
|
||||||
|
export { default as MobileNav } from './MobileNav';
|
||||||
|
|
@ -57,3 +57,11 @@ export const MONTH_NAMES = [
|
||||||
'1월', '2월', '3월', '4월', '5월', '6월',
|
'1월', '2월', '3월', '4월', '5월', '6월',
|
||||||
'7월', '8월', '9월', '10월', '11월', '12월',
|
'7월', '8월', '9월', '10월', '11월', '12월',
|
||||||
];
|
];
|
||||||
|
|
||||||
|
/** 네비게이션 메뉴 항목 */
|
||||||
|
export const NAV_ITEMS = [
|
||||||
|
{ path: '/', label: '홈' },
|
||||||
|
{ path: '/members', label: '멤버' },
|
||||||
|
{ path: '/album', label: '앨범' },
|
||||||
|
{ path: '/schedule', label: '일정' },
|
||||||
|
];
|
||||||
|
|
|
||||||
4
frontend-temp/src/pages/index.js
Normal file
4
frontend-temp/src/pages/index.js
Normal file
|
|
@ -0,0 +1,4 @@
|
||||||
|
/**
|
||||||
|
* 페이지 export
|
||||||
|
*/
|
||||||
|
export * from './schedule';
|
||||||
329
frontend-temp/src/pages/schedule/Schedule.jsx
Normal file
329
frontend-temp/src/pages/schedule/Schedule.jsx
Normal file
|
|
@ -0,0 +1,329 @@
|
||||||
|
import { useState, useMemo, useEffect } from 'react';
|
||||||
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
import { ChevronLeft, ChevronRight, Search } from 'lucide-react';
|
||||||
|
import { useIsMobile, useScheduleData, useCategories, useCalendar } from '@/hooks';
|
||||||
|
import { useScheduleStore } from '@/stores';
|
||||||
|
import { Loading, ScheduleCard } from '@/components';
|
||||||
|
import { cn, getTodayKST, decodeHtmlEntities } from '@/utils';
|
||||||
|
import { WEEKDAYS, MIN_YEAR } from '@/constants';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* PC 캘린더 컴포넌트
|
||||||
|
*/
|
||||||
|
function PCCalendar({ selectedDate, schedules, categories, onSelectDate, onMonthChange }) {
|
||||||
|
const { days, year, month, canGoPrev } = useCalendar(selectedDate);
|
||||||
|
|
||||||
|
// 날짜별 일정 맵
|
||||||
|
const scheduleDates = useMemo(() => {
|
||||||
|
const dateMap = {};
|
||||||
|
schedules?.forEach((schedule) => {
|
||||||
|
const date = schedule.date?.split('T')[0];
|
||||||
|
if (!dateMap[date]) dateMap[date] = [];
|
||||||
|
const cat = categories?.find((c) => c.id === (schedule.category_id || schedule.category?.id));
|
||||||
|
dateMap[date].push(cat?.color || '#6b7280');
|
||||||
|
});
|
||||||
|
return dateMap;
|
||||||
|
}, [schedules, categories]);
|
||||||
|
|
||||||
|
const isToday = (date) => {
|
||||||
|
const today = new Date();
|
||||||
|
return (
|
||||||
|
date.getDate() === today.getDate() &&
|
||||||
|
date.getMonth() === today.getMonth() &&
|
||||||
|
date.getFullYear() === today.getFullYear()
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const isSelected = (date) => {
|
||||||
|
const sel = new Date(selectedDate);
|
||||||
|
return (
|
||||||
|
date.getDate() === sel.getDate() &&
|
||||||
|
date.getMonth() === sel.getMonth() &&
|
||||||
|
date.getFullYear() === sel.getFullYear()
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatDateStr = (date) => {
|
||||||
|
const y = date.getFullYear();
|
||||||
|
const m = String(date.getMonth() + 1).padStart(2, '0');
|
||||||
|
const d = String(date.getDate()).padStart(2, '0');
|
||||||
|
return `${y}-${m}-${d}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="bg-white rounded-2xl shadow-sm p-6">
|
||||||
|
{/* 헤더 */}
|
||||||
|
<div className="flex items-center justify-between mb-6">
|
||||||
|
<button
|
||||||
|
onClick={() => canGoPrev && onMonthChange(-1)}
|
||||||
|
disabled={!canGoPrev}
|
||||||
|
className={cn('p-2 rounded-lg hover:bg-gray-100', !canGoPrev && 'opacity-30 cursor-not-allowed')}
|
||||||
|
>
|
||||||
|
<ChevronLeft size={20} />
|
||||||
|
</button>
|
||||||
|
<h2 className="text-lg font-bold">
|
||||||
|
{year}년 {month + 1}월
|
||||||
|
</h2>
|
||||||
|
<button onClick={() => onMonthChange(1)} className="p-2 rounded-lg hover:bg-gray-100">
|
||||||
|
<ChevronRight size={20} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 요일 헤더 */}
|
||||||
|
<div className="grid grid-cols-7 gap-1 mb-2">
|
||||||
|
{WEEKDAYS.map((day, i) => (
|
||||||
|
<div
|
||||||
|
key={day}
|
||||||
|
className={cn(
|
||||||
|
'text-center text-xs font-medium py-2',
|
||||||
|
i === 0 ? 'text-red-400' : i === 6 ? 'text-blue-400' : 'text-gray-500'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{day}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 날짜 그리드 */}
|
||||||
|
<div className="grid grid-cols-7 gap-1">
|
||||||
|
{days.map((item, index) => {
|
||||||
|
const dayOfWeek = index % 7;
|
||||||
|
const dateStr = formatDateStr(item.date);
|
||||||
|
const colors = scheduleDates[dateStr] || [];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={index}
|
||||||
|
onClick={() => onSelectDate(item.date)}
|
||||||
|
className="flex flex-col items-center py-2"
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
'w-9 h-9 flex items-center justify-center text-sm font-medium rounded-full transition-all',
|
||||||
|
!item.isCurrentMonth
|
||||||
|
? 'text-gray-300'
|
||||||
|
: isSelected(item.date)
|
||||||
|
? 'bg-primary text-white font-bold'
|
||||||
|
: isToday(item.date)
|
||||||
|
? 'text-primary font-bold'
|
||||||
|
: dayOfWeek === 0
|
||||||
|
? 'text-red-500 hover:bg-red-50'
|
||||||
|
: dayOfWeek === 6
|
||||||
|
? 'text-blue-500 hover:bg-blue-50'
|
||||||
|
: 'text-gray-700 hover:bg-gray-100'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{item.day}
|
||||||
|
</span>
|
||||||
|
{/* 일정 점 */}
|
||||||
|
{!isSelected(item.date) && colors.length > 0 && (
|
||||||
|
<div className="flex gap-0.5 mt-1 h-1.5">
|
||||||
|
{colors.slice(0, 3).map((color, i) => (
|
||||||
|
<div
|
||||||
|
key={i}
|
||||||
|
className="w-1 h-1 rounded-full"
|
||||||
|
style={{ backgroundColor: color }}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 스케줄 페이지 (PC/Mobile 통합)
|
||||||
|
*/
|
||||||
|
function Schedule() {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const isMobile = useIsMobile();
|
||||||
|
|
||||||
|
// Zustand store
|
||||||
|
const { currentDate, setCurrentDate, selectedDate, setSelectedDate } = useScheduleStore();
|
||||||
|
|
||||||
|
// 초기값 설정
|
||||||
|
const today = getTodayKST();
|
||||||
|
const actualSelectedDate = selectedDate || today;
|
||||||
|
|
||||||
|
// 데이터 로드
|
||||||
|
const year = currentDate.getFullYear();
|
||||||
|
const month = currentDate.getMonth() + 1;
|
||||||
|
const { data: schedules, isLoading: schedulesLoading } = useScheduleData(year, month);
|
||||||
|
const { data: categories } = useCategories();
|
||||||
|
|
||||||
|
// 선택된 날짜의 일정
|
||||||
|
const selectedDateSchedules = useMemo(() => {
|
||||||
|
if (!schedules) return [];
|
||||||
|
const sel = new Date(actualSelectedDate);
|
||||||
|
const dateStr = `${sel.getFullYear()}-${String(sel.getMonth() + 1).padStart(2, '0')}-${String(sel.getDate()).padStart(2, '0')}`;
|
||||||
|
return schedules
|
||||||
|
.filter((s) => s.date?.split('T')[0] === dateStr)
|
||||||
|
.sort((a, b) => {
|
||||||
|
const aIsBirthday = a.is_birthday || String(a.id).startsWith('birthday-');
|
||||||
|
const bIsBirthday = b.is_birthday || String(b.id).startsWith('birthday-');
|
||||||
|
if (aIsBirthday && !bIsBirthday) return -1;
|
||||||
|
if (!aIsBirthday && bIsBirthday) return 1;
|
||||||
|
return 0;
|
||||||
|
});
|
||||||
|
}, [schedules, actualSelectedDate]);
|
||||||
|
|
||||||
|
// 월 변경
|
||||||
|
const changeMonth = (delta) => {
|
||||||
|
const newDate = new Date(currentDate);
|
||||||
|
newDate.setMonth(newDate.getMonth() + delta);
|
||||||
|
|
||||||
|
// 2017년 1월 이전으로 이동 불가
|
||||||
|
if (newDate.getFullYear() < MIN_YEAR || (newDate.getFullYear() === MIN_YEAR && newDate.getMonth() < 0)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setCurrentDate(newDate);
|
||||||
|
|
||||||
|
// 이번 달이면 오늘 날짜, 다른 달이면 1일 선택
|
||||||
|
const now = new Date();
|
||||||
|
if (newDate.getFullYear() === now.getFullYear() && newDate.getMonth() === now.getMonth()) {
|
||||||
|
setSelectedDate(getTodayKST());
|
||||||
|
} else {
|
||||||
|
const firstDay = `${newDate.getFullYear()}-${String(newDate.getMonth() + 1).padStart(2, '0')}-01`;
|
||||||
|
setSelectedDate(firstDay);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 날짜 선택
|
||||||
|
const handleSelectDate = (date) => {
|
||||||
|
const dateStr = `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}`;
|
||||||
|
setSelectedDate(dateStr);
|
||||||
|
|
||||||
|
// 월이 다르면 currentDate도 변경
|
||||||
|
if (date.getMonth() !== currentDate.getMonth() || date.getFullYear() !== currentDate.getFullYear()) {
|
||||||
|
setCurrentDate(date);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// PC 레이아웃
|
||||||
|
if (!isMobile) {
|
||||||
|
return (
|
||||||
|
<div className="flex-1 flex overflow-hidden">
|
||||||
|
{/* 좌측: 캘린더 */}
|
||||||
|
<div className="w-80 flex-shrink-0 p-6 border-r overflow-y-auto">
|
||||||
|
<PCCalendar
|
||||||
|
selectedDate={actualSelectedDate}
|
||||||
|
schedules={schedules}
|
||||||
|
categories={categories}
|
||||||
|
onSelectDate={handleSelectDate}
|
||||||
|
onMonthChange={changeMonth}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 우측: 일정 목록 */}
|
||||||
|
<div className="flex-1 flex flex-col overflow-hidden">
|
||||||
|
{/* 헤더 */}
|
||||||
|
<div className="flex items-center justify-between px-6 py-4 border-b">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<h2 className="text-lg font-bold">
|
||||||
|
{new Date(actualSelectedDate).getMonth() + 1}월{' '}
|
||||||
|
{new Date(actualSelectedDate).getDate()}일{' '}
|
||||||
|
{WEEKDAYS[new Date(actualSelectedDate).getDay()]}요일
|
||||||
|
</h2>
|
||||||
|
<span className="text-sm text-gray-500">
|
||||||
|
{selectedDateSchedules.length}개의 일정
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<button className="p-2 hover:bg-gray-100 rounded-lg">
|
||||||
|
<Search size={20} className="text-gray-500" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 일정 목록 */}
|
||||||
|
<div className="flex-1 overflow-y-auto p-6">
|
||||||
|
{schedulesLoading ? (
|
||||||
|
<div className="flex justify-center py-12">
|
||||||
|
<Loading text="일정 로딩 중..." />
|
||||||
|
</div>
|
||||||
|
) : selectedDateSchedules.length === 0 ? (
|
||||||
|
<div className="text-center py-12 text-gray-400">
|
||||||
|
{new Date(actualSelectedDate).getMonth() + 1}월{' '}
|
||||||
|
{new Date(actualSelectedDate).getDate()}일 일정이 없습니다
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{selectedDateSchedules.map((schedule) => (
|
||||||
|
<ScheduleCard
|
||||||
|
key={schedule.id}
|
||||||
|
schedule={schedule}
|
||||||
|
variant="public"
|
||||||
|
showDate={false}
|
||||||
|
onClick={() => navigate(`/schedule/${schedule.id}`)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 모바일 레이아웃 (간소화된 버전)
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{/* 모바일 툴바 */}
|
||||||
|
<div className="mobile-toolbar-schedule shadow-sm z-50">
|
||||||
|
<div className="flex items-center justify-between px-4 py-3">
|
||||||
|
<button
|
||||||
|
onClick={() => changeMonth(-1)}
|
||||||
|
className="p-2"
|
||||||
|
disabled={currentDate.getFullYear() === MIN_YEAR && currentDate.getMonth() === 0}
|
||||||
|
>
|
||||||
|
<ChevronLeft size={20} />
|
||||||
|
</button>
|
||||||
|
<span className="font-bold">
|
||||||
|
{currentDate.getFullYear()}년 {currentDate.getMonth() + 1}월
|
||||||
|
</span>
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<button onClick={() => changeMonth(1)} className="p-2">
|
||||||
|
<ChevronRight size={20} />
|
||||||
|
</button>
|
||||||
|
<button className="p-2">
|
||||||
|
<Search size={20} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 모바일 컨텐츠 */}
|
||||||
|
<div className="mobile-content">
|
||||||
|
<div className="px-4 py-4">
|
||||||
|
{schedulesLoading ? (
|
||||||
|
<div className="flex justify-center py-8">
|
||||||
|
<Loading size="sm" />
|
||||||
|
</div>
|
||||||
|
) : selectedDateSchedules.length === 0 ? (
|
||||||
|
<div className="text-center py-8 text-gray-400">
|
||||||
|
{new Date(actualSelectedDate).getMonth() + 1}월{' '}
|
||||||
|
{new Date(actualSelectedDate).getDate()}일 일정이 없습니다
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-3">
|
||||||
|
{selectedDateSchedules.map((schedule) => (
|
||||||
|
<ScheduleCard
|
||||||
|
key={schedule.id}
|
||||||
|
schedule={schedule}
|
||||||
|
variant="public"
|
||||||
|
showDate={false}
|
||||||
|
onClick={() => navigate(`/schedule/${schedule.id}`)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default Schedule;
|
||||||
4
frontend-temp/src/pages/schedule/index.js
Normal file
4
frontend-temp/src/pages/schedule/index.js
Normal file
|
|
@ -0,0 +1,4 @@
|
||||||
|
/**
|
||||||
|
* 스케줄 페이지 export
|
||||||
|
*/
|
||||||
|
export { default as Schedule } from './Schedule';
|
||||||
Loading…
Add table
Reference in a new issue