diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 9da2a82..0301b9c 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -9,6 +9,7 @@ "version": "1.0.0", "dependencies": { "@tanstack/react-query": "^5.90.16", + "@tanstack/react-virtual": "^3.13.18", "dayjs": "^1.11.19", "framer-motion": "^11.0.8", "lucide-react": "^0.344.0", @@ -1160,6 +1161,33 @@ "react": "^18 || ^19" } }, + "node_modules/@tanstack/react-virtual": { + "version": "3.13.18", + "resolved": "https://registry.npmjs.org/@tanstack/react-virtual/-/react-virtual-3.13.18.tgz", + "integrity": "sha512-dZkhyfahpvlaV0rIKnvQiVoWPyURppl6w4m9IwMDpuIjcJ1sD9YGWrt0wISvgU7ewACXx2Ct46WPgI6qAD4v6A==", + "license": "MIT", + "dependencies": { + "@tanstack/virtual-core": "3.13.18" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/@tanstack/virtual-core": { + "version": "3.13.18", + "resolved": "https://registry.npmjs.org/@tanstack/virtual-core/-/virtual-core-3.13.18.tgz", + "integrity": "sha512-Mx86Hqu1k39icq2Zusq+Ey2J6dDWTjDvEv43PJtRCoEYTLyfaPnxIQ6iy7YAOK0NV/qOEmZQ/uCufrppZxTgcg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, "node_modules/@types/babel__core": { "version": "7.20.5", "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", diff --git a/frontend/package.json b/frontend/package.json index 2049573..c0899d3 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -10,6 +10,7 @@ }, "dependencies": { "@tanstack/react-query": "^5.90.16", + "@tanstack/react-virtual": "^3.13.18", "dayjs": "^1.11.19", "framer-motion": "^11.0.8", "lucide-react": "^0.344.0", diff --git a/frontend/src/pages/pc/admin/AdminSchedule.jsx b/frontend/src/pages/pc/admin/AdminSchedule.jsx index 386f643..2876f12 100644 --- a/frontend/src/pages/pc/admin/AdminSchedule.jsx +++ b/frontend/src/pages/pc/admin/AdminSchedule.jsx @@ -6,6 +6,7 @@ import { ChevronLeft, Search, ChevronDown, Bot, Tag, ArrowLeft, ExternalLink, Clock, Link2 } from 'lucide-react'; import { useInfiniteQuery } from '@tanstack/react-query'; +import { useVirtualizer } from '@tanstack/react-virtual'; import { useInView } from 'react-intersection-observer'; import Toast from '../../../components/Toast'; @@ -152,6 +153,7 @@ function AdminSchedule() { const { toast, setToast } = useToast(); const scrollContainerRef = useRef(null); const SEARCH_LIMIT = 20; // 페이지당 20개 + const ITEM_HEIGHT = 100; // 각 아이템 높이 (px) // Intersection Observer for infinite scroll const { ref: loadMoreRef, inView } = useInView({ @@ -532,6 +534,14 @@ function AdminSchedule() { return matchesCategory && matchesDate; }); }, [isSearchMode, searchTerm, searchResults, schedules, selectedCategories, selectedDate]); + + // 가상 스크롤 설정 (검색 모드에서만 활성화) + const virtualizer = useVirtualizer({ + count: isSearchMode && searchTerm ? filteredSchedules.length : 0, + getScrollElement: () => scrollContainerRef.current, + estimateSize: () => ITEM_HEIGHT, + overscan: 5, // 버퍼 아이템 수 + }); // 카테고리별 카운트 맵 (useMemo로 미리 계산) - 선택된 날짜 기준 const categoryCounts = useMemo(() => { @@ -1085,99 +1095,118 @@ function AdminSchedule() { className="flex-1 overflow-y-auto divide-y divide-gray-100 py-2" > {isSearchMode && searchTerm ? ( - /* 검색 모드: useInView 기반 무한 스크롤 */ + /* 검색 모드: 가상 스크롤 */ <> - {filteredSchedules.map((schedule, index) => ( - -
-
-
- {new Date(schedule.date).getFullYear()}.{new Date(schedule.date).getMonth() + 1} -
-
- {new Date(schedule.date).getDate()} -
-
- {['일', '월', '화', '수', '목', '금', '토'][new Date(schedule.date).getDay()]}요일 -
-
+
+ {virtualizer.getVirtualItems().map((virtualItem) => { + const schedule = filteredSchedules[virtualItem.index]; + if (!schedule) return null; + + return ( +
+
+
+
+
+ {new Date(schedule.date).getFullYear()}.{new Date(schedule.date).getMonth() + 1} +
+
+ {new Date(schedule.date).getDate()} +
+
+ {['일', '월', '화', '수', '목', '금', '토'][new Date(schedule.date).getDay()]}요일 +
+
-
c.id === schedule.category_id)?.color)?.style?.backgroundColor || '#6b7280' }} - /> +
c.id === schedule.category_id)?.color)?.style?.backgroundColor || '#6b7280' }} + /> -
-

{decodeHtmlEntities(schedule.title)}

-
- {schedule.time && ( - - - {schedule.time.slice(0, 5)} - - )} - - - {categories.find(c => c.id === schedule.category_id)?.name || '미분류'} - - {schedule.source_name && ( - - - {schedule.source_name} - - )} -
- {schedule.member_names && ( -
- {schedule.member_names.split(',').length >= 5 ? ( - - 프로미스나인 - - ) : ( - schedule.member_names.split(',').map((name, i) => ( - - {name.trim()} +
+

{decodeHtmlEntities(schedule.title)}

+
+ {schedule.time && ( + + + {schedule.time.slice(0, 5)} + + )} + + + {categories.find(c => c.id === schedule.category_id)?.name || '미분류'} - )) - )} -
- )} -
+ {schedule.source_name && ( + + + {schedule.source_name} + + )} +
+ {schedule.member_names && ( +
+ {schedule.member_names.split(',').length >= 5 ? ( + + 프로미스나인 + + ) : ( + schedule.member_names.split(',').map((name, i) => ( + + {name.trim()} + + )) + )} +
+ )} +
-
- {schedule.source_url && ( - e.stopPropagation()} - className="p-2 hover:bg-blue-100 rounded-lg transition-colors text-blue-500" - > - - - )} - - +
+ {schedule.source_url && ( + e.stopPropagation()} + className="p-1.5 hover:bg-blue-100 rounded-lg transition-colors text-blue-500" + > + + + )} + + +
+
+
-
- - ))} + ); + })} +
{/* 무한 스크롤 트리거 & 로딩 인디케이터 */}
@@ -1186,9 +1215,9 @@ function AdminSchedule() {
)} - {!hasNextPage && searchResults.length > 0 && ( + {!hasNextPage && filteredSchedules.length > 0 && (
- {searchResults.length} / {searchTotal}개 표시 (모두 로드됨) + {filteredSchedules.length}개 표시 (모두 로드됨)
)}
diff --git a/frontend/src/pages/pc/public/Schedule.jsx b/frontend/src/pages/pc/public/Schedule.jsx index a478ba3..92621aa 100644 --- a/frontend/src/pages/pc/public/Schedule.jsx +++ b/frontend/src/pages/pc/public/Schedule.jsx @@ -3,6 +3,7 @@ import { useNavigate } from 'react-router-dom'; import { motion, AnimatePresence } from 'framer-motion'; import { Clock, ChevronLeft, ChevronRight, ChevronDown, Tag, Search, ArrowLeft, Link2 } from 'lucide-react'; import { useInfiniteQuery } from '@tanstack/react-query'; +import { useVirtualizer } from '@tanstack/react-virtual'; import { useInView } from 'react-intersection-observer'; import { getTodayKST } from '../../../utils/date'; import { getSchedules, getCategories, searchSchedules as searchSchedulesApi } from '../../../api/public/schedules'; @@ -42,6 +43,7 @@ function Schedule() { const [searchInput, setSearchInput] = useState(''); const [searchTerm, setSearchTerm] = useState(''); const SEARCH_LIMIT = 20; // 페이지당 20개 + const ITEM_HEIGHT = 120; // 각 아이템 높이 (px) // Intersection Observer for infinite scroll const { ref: loadMoreRef, inView } = useInView({ @@ -83,6 +85,8 @@ function Schedule() { const searchTotal = searchData?.pages?.[0]?.total || 0; + + // Auto fetch next page when scrolled to bottom useEffect(() => { if (inView && hasNextPage && !isFetchingNextPage && isSearchMode && searchTerm) { @@ -285,6 +289,14 @@ function Schedule() { }); }, [schedules, selectedDate, currentYearMonth, selectedCategories, isSearchMode, searchTerm, searchResults]); + // 가상 스크롤 설정 (검색 모드에서만 활성화) + const virtualizer = useVirtualizer({ + count: isSearchMode && searchTerm ? filteredSchedules.length : 0, + getScrollElement: () => scrollContainerRef.current, + estimateSize: () => ITEM_HEIGHT, + overscan: 5, // 버퍼 아이템 수 + }); + // 카테고리별 카운트 맵 (useMemo로 미리 계산) - 선택된 날짜 기준 const categoryCounts = useMemo(() => { const source = (isSearchMode && searchTerm) ? searchResults : schedules; @@ -820,84 +832,103 @@ function Schedule() {
로딩 중...
) : filteredSchedules.length > 0 ? ( isSearchMode && searchTerm ? ( - /* 검색 모드: useInView 기반 무한 스크롤 */ + /* 검색 모드: 가상 스크롤 */ <> - {filteredSchedules.map((schedule, index) => { - const formatted = formatDate(schedule.date); - const categoryColor = getCategoryColor(schedule.category_id); - const categoryName = getCategoryName(schedule.category_id); - - return ( - handleScheduleClick(schedule)} - className="flex items-stretch bg-white rounded-2xl shadow-sm hover:shadow-md transition-shadow overflow-hidden cursor-pointer" - > - {/* 날짜 영역 */} -
+ {virtualizer.getVirtualItems().map((virtualItem) => { + const schedule = filteredSchedules[virtualItem.index]; + if (!schedule) return null; + + const formatted = formatDate(schedule.date); + const categoryColor = getCategoryColor(schedule.category_id); + const categoryName = getCategoryName(schedule.category_id); + + return ( +
- - {new Date(schedule.date).getFullYear()}.{new Date(schedule.date).getMonth() + 1} - - {formatted.day} - {formatted.weekday} -
- - {/* 스케줄 내용 */} -
-

{decodeHtmlEntities(schedule.title)}

- -
- {schedule.time && ( - - - {schedule.time.slice(0, 5)} +
handleScheduleClick(schedule)} + className="flex items-stretch bg-white rounded-2xl shadow-sm hover:shadow-md transition-shadow overflow-hidden cursor-pointer h-full" + > + {/* 날짜 영역 */} +
+ + {new Date(schedule.date).getFullYear()}.{new Date(schedule.date).getMonth() + 1} - )} - - - {categoryName} - - {schedule.source_name && ( - - - {schedule.source_name} - - )} -
+ {formatted.day} + {formatted.weekday} +
- {(() => { - const memberNames = schedule.member_names || schedule.members?.map(m => m.name).join(',') || ''; - const memberList = memberNames.split(',').filter(name => name.trim()); - if (memberList.length === 0) return null; - if (memberList.length === 5) { - return ( -
- - 프로미스나인 + {/* 스케줄 내용 */} +
+

{decodeHtmlEntities(schedule.title)}

+ +
+ {schedule.time && ( + + + {schedule.time.slice(0, 5)} -
- ); - } - return ( -
- {memberList.map((name, i) => ( - - {name} + )} + + + {categoryName} + + {schedule.source_name && ( + + + {schedule.source_name} - ))} + )}
- ); - })()} + + {(() => { + const memberNames = schedule.member_names || schedule.members?.map(m => m.name).join(',') || ''; + const memberList = memberNames.split(',').filter(name => name.trim()); + if (memberList.length === 0) return null; + if (memberList.length === 5) { + return ( +
+ + 프로미스나인 + +
+ ); + } + return ( +
+ {memberList.map((name, i) => ( + + {name} + + ))} +
+ ); + })()} +
+
- - ); - })} + ); + })} +
{/* 무한 스크롤 트리거 & 로딩 인디케이터 */}
@@ -906,9 +937,9 @@ function Schedule() {
)} - {!hasNextPage && searchResults.length > 0 && ( + {!hasNextPage && filteredSchedules.length > 0 && (
- {searchResults.length} / {searchTotal}개 표시 (모두 로드됨) + {filteredSchedules.length}개 표시 (모두 로드됨)
)}