From 64fc07044dc1ebb3f922a6e4ed2fea52eaa5c930 Mon Sep 17 00:00:00 2001 From: caadiq Date: Wed, 21 Jan 2026 17:54:27 +0900 Subject: [PATCH] =?UTF-8?q?feat(frontend):=20Phase=207=20-=20=EB=A0=88?= =?UTF-8?q?=EC=9D=B4=EC=95=84=EC=9B=83=20=EB=B0=8F=20=EC=8A=A4=EC=BC=80?= =?UTF-8?q?=EC=A4=84=20=ED=8E=98=EC=9D=B4=EC=A7=80=20=EB=A7=88=EC=9D=B4?= =?UTF-8?q?=EA=B7=B8=EB=A0=88=EC=9D=B4=EC=85=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 레이아웃 컴포넌트: - 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 --- frontend-temp/src/App.jsx | 276 +++++++++------ frontend-temp/src/components/index.js | 3 + .../src/components/layout/Footer.jsx | 18 + .../src/components/layout/Header.jsx | 77 ++++ .../src/components/layout/Layout.jsx | 104 ++++++ .../src/components/layout/MobileNav.jsx | 44 +++ frontend-temp/src/components/layout/index.js | 7 + frontend-temp/src/constants/index.js | 8 + frontend-temp/src/pages/index.js | 4 + frontend-temp/src/pages/schedule/Schedule.jsx | 329 ++++++++++++++++++ frontend-temp/src/pages/schedule/index.js | 4 + 11 files changed, 760 insertions(+), 114 deletions(-) create mode 100644 frontend-temp/src/components/layout/Footer.jsx create mode 100644 frontend-temp/src/components/layout/Header.jsx create mode 100644 frontend-temp/src/components/layout/Layout.jsx create mode 100644 frontend-temp/src/components/layout/MobileNav.jsx create mode 100644 frontend-temp/src/components/layout/index.js create mode 100644 frontend-temp/src/pages/index.js create mode 100644 frontend-temp/src/pages/schedule/Schedule.jsx create mode 100644 frontend-temp/src/pages/schedule/index.js diff --git a/frontend-temp/src/App.jsx b/frontend-temp/src/App.jsx index fb2c379..bf1e760 100644 --- a/frontend-temp/src/App.jsx +++ b/frontend-temp/src/App.jsx @@ -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( @@ -27,115 +19,171 @@ function App() { currentDate.getMonth() + 1 ); + return ( +
+
+
+

+ fromis_9 Frontend Refactoring +

+

Phase 7 진행 중 - 스케줄 페이지

+

+ 디바이스: {isMobile ? "모바일" : "PC"} +

+
+ +
+

오늘: {formatFullDate(today)}

+ +
+

카테고리 ({categories?.length || 0}개)

+ {categoriesLoading ? ( + + ) : ( +
+ {categories?.map((c) => ( + + {c.name} + + ))} +
+ )} +
+ +
+

토스트 테스트

+
+ + +
+
+ +
+

페이지 이동

+
+ + 일정 페이지 + +
+
+
+ + {/* ScheduleCard 컴포넌트 테스트 */} + {schedulesLoading ? ( +
+ +
+ ) : schedules?.length > 0 ? ( + <> + {/* Public Variant (공개 페이지용) */} +
+

variant="public" (공개 페이지용)

+
+ {schedules.slice(0, 2).map((schedule) => ( + showSuccess(`${schedule.title} 클릭`)} + /> + ))} +
+
+ + {/* Admin Variant (관리자 페이지용) */} +
+

variant="admin" (관리자 페이지용)

+
+ {schedules.slice(0, 2).map((schedule) => ( + showSuccess(`${schedule.title} 클릭`)} + onEdit={(s) => showSuccess(`${s.title} 수정`)} + onDelete={(s) => showError(`${s.title} 삭제`)} + /> + ))} +
+
+ + ) : ( +
+

이번 달 스케줄이 없습니다.

+
+ )} +
+
+ ); +} + +/** + * 프로미스나인 팬사이트 메인 앱 + * + * Phase 7: 스케줄 페이지 마이그레이션 + * - Layout 컴포넌트 (PC/Mobile 통합) + * - Header, Footer, MobileNav 컴포넌트 + * - Schedule 페이지 (기본 구조) + */ +function App() { return ( + {/* 홈 */} -
-
-

- fromis_9 Frontend Refactoring -

-

Phase 6 완료 - 공통 컴포넌트

-

- 디바이스: {isMobile ? "모바일" : "PC"} -

-
- -
-

오늘: {formatFullDate(today)}

- -
-

카테고리 ({categories?.length || 0}개)

- {categoriesLoading ? ( - - ) : ( -
- {categories?.map((c) => ( - - {c.name} - - ))} -
- )} -
- -
-

토스트 테스트

-
- - -
-
-
- - {/* ScheduleCard 컴포넌트 테스트 */} - {schedulesLoading ? ( -
- -
- ) : schedules?.length > 0 ? ( - <> - {/* Public Variant (공개 페이지용) */} -
-

variant="public" (공개 페이지용)

-
- {schedules.slice(0, 2).map((schedule) => ( - showSuccess(`${schedule.title} 클릭`)} - /> - ))} -
-
- - {/* Admin Variant (관리자 페이지용) */} -
-

variant="admin" (관리자 페이지용)

-
- {schedules.slice(0, 2).map((schedule) => ( - showSuccess(`${schedule.title} 클릭`)} - onEdit={(s) => showSuccess(`${s.title} 수정`)} - onDelete={(s) => showError(`${s.title} 삭제`)} - /> - ))} -
-
- - ) : ( -
-

이번 달 스케줄이 없습니다.

-
- )} -
- - {/* 토스트 컨테이너 */} + + - + + } + /> + + {/* 스케줄 */} + + + + + } + /> + + {/* 임시 페이지들 */} + +
멤버 페이지 (준비 중)
+ + } + /> + +
앨범 페이지 (준비 중)
+ } />
diff --git a/frontend-temp/src/components/index.js b/frontend-temp/src/components/index.js index 07f0599..c799ef5 100644 --- a/frontend-temp/src/components/index.js +++ b/frontend-temp/src/components/index.js @@ -5,5 +5,8 @@ // 공통 컴포넌트 export * from './common'; +// 레이아웃 컴포넌트 +export * from './layout'; + // 스케줄 컴포넌트 export * from './schedule'; diff --git a/frontend-temp/src/components/layout/Footer.jsx b/frontend-temp/src/components/layout/Footer.jsx new file mode 100644 index 0000000..afa25fc --- /dev/null +++ b/frontend-temp/src/components/layout/Footer.jsx @@ -0,0 +1,18 @@ +/** + * PC용 푸터 컴포넌트 + */ +function Footer() { + const currentYear = new Date().getFullYear(); + + return ( +
+
+
+

© {currentYear} fromis_9 Fan Site. This is an unofficial fan-made website.

+
+
+
+ ); +} + +export default Footer; diff --git a/frontend-temp/src/components/layout/Header.jsx b/frontend-temp/src/components/layout/Header.jsx new file mode 100644 index 0000000..40dc75f --- /dev/null +++ b/frontend-temp/src/components/layout/Header.jsx @@ -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 }) => ( + + + +); + +/** + * PC용 헤더 컴포넌트 + */ +function Header() { + return ( +
+
+
+ {/* 로고 */} + + fromis_9 + + + {/* 네비게이션 */} + + + {/* SNS 링크 */} + +
+
+
+ ); +} + +export default Header; diff --git a/frontend-temp/src/components/layout/Layout.jsx b/frontend-temp/src/components/layout/Layout.jsx new file mode 100644 index 0000000..6a4e683 --- /dev/null +++ b/frontend-temp/src/components/layout/Layout.jsx @@ -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 ( +
+
+ {title ? ( + {title} + ) : ( + + fromis_9 + + )} +
+
+ ); +} + +/** + * 통합 레이아웃 컴포넌트 + * 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 ( +
+ {!hideHeader &&
} +
+
+ {children} +
+ {!shouldHideFooter &&
} +
+
+ ); + } + + // 모바일 레이아웃 - 자체 레이아웃 사용 시 (Schedule 페이지 등) + if (useCustomLayout) { + return ( +
+ {children} + +
+ ); + } + + // 모바일 레이아웃 - 기본 + return ( +
+ {!hideHeader && } +
{children}
+ +
+ ); +} + +export default Layout; diff --git a/frontend-temp/src/components/layout/MobileNav.jsx b/frontend-temp/src/components/layout/MobileNav.jsx new file mode 100644 index 0000000..dab72e3 --- /dev/null +++ b/frontend-temp/src/components/layout/MobileNav.jsx @@ -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 ( + + ); +} + +export default MobileNav; diff --git a/frontend-temp/src/components/layout/index.js b/frontend-temp/src/components/layout/index.js new file mode 100644 index 0000000..f43f04a --- /dev/null +++ b/frontend-temp/src/components/layout/index.js @@ -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'; diff --git a/frontend-temp/src/constants/index.js b/frontend-temp/src/constants/index.js index 7b52724..53824e1 100644 --- a/frontend-temp/src/constants/index.js +++ b/frontend-temp/src/constants/index.js @@ -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: '일정' }, +]; diff --git a/frontend-temp/src/pages/index.js b/frontend-temp/src/pages/index.js new file mode 100644 index 0000000..3b7c75b --- /dev/null +++ b/frontend-temp/src/pages/index.js @@ -0,0 +1,4 @@ +/** + * 페이지 export + */ +export * from './schedule'; diff --git a/frontend-temp/src/pages/schedule/Schedule.jsx b/frontend-temp/src/pages/schedule/Schedule.jsx new file mode 100644 index 0000000..874377d --- /dev/null +++ b/frontend-temp/src/pages/schedule/Schedule.jsx @@ -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 ( +
+ {/* 헤더 */} +
+ +

+ {year}년 {month + 1}월 +

+ +
+ + {/* 요일 헤더 */} +
+ {WEEKDAYS.map((day, i) => ( +
+ {day} +
+ ))} +
+ + {/* 날짜 그리드 */} +
+ {days.map((item, index) => { + const dayOfWeek = index % 7; + const dateStr = formatDateStr(item.date); + const colors = scheduleDates[dateStr] || []; + + return ( + + ); + })} +
+
+ ); +} + +/** + * 스케줄 페이지 (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 ( +
+ {/* 좌측: 캘린더 */} +
+ +
+ + {/* 우측: 일정 목록 */} +
+ {/* 헤더 */} +
+
+

+ {new Date(actualSelectedDate).getMonth() + 1}월{' '} + {new Date(actualSelectedDate).getDate()}일{' '} + {WEEKDAYS[new Date(actualSelectedDate).getDay()]}요일 +

+ + {selectedDateSchedules.length}개의 일정 + +
+ +
+ + {/* 일정 목록 */} +
+ {schedulesLoading ? ( +
+ +
+ ) : selectedDateSchedules.length === 0 ? ( +
+ {new Date(actualSelectedDate).getMonth() + 1}월{' '} + {new Date(actualSelectedDate).getDate()}일 일정이 없습니다 +
+ ) : ( +
+ {selectedDateSchedules.map((schedule) => ( + navigate(`/schedule/${schedule.id}`)} + /> + ))} +
+ )} +
+
+
+ ); + } + + // 모바일 레이아웃 (간소화된 버전) + return ( + <> + {/* 모바일 툴바 */} +
+
+ + + {currentDate.getFullYear()}년 {currentDate.getMonth() + 1}월 + +
+ + +
+
+
+ + {/* 모바일 컨텐츠 */} +
+
+ {schedulesLoading ? ( +
+ +
+ ) : selectedDateSchedules.length === 0 ? ( +
+ {new Date(actualSelectedDate).getMonth() + 1}월{' '} + {new Date(actualSelectedDate).getDate()}일 일정이 없습니다 +
+ ) : ( +
+ {selectedDateSchedules.map((schedule) => ( + navigate(`/schedule/${schedule.id}`)} + /> + ))} +
+ )} +
+
+ + ); +} + +export default Schedule; diff --git a/frontend-temp/src/pages/schedule/index.js b/frontend-temp/src/pages/schedule/index.js new file mode 100644 index 0000000..b11430b --- /dev/null +++ b/frontend-temp/src/pages/schedule/index.js @@ -0,0 +1,4 @@ +/** + * 스케줄 페이지 export + */ +export { default as Schedule } from './Schedule';