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:
caadiq 2026-01-21 17:54:27 +09:00
parent 84030019cd
commit 64fc07044d
11 changed files with 760 additions and 114 deletions

View file

@ -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 { useUIStore } from "@/stores";
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 isMobile = useIsMobile();
const { showSuccess, showError } = useUIStore();
//
const { data: categories, isLoading: categoriesLoading } = useCategories();
const currentDate = new Date();
const { data: schedules, isLoading: schedulesLoading } = useScheduleData(
@ -28,19 +20,13 @@ function App() {
);
return (
<BrowserRouter>
<ErrorBoundary>
<Routes>
<Route
path="/"
element={
<div className="min-h-screen bg-gray-50 p-4">
<div className={cn("p-4", isMobile ? "pb-20" : "")}>
<div className="max-w-2xl mx-auto space-y-4">
<div className="text-center">
<h1 className="text-2xl font-bold text-primary mb-2">
fromis_9 Frontend Refactoring
</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")}>
디바이스: {isMobile ? "모바일" : "PC"}
</p>
@ -85,6 +71,18 @@ function App() {
</button>
</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>
{/* ScheduleCard 컴포넌트 테스트 */}
@ -132,10 +130,60 @@ function App() {
</div>
)}
</div>
{/* 토스트 컨테이너 */}
<ToastContainer />
</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>

View file

@ -5,5 +5,8 @@
// 공통 컴포넌트
export * from './common';
// 레이아웃 컴포넌트
export * from './layout';
// 스케줄 컴포넌트
export * from './schedule';

View 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>&copy; {currentYear} fromis_9 Fan Site. This is an unofficial fan-made website.</p>
</div>
</div>
</footer>
);
}
export default Footer;

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

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

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

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

View file

@ -57,3 +57,11 @@ export const MONTH_NAMES = [
'1월', '2월', '3월', '4월', '5월', '6월',
'7월', '8월', '9월', '10월', '11월', '12월',
];
/** 네비게이션 메뉴 항목 */
export const NAV_ITEMS = [
{ path: '/', label: '홈' },
{ path: '/members', label: '멤버' },
{ path: '/album', label: '앨범' },
{ path: '/schedule', label: '일정' },
];

View file

@ -0,0 +1,4 @@
/**
* 페이지 export
*/
export * from './schedule';

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

View file

@ -0,0 +1,4 @@
/**
* 스케줄 페이지 export
*/
export { default as Schedule } from './Schedule';