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 (
+
+ );
+}
+
+// 모바일 하단 네비게이션
+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.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.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}
+
+ ))}
+
+
+
+ {/* 앨범 섹션 */}
+
+
+
앨범
+
+
+
+ {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.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.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.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}
)}