diff --git a/frontend/index.html b/frontend/index.html index 7427a10..31e26d1 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -2,7 +2,10 @@ - + fromis_9 - 프로미스나인 = 4.7.0" + } + }, "node_modules/tailwindcss": { "version": "3.4.19", "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.19.tgz", diff --git a/frontend/package.json b/frontend/package.json index 37770ba..e0f6a9b 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -22,6 +22,7 @@ "react-photo-album": "^3.4.0", "react-router-dom": "^6.22.3", "react-window": "^2.2.3", + "swiper": "^12.0.3", "zustand": "^5.0.9" }, "devDependencies": { diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index 088111c..250dda5 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -1,6 +1,9 @@ import { BrowserRouter, Routes, Route } from 'react-router-dom'; import { BrowserView, MobileView } from 'react-device-detect'; +// 공통 컴포넌트 +import ScrollToTop from './components/ScrollToTop'; + // PC 페이지 import PCHome from './pages/pc/Home'; import PCMembers from './pages/pc/Members'; @@ -9,6 +12,14 @@ import PCAlbumDetail from './pages/pc/AlbumDetail'; import PCAlbumGallery from './pages/pc/AlbumGallery'; import PCSchedule from './pages/pc/Schedule'; +// 모바일 페이지 +import MobileHome from './pages/mobile/Home'; +import MobileMembers from './pages/mobile/Members'; +import MobileAlbum from './pages/mobile/Album'; +import MobileAlbumDetail from './pages/mobile/AlbumDetail'; +import MobileAlbumGallery from './pages/mobile/AlbumGallery'; +import MobileSchedule from './pages/mobile/Schedule'; + // 관리자 페이지 import AdminLogin from './pages/pc/admin/AdminLogin'; import AdminDashboard from './pages/pc/admin/AdminDashboard'; @@ -22,12 +33,14 @@ import AdminScheduleForm from './pages/pc/admin/AdminScheduleForm'; import AdminScheduleCategory from './pages/pc/admin/AdminScheduleCategory'; import AdminScheduleBots from './pages/pc/admin/AdminScheduleBots'; -// PC 레이아웃 +// 레이아웃 import PCLayout from './components/pc/Layout'; +import MobileLayout from './components/mobile/Layout'; function App() { return ( + {/* 관리자 페이지 (레이아웃 없음) */} @@ -61,16 +74,18 @@ function App() { - {/* 모바일 버전은 추후 구현 */} -
-
-

fromis_9

-

모바일 버전은 준비 중입니다.

-
-
+ + } /> + } /> + } /> + } /> + } /> + } /> +
); } export default App; + diff --git a/frontend/src/components/ScrollToTop.jsx b/frontend/src/components/ScrollToTop.jsx new file mode 100644 index 0000000..1a865da --- /dev/null +++ b/frontend/src/components/ScrollToTop.jsx @@ -0,0 +1,15 @@ +import { useEffect } from 'react'; +import { useLocation } from 'react-router-dom'; + +// 페이지 이동 시 스크롤을 맨 위로 이동시키는 컴포넌트 +function ScrollToTop() { + const { pathname } = useLocation(); + + useEffect(() => { + window.scrollTo(0, 0); + }, [pathname]); + + return null; +} + +export default ScrollToTop; diff --git a/frontend/src/components/mobile/Layout.jsx b/frontend/src/components/mobile/Layout.jsx new file mode 100644 index 0000000..72d5071 --- /dev/null +++ b/frontend/src/components/mobile/Layout.jsx @@ -0,0 +1,72 @@ +import { NavLink, useLocation } from 'react-router-dom'; +import { Home, Users, Disc3, Calendar } from 'lucide-react'; + +// 모바일 헤더 컴포넌트 +function MobileHeader({ title }) { + return ( +
+
+ {title ? ( + {title} + ) : ( + + fromis_9 + + )} +
+
+ ); +} + +// 모바일 하단 네비게이션 +function MobileBottomNav() { + 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 ( + + ); +} + +// 모바일 레이아웃 컴포넌트 +// pageTitle: 헤더에 표시할 제목 (없으면 fromis_9) +// hideHeader: true면 헤더 숨김 (일정 페이지처럼 자체 헤더가 있는 경우) +function MobileLayout({ children, pageTitle, hideHeader = false }) { + return ( +
+ {!hideHeader && } +
{children}
+ +
+ ); +} + +export default MobileLayout; + diff --git a/frontend/src/index.css b/frontend/src/index.css index 8c7cc9d..75d36eb 100644 --- a/frontend/src/index.css +++ b/frontend/src/index.css @@ -12,9 +12,20 @@ body { color: #1a1a1a; } -/* 최소 너비 설정 - 화면 축소시 깨짐 방지 */ -#root { - min-width: 1440px; +/* 최소 너비 설정 - PC에서만 적용 */ +@media (min-width: 1024px) { + #root { + min-width: 1440px; + } +} + +/* 모바일 safe-area 지원 (노치, 홈 인디케이터) */ +.safe-area-bottom { + padding-bottom: env(safe-area-inset-bottom, 0); +} + +.safe-area-top { + padding-top: env(safe-area-inset-top, 0); } /* 스크롤바 스타일 */ diff --git a/frontend/src/pages/mobile/Album.jsx b/frontend/src/pages/mobile/Album.jsx new file mode 100644 index 0000000..ea8c8d4 --- /dev/null +++ b/frontend/src/pages/mobile/Album.jsx @@ -0,0 +1,63 @@ +import { motion } from 'framer-motion'; +import { useState, useEffect } from 'react'; +import { useNavigate } from 'react-router-dom'; + +// 모바일 앨범 목록 페이지 +function MobileAlbum() { + const navigate = useNavigate(); + const [albums, setAlbums] = useState([]); + const [loading, setLoading] = useState(true); + + useEffect(() => { + fetch('/api/albums') + .then(res => res.json()) + .then(data => { + setAlbums(data); + setLoading(false); + }) + .catch(console.error); + }, []); + + if (loading) { + return ( +
+
+
+ ); + } + + return ( +
+
+ {albums.map((album, index) => ( + navigate(`/album/${album.folder_name}`)} + className="bg-white rounded-2xl overflow-hidden shadow-sm" + > +
+ {album.cover_thumb_url && ( + {album.title} + )} +
+
+

{album.title}

+

+ {album.album_type} · {album.release_date?.slice(0, 4)} +

+
+
+ ))} +
+
+ ); +} + +export default MobileAlbum; diff --git a/frontend/src/pages/mobile/AlbumDetail.jsx b/frontend/src/pages/mobile/AlbumDetail.jsx new file mode 100644 index 0000000..fe6adbe --- /dev/null +++ b/frontend/src/pages/mobile/AlbumDetail.jsx @@ -0,0 +1,142 @@ +import { motion } from 'framer-motion'; +import { useState, useEffect } from 'react'; +import { useParams, useNavigate } from 'react-router-dom'; +import { ArrowLeft, Play } from 'lucide-react'; + +// 모바일 앨범 상세 페이지 +function MobileAlbumDetail() { + const { name } = useParams(); + const navigate = useNavigate(); + const [album, setAlbum] = useState(null); + const [tracks, setTracks] = useState([]); + const [loading, setLoading] = useState(true); + + useEffect(() => { + // 앨범 정보 로드 + fetch('/api/albums') + .then(res => res.json()) + .then(data => { + const found = data.find(a => a.folder_name === name); + if (found) { + setAlbum(found); + // 트랙 정보 로드 + fetch(`/api/albums/${found.id}/tracks`) + .then(res => res.json()) + .then(setTracks) + .catch(console.error); + } + setLoading(false); + }) + .catch(console.error); + }, [name]); + + if (loading) { + return ( +
+
+
+ ); + } + + if (!album) { + return ( +
+

앨범을 찾을 수 없습니다

+
+ ); + } + + return ( +
+ {/* 헤더 */} +
+ + {album.title} +
+ + {/* 앨범 정보 */} +
+
+
+ {album.cover_medium_url && ( + {album.title} + )} +
+
+

{album.title}

+

{album.album_type}

+

{album.release_date}

+ + +
+
+
+ + {/* 트랙 리스트 */} + {tracks.length > 0 && ( +
+

수록곡

+
+ {tracks.map((track, index) => ( + + + {track.track_number} + +
+

+ {track.title} + {track.is_title_track && ( + + 타이틀 + + )} +

+

{track.duration}

+
+ {track.music_video_url && ( + + + + )} +
+ ))} +
+
+ )} + + {/* 설명 */} + {album.description && ( +
+

소개

+

+ {album.description} +

+
+ )} +
+ ); +} + +export default MobileAlbumDetail; diff --git a/frontend/src/pages/mobile/AlbumGallery.jsx b/frontend/src/pages/mobile/AlbumGallery.jsx new file mode 100644 index 0000000..ea026de --- /dev/null +++ b/frontend/src/pages/mobile/AlbumGallery.jsx @@ -0,0 +1,132 @@ +import { useState, useEffect } from 'react'; +import { useParams, useNavigate } from 'react-router-dom'; +import { ArrowLeft, X, ChevronLeft, ChevronRight } from 'lucide-react'; +import { motion, AnimatePresence } from 'framer-motion'; + +// 모바일 앨범 갤러리 페이지 +function MobileAlbumGallery() { + const { name } = useParams(); + const navigate = useNavigate(); + const [album, setAlbum] = useState(null); + const [photos, setPhotos] = useState([]); + const [loading, setLoading] = useState(true); + const [selectedIndex, setSelectedIndex] = useState(null); + + useEffect(() => { + fetch('/api/albums') + .then(res => res.json()) + .then(data => { + const found = data.find(a => a.folder_name === name); + if (found) { + setAlbum(found); + fetch(`/api/albums/${found.id}/photos`) + .then(res => res.json()) + .then(setPhotos) + .catch(console.error); + } + setLoading(false); + }) + .catch(console.error); + }, [name]); + + // 이미지 네비게이션 + const goToImage = (delta) => { + const newIndex = selectedIndex + delta; + if (newIndex >= 0 && newIndex < photos.length) { + setSelectedIndex(newIndex); + } + }; + + if (loading) { + return ( +
+
+
+ ); + } + + return ( +
+ {/* 헤더 */} +
+ + {album?.title} 갤러리 +
+ + {/* 갤러리 그리드 */} +
+ {photos.map((photo, index) => ( + setSelectedIndex(index)} + className="aspect-square bg-gray-200 cursor-pointer" + > + + + ))} +
+ + {/* 풀스크린 뷰어 */} + + {selectedIndex !== null && ( + + {/* 뷰어 헤더 */} +
+ + + {selectedIndex + 1} / {photos.length} + +
+
+ + {/* 이미지 */} +
+ + + {/* 좌우 네비게이션 */} + {selectedIndex > 0 && ( + + )} + {selectedIndex < photos.length - 1 && ( + + )} +
+ + )} + +
+ ); +} + +export default MobileAlbumGallery; diff --git a/frontend/src/pages/mobile/Home.jsx b/frontend/src/pages/mobile/Home.jsx new file mode 100644 index 0000000..2eeecae --- /dev/null +++ b/frontend/src/pages/mobile/Home.jsx @@ -0,0 +1,231 @@ +import { motion } from 'framer-motion'; +import { ChevronRight, Clock, Tag } from 'lucide-react'; +import { useState, useEffect } from 'react'; +import { useNavigate } from 'react-router-dom'; + +// 모바일 홈 페이지 +function MobileHome() { + const navigate = useNavigate(); + const [members, setMembers] = useState([]); + const [albums, setAlbums] = useState([]); + const [schedules, setSchedules] = useState([]); + + // 데이터 로드 + useEffect(() => { + // 멤버 로드 + fetch('/api/members') + .then(res => res.json()) + .then(data => setMembers(data.filter(m => !m.is_former))) + .catch(console.error); + + // 앨범 로드 (최신 4개) + fetch('/api/albums') + .then(res => res.json()) + .then(data => setAlbums(data.slice(0, 2))) + .catch(console.error); + + // 다가오는 일정 로드 (startDate + limit 방식) + const today = new Date().toISOString().split('T')[0]; + fetch(`/api/schedules?startDate=${today}&limit=3`) + .then(res => res.json()) + .then(data => setSchedules(data)) + .catch(console.error); + }, []); + + return ( +
+ {/* 히어로 섹션 */} +
+
+
+

fromis_9

+

프로미스나인

+

+ 인사드리겠습니다. 둘, 셋!
+ 이제는 약속해 소중히 간직해,
+ 당신의 아이돌로 성장하겠습니다! +

+
+ {/* 장식 */} +
+
+
+ + {/* 멤버 섹션 */} +
+
+

멤버

+ +
+
+ {members.map((member) => ( + +
+ {member.image_url && ( + {member.name} + )} +
+

{member.name}

+
+ ))} +
+
+ + {/* 앨범 섹션 */} +
+
+

앨범

+ +
+
+ {albums.map((album) => ( + navigate(`/album/${album.folder_name}`)} + className="bg-white rounded-xl overflow-hidden shadow-sm" + whileTap={{ scale: 0.98 }} + > +
+ {album.cover_thumb_url && ( + {album.title} + )} +
+
+

{album.title}

+

{album.release_date?.slice(0, 4)}

+
+
+ ))} +
+
+ + {/* 일정 섹션 */} +
+
+

다가오는 일정

+ +
+ {schedules.length > 0 ? ( +
+ {schedules.map((schedule) => { + 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; + + // 멤버 처리 (5명 이상이면 프로미스나인) + const memberList = schedule.member_names ? schedule.member_names.split(',').map(n => n.trim()).filter(Boolean) : []; + + return ( + navigate('/schedule')} + > + {/* 날짜 영역 */} +
+ {/* 현재 년도가 아니면 년.월 표시 */} + {!isCurrentYear && ( + + {scheduleYear}.{scheduleMonth + 1} + + )} + {/* 현재 달이 아니면 월 표시 (현재 년도일 때) */} + {isCurrentYear && !isCurrentMonth && ( + + {scheduleMonth + 1}월 + + )} + + {scheduleDate.getDate()} + + + {['일', '월', '화', '수', '목', '금', '토'][scheduleDate.getDay()]} + +
+ + {/* 세로 구분선 */} +
+ + {/* 내용 영역 */} +
+

+ {schedule.title} +

+ {/* 시간 + 카테고리 (PC버전 스타일) */} +
+ {schedule.time && ( + + + {schedule.time.slice(0, 5)} + + )} + {schedule.category_name && ( + + + {schedule.category_name} + + )} +
+ {/* 멤버 */} + {memberList.length > 0 && ( +
+ {(memberList.length >= 5 ? ['프로미스나인'] : memberList).map((name, i) => ( + + {name.trim()} + + ))} +
+ )} +
+ + ); + })} +
+ ) : ( +
+

다가오는 일정이 없습니다

+
+ )} +
+
+ ); +} + +export default MobileHome; diff --git a/frontend/src/pages/mobile/Members.jsx b/frontend/src/pages/mobile/Members.jsx new file mode 100644 index 0000000..5bb1da4 --- /dev/null +++ b/frontend/src/pages/mobile/Members.jsx @@ -0,0 +1,124 @@ +import { motion, AnimatePresence } from 'framer-motion'; +import { useState, useEffect } from 'react'; +import { Instagram } from 'lucide-react'; + +// 모바일 멤버 페이지 +function MobileMembers() { + const [members, setMembers] = useState([]); + const [formerMembers, setFormerMembers] = useState([]); + const [selectedMember, setSelectedMember] = useState(null); + + useEffect(() => { + fetch('/api/members') + .then(res => res.json()) + .then(data => { + setMembers(data.filter(m => !m.is_former)); + setFormerMembers(data.filter(m => m.is_former)); + }) + .catch(console.error); + }, []); + + // 멤버 카드 렌더링 함수 + const renderMemberCard = (member, isFormer = false) => ( + setSelectedMember(member)} + className="text-center cursor-pointer" + whileTap={{ scale: 0.95 }} + > +
+ {member.image_url && ( + {member.name} + )} +
+

{member.name}

+

{member.position || ''}

+
+ ); + + return ( +
+ {/* 현재 멤버 */} +
+ {members.map((member) => renderMemberCard(member))} +
+ + {/* 전 멤버 */} + {formerMembers.length > 0 && ( + <> +
+

전 멤버

+
+
+ {formerMembers.map((member) => renderMemberCard(member, true))} +
+ + )} + + {/* 멤버 상세 모달 */} + + {selectedMember && ( + setSelectedMember(null)} + > + e.stopPropagation()} + > +
+
+ {selectedMember.image_url && ( + {selectedMember.name} + )} +
+
+

{selectedMember.name}

+

{selectedMember.position}

+

+ {selectedMember.birth_date?.slice(0, 10).replaceAll('-', '.')} +

+ {/* 전 멤버가 아닌 경우에만 인스타그램 표시 */} + {!selectedMember.is_former && selectedMember.instagram && ( + + + Instagram + + )} +
+
+ +
+
+ )} +
+
+ ); +} + +export default MobileMembers; diff --git a/frontend/src/pages/mobile/Schedule.jsx b/frontend/src/pages/mobile/Schedule.jsx new file mode 100644 index 0000000..818ff3b --- /dev/null +++ b/frontend/src/pages/mobile/Schedule.jsx @@ -0,0 +1,733 @@ +import { useState, useEffect, useMemo, useRef, useCallback } from 'react'; +import { motion, AnimatePresence } from 'framer-motion'; +import { Clock, Tag, Link2, ChevronLeft, ChevronRight, ChevronDown, Search, X, Calendar } from 'lucide-react'; +import { useInfiniteQuery } from '@tanstack/react-query'; +import { useInView } from 'react-intersection-observer'; +import { Swiper, SwiperSlide } from 'swiper/react'; +import 'swiper/css'; + +// 모바일 일정 페이지 +function MobileSchedule() { + const [selectedDate, setSelectedDate] = useState(new Date()); + const [schedules, setSchedules] = useState([]); + const [categories, setCategories] = useState([]); + const [loading, setLoading] = useState(true); + const [isSearchMode, setIsSearchMode] = useState(false); + const [searchTerm, setSearchTerm] = useState(''); + const [showCalendar, setShowCalendar] = useState(false); + + const SEARCH_LIMIT = 10; + const { ref: loadMoreRef, inView } = useInView({ threshold: 0, rootMargin: '100px' }); + + // 검색 무한 스크롤 + const { + data: searchData, + fetchNextPage, + hasNextPage, + isFetchingNextPage, + isLoading: searchLoading, + } = useInfiniteQuery({ + queryKey: ['mobileScheduleSearch', searchTerm], + queryFn: async ({ pageParam = 0 }) => { + const response = await fetch( + `/api/schedules?search=${encodeURIComponent(searchTerm)}&offset=${pageParam}&limit=${SEARCH_LIMIT}` + ); + if (!response.ok) throw new Error('Search failed'); + return response.json(); + }, + getNextPageParam: (lastPage) => { + if (lastPage.hasMore) { + return lastPage.offset + lastPage.schedules.length; + } + return undefined; + }, + enabled: !!searchTerm && isSearchMode, + }); + + const searchResults = useMemo(() => { + if (!searchData?.pages) return []; + return searchData.pages.flatMap(page => page.schedules); + }, [searchData]); + + useEffect(() => { + if (inView && hasNextPage && !isFetchingNextPage && isSearchMode && searchTerm) { + fetchNextPage(); + } + }, [inView, hasNextPage, isFetchingNextPage, fetchNextPage, isSearchMode, searchTerm]); + + // 일정 및 카테고리 로드 + useEffect(() => { + const year = selectedDate.getFullYear(); + const month = selectedDate.getMonth() + 1; + + Promise.all([ + fetch(`/api/schedules?year=${year}&month=${month}`).then(res => res.json()), + fetch('/api/schedules/categories').then(res => res.json()) + ]).then(([schedulesData, categoriesData]) => { + setSchedules(schedulesData); + setCategories(categoriesData); + setLoading(false); + }).catch(console.error); + }, [selectedDate]); + + // 월 변경 + const changeMonth = (delta) => { + const newDate = new Date(selectedDate); + newDate.setMonth(newDate.getMonth() + delta); + setSelectedDate(newDate); + }; + + // 카테고리 색상 + const getCategoryColor = (categoryId) => { + const category = categories.find(c => c.id === categoryId); + return category?.color || '#6b7280'; + }; + + // 날짜별 일정 그룹화 + const groupedSchedules = useMemo(() => { + const groups = {}; + schedules.forEach(schedule => { + const date = schedule.date; + if (!groups[date]) groups[date] = []; + groups[date].push(schedule); + }); + return Object.entries(groups).sort((a, b) => a[0].localeCompare(b[0])); + }, [schedules]); + + return ( +
+ {/* 헤더 */} +
+ {isSearchMode ? ( +
+
+ + setSearchTerm(e.target.value)} + className="flex-1 bg-transparent outline-none text-sm" + autoFocus + /> + {searchTerm && ( + + )} +
+ +
+ ) : ( +
+
+ + +
+ + {selectedDate.getFullYear()}년 {selectedDate.getMonth() + 1}월 + +
+ + +
+
+ )} + + {/* 달력 팝업 */} + + {showCalendar && !isSearchMode && ( + + { + setSelectedDate(date); + setShowCalendar(false); + }} + /> + + )} + +
+ + {/* 컨텐츠 */} +
+ {isSearchMode && searchTerm ? ( + // 검색 결과 +
+ {searchLoading ? ( +
+
+
+ ) : searchResults.length === 0 ? ( +
+ 검색 결과가 없습니다 +
+ ) : ( + <> + {searchResults.map((schedule, index) => ( + + ))} +
+ {isFetchingNextPage && ( +
+
+
+ )} +
+ + )} +
+ ) : loading ? ( +
+
+
+ ) : groupedSchedules.length === 0 ? ( +
+ 이번 달 일정이 없습니다 +
+ ) : ( + // 깔끔한 날짜별 일정 +
+ {groupedSchedules.map(([date, daySchedules], groupIndex) => { + const dateObj = new Date(date); + const month = dateObj.getMonth() + 1; + const day = dateObj.getDate(); + const weekday = ['일', '월', '화', '수', '목', '금', '토'][dateObj.getDay()]; + const isWeekend = dateObj.getDay() === 0 || dateObj.getDay() === 6; + + return ( +
+ {/* 날짜 헤더 - 심플 스타일 */} +
+
+ {day} + {weekday} +
+
+
+ + {/* 일정 카드들 */} +
+ {daySchedules.map((schedule, index) => ( + + ))} +
+
+ ); + })} +
+ )} +
+
+ ); +} + +// 일정 카드 컴포넌트 (검색용) +function ScheduleCard({ schedule, categoryColor, categories, delay = 0 }) { + const categoryName = categories.find(c => c.id === schedule.category_id)?.name || '미분류'; + const memberNames = schedule.member_names || schedule.members?.map(m => m.name).join(',') || ''; + const memberList = memberNames.split(',').filter(name => name.trim()); + + return ( + +
+
+
+

{schedule.title}

+
+ {schedule.time && ( + + + {schedule.time.slice(0, 5)} + + )} + + + {categoryName} + + {schedule.source_name && ( + + + {schedule.source_name} + + )} +
+ {memberList.length > 0 && ( +
+ {memberList.length >= 5 ? ( + + 프로미스나인 + + ) : ( + memberList.map((name, i) => ( + + {name.trim()} + + )) + )} +
+ )} +
+
+ + ); +} + +// 타임라인용 일정 카드 컴포넌트 - 모던 디자인 +function TimelineScheduleCard({ schedule, categoryColor, categories, delay = 0 }) { + const categoryName = categories.find(c => c.id === schedule.category_id)?.name || '미분류'; + const memberNames = schedule.member_names || schedule.members?.map(m => m.name).join(',') || ''; + const memberList = memberNames.split(',').filter(name => name.trim()); + + return ( + + {/* 카드 본체 */} +
+
+ + {/* 시간 뱃지 */} + {schedule.time && ( +
+
+ + {schedule.time.slice(0, 5)} +
+ + {categoryName} + +
+ )} + + {/* 제목 */} +

+ {schedule.title} +

+ + {/* 출처 */} + {schedule.source_name && ( +
+ + {schedule.source_name} +
+ )} + + {/* 멤버 */} + {memberList.length > 0 && ( +
+ {memberList.length >= 5 ? ( + + 프로미스나인 + + ) : ( + memberList.map((name, i) => ( + + {name.trim()} + + )) + )} +
+ )} +
+
+
+ ); +} + +// 달력 선택기 컴포넌트 +function CalendarPicker({ selectedDate, schedules = [], categories = [], onSelectDate }) { + const [viewDate, setViewDate] = useState(new Date(selectedDate)); + const swiperRef = useRef(null); + + // 날짜별 일정 존재 여부 및 카테고리 색상 + const scheduleDates = useMemo(() => { + const dateMap = {}; + schedules.forEach(schedule => { + const date = schedule.date; + if (!dateMap[date]) { + dateMap[date] = []; + } + const category = categories.find(c => c.id === schedule.category_id); + dateMap[date].push(category?.color || '#6b7280'); + }); + return dateMap; + }, [schedules, categories]); + + const getScheduleColors = (date) => { + const dateStr = date.toISOString().split('T')[0]; + const colors = scheduleDates[dateStr] || []; + // 최대 3개까지만 표시 + return [...new Set(colors)].slice(0, 3); + }; + + const year = viewDate.getFullYear(); + const month = viewDate.getMonth(); + + // 달력 데이터 생성 함수 + const getCalendarDays = useCallback((y, m) => { + const firstDay = new Date(y, m, 1); + const lastDay = new Date(y, m + 1, 0); + const startDay = firstDay.getDay(); + const daysInMonth = lastDay.getDate(); + + const days = []; + + // 이전 달 날짜 + const prevMonth = new Date(y, m, 0); + for (let i = startDay - 1; i >= 0; i--) { + days.push({ + day: prevMonth.getDate() - i, + isCurrentMonth: false, + date: new Date(y, m - 1, prevMonth.getDate() - i) + }); + } + + // 현재 달 날짜 + for (let i = 1; i <= daysInMonth; i++) { + days.push({ + day: i, + isCurrentMonth: true, + date: new Date(y, m, i) + }); + } + + // 다음 달 날짜 (현재 줄만 채우기) + const remaining = (7 - (days.length % 7)) % 7; + for (let i = 1; i <= remaining; i++) { + days.push({ + day: i, + isCurrentMonth: false, + date: new Date(y, m + 1, i) + }); + } + + return days; + }, []); + + const changeMonth = useCallback((delta) => { + const newDate = new Date(viewDate); + newDate.setMonth(newDate.getMonth() + delta); + setViewDate(newDate); + }, [viewDate]); + + const isToday = (date) => { + const today = new Date(); + return date.getDate() === today.getDate() && + date.getMonth() === today.getMonth() && + date.getFullYear() === today.getFullYear(); + }; + + // 년월 선택 모드 + const [showYearMonth, setShowYearMonth] = useState(false); + const [yearRangeStart, setYearRangeStart] = useState(Math.floor(year / 12) * 12); + const yearRange = Array.from({ length: 12 }, (_, i) => yearRangeStart + i); + + // 배경 스크롤 막기 + useEffect(() => { + document.body.style.overflow = 'hidden'; + return () => { document.body.style.overflow = ''; }; + }, []); + + // 슬라이드에 표시할 3개월 데이터 (이전, 현재, 다음) + const slides = useMemo(() => { + return [-1, 0, 1].map(offset => { + const d = new Date(year, month + offset, 1); + return { + year: d.getFullYear(), + month: d.getMonth(), + days: getCalendarDays(d.getFullYear(), d.getMonth()) + }; + }); + }, [year, month, getCalendarDays]); + + // 스와이프 핸들러 + const handleSlideChange = (swiper) => { + const direction = swiper.activeIndex - 1; // -1, 0, 1 + if (direction !== 0) { + changeMonth(direction); + // 다시 중앙으로 리셋 (무한 스와이프를 위해) + setTimeout(() => { + swiper.slideTo(1, 0); + }, 0); + } + }; + + // 월 렌더링 컴포넌트 + const renderMonth = (slideData) => ( +
+ {/* 요일 헤더 */} +
+ {['일', '월', '화', '수', '목', '금', '토'].map((day, i) => ( +
+ {day} +
+ ))} +
+ + {/* 날짜 그리드 */} +
+ {slideData.days.map((item, index) => { + const dayOfWeek = index % 7; + const isSunday = dayOfWeek === 0; + const isSaturday = dayOfWeek === 6; + const scheduleColors = item.isCurrentMonth ? getScheduleColors(item.date) : []; + + return ( + + ); + })} +
+
+ ); + + return ( +
+ + {showYearMonth ? ( + // 년월 선택 UI + + {/* 년도 범위 헤더 */} +
+ + + {yearRangeStart} - {yearRangeStart + 11} + + +
+ + {/* 년도 선택 */} +
년도
+
+ {yearRange.map(y => ( + + ))} +
+ + {/* 월 선택 */} +
+
+ {Array.from({ length: 12 }, (_, i) => i + 1).map(m => ( + + ))} +
+ + {/* 취소 버튼 */} +
+ +
+
+ ) : ( + + {/* 달력 헤더 */} +
+ + + +
+ + {/* Swiper 달력 */} + { swiperRef.current = swiper; }} + initialSlide={1} + onSlideChangeTransitionEnd={handleSlideChange} + spaceBetween={20} + slidesPerView={1} + speed={200} + touchRatio={1} + resistance={true} + resistanceRatio={0.5} + > + {slides.map((slide, idx) => ( + + {renderMonth(slide)} + + ))} + + + {/* 오늘 버튼 */} +
+ +
+
+ )} +
+
+ ); +} + +export default MobileSchedule; diff --git a/frontend/src/pages/pc/Home.jsx b/frontend/src/pages/pc/Home.jsx index aecc689..28f2897 100644 --- a/frontend/src/pages/pc/Home.jsx +++ b/frontend/src/pages/pc/Home.jsx @@ -1,7 +1,7 @@ import { useState, useEffect } from 'react'; import { motion } from 'framer-motion'; import { Link } from 'react-router-dom'; -import { Calendar, Users, Disc3, ArrowRight, Clock } from 'lucide-react'; +import { Calendar, Users, Disc3, ArrowRight, Clock, Link2, Tag } from 'lucide-react'; function Home() { const [members, setMembers] = useState([]); @@ -157,9 +157,6 @@ function Home() { const memberList = schedule.member_names ? schedule.member_names.split(',') : []; const displayMembers = memberList.length >= 5 ? ['프로미스나인'] : memberList; - // 카테고리 색상 (기본값) - const categoryColor = schedule.category_color || '#6B8E6B'; - return ( - {/* 날짜 영역 */} + {/* 날짜 영역 - primary 색상 고정 */}
{day} {weekday}
- {/* 내용 영역 */}

{schedule.title}

@@ -182,20 +178,20 @@ function Home() {
{schedule.time && (
- + {schedule.time.slice(0, 5)}
)} {schedule.category_name && ( -
- - - {schedule.category_name} - {schedule.source_name && ` · ${schedule.source_name}`} - +
+ + {schedule.category_name} +
+ )} + {schedule.source_name && ( +
+ + {schedule.source_name}
)}