diff --git a/.changeset/six-spies-deny.md b/.changeset/six-spies-deny.md new file mode 100644 index 00000000..0930b878 --- /dev/null +++ b/.changeset/six-spies-deny.md @@ -0,0 +1,5 @@ +--- +"@clab-platforms/time": patch +--- + +feat(time): TimeTable Modal 추가기능 diff --git a/apps/time/public/font/PretendardVariable.woff2 b/apps/time/public/font/PretendardVariable.woff2 new file mode 100644 index 00000000..49c54b51 Binary files /dev/null and b/apps/time/public/font/PretendardVariable.woff2 differ diff --git a/apps/time/src/app/font.css b/apps/time/src/app/font.css new file mode 100644 index 00000000..9f68bab6 --- /dev/null +++ b/apps/time/src/app/font.css @@ -0,0 +1,16 @@ +/* +Copyright (c) 2021 Kil Hyung-jin, with Reserved Font Name Pretendard. +https://github.com/orioncactus/pretendard + +This Font Software is licensed under the SIL Open Font License, Version 1.1. +This license is copied below, and is also available with a FAQ at: +http://scripts.sil.org/OFL +*/ + +@font-face { + font-family: 'Pretendard Variable'; + font-weight: 45 920; + font-style: normal; + font-display: swap; + src: url('/font/PretendardVariable.woff2') format('woff2-variations'); +} diff --git a/apps/time/src/app/layout.tsx b/apps/time/src/app/layout.tsx index 18484d1d..7fe26607 100644 --- a/apps/time/src/app/layout.tsx +++ b/apps/time/src/app/layout.tsx @@ -2,12 +2,10 @@ import { cn } from '@clab-platforms/utils'; import { Providers } from '@/shared/utils'; import type { Metadata } from 'next'; -import { Noto_Sans_KR } from 'next/font/google'; +import './font.css'; import './globals.css'; -const inter = Noto_Sans_KR({ subsets: ['latin'] }); - export const metadata: Metadata = { title: '경기플러스', description: '경기대학교에 계신 모든 순간을 도와드릴게요.', @@ -23,9 +21,7 @@ export default function RootLayout({ }>) { return ( - + {children} diff --git a/apps/time/src/shared/hooks/index.ts b/apps/time/src/shared/hooks/index.ts index be92bc94..7c772e57 100644 --- a/apps/time/src/shared/hooks/index.ts +++ b/apps/time/src/shared/hooks/index.ts @@ -3,3 +3,4 @@ export { default as useOutsideClick } from './useOutsideClick'; export { default as useModalAction } from './useModalAction'; export { default as useModalState } from './useModalState'; export { default as useDebounce } from './useDebounce'; +export { default as useInfiniteScroll } from './useInfiniteScroll'; diff --git a/apps/time/src/shared/hooks/useInfiniteScroll.ts b/apps/time/src/shared/hooks/useInfiniteScroll.ts new file mode 100644 index 00000000..49aa0ed1 --- /dev/null +++ b/apps/time/src/shared/hooks/useInfiniteScroll.ts @@ -0,0 +1,39 @@ +'use client'; + +import { useCallback, useEffect, useRef } from 'react'; + +export default function useInfiniteScroll(callback: () => void) { + const targetRef = useRef(null); + const observerRef = useRef(null); + + const handleIntersection = useCallback( + ([target]: IntersectionObserverEntry[]) => { + if (target.isIntersecting) { + callback(); + } + }, + [callback], + ); + + useEffect(() => { + if (observerRef.current) { + observerRef.current.disconnect(); // 기존 옵저버 해제 + } + + observerRef.current = new IntersectionObserver(handleIntersection, { + root: null, + rootMargin: '0px', + threshold: 1.0, + }); + + if (targetRef.current) { + observerRef.current.observe(targetRef.current); + } + + return () => { + observerRef.current?.disconnect(); + }; + }, [handleIntersection]); + + return targetRef; +} diff --git a/apps/time/src/shared/utils/getAPIURL.ts b/apps/time/src/shared/utils/getAPIURL.ts index cfb490b0..ae10bd84 100644 --- a/apps/time/src/shared/utils/getAPIURL.ts +++ b/apps/time/src/shared/utils/getAPIURL.ts @@ -1,3 +1,5 @@ +'use client'; + export default function getAPIURL(endPoint: string) { - return new URL(`${process.env.NEXT_PUBLIC_API_URL}/api/${endPoint}`); + return new URL(`${process.env.NEXT_PUBLIC_SERVER_URL}/api/${endPoint}`); } diff --git a/apps/time/src/widgets/time-table/api/getLectureList.ts b/apps/time/src/widgets/time-table/api/getLectureList.ts index 0e7373a5..aca36aa1 100644 --- a/apps/time/src/widgets/time-table/api/getLectureList.ts +++ b/apps/time/src/widgets/time-table/api/getLectureList.ts @@ -18,7 +18,7 @@ export interface GetLectureListParams { time: (DayPeriod | NightPeriod)[]; major: string[]; lectureName: string; - cursor: number; + cursor?: number; limit: number; } @@ -45,9 +45,9 @@ export interface GetLectureListResponse { success: boolean; data: { values: GetLectureListResponseValue[]; + hasPrevious: boolean; + hasNext: boolean; }; - hasPrevious: boolean; - hasNext: boolean; } export async function getLectureList({ diff --git a/apps/time/src/widgets/time-table/model/hooks/useLectureList.ts b/apps/time/src/widgets/time-table/model/hooks/useLectureList.ts index a840f2ff..8527bdc2 100644 --- a/apps/time/src/widgets/time-table/model/hooks/useLectureList.ts +++ b/apps/time/src/widgets/time-table/model/hooks/useLectureList.ts @@ -1,4 +1,4 @@ -import { useSuspenseQuery } from '@tanstack/react-query'; +import { useInfiniteQuery } from '@tanstack/react-query'; import { type GetLectureListParams, @@ -7,8 +7,15 @@ import { } from '@/widgets/time-table'; export default function useLectureList({ ...params }: GetLectureListParams) { - return useSuspenseQuery({ + return useInfiniteQuery({ queryKey: timeTableQueryKeys.getLectureList(params), - queryFn: () => getLectureList(params), + queryFn: ({ pageParam }) => + getLectureList({ ...params, cursor: pageParam }), + initialPageParam: 0, + select: ({ pages = [] }) => pages.flatMap((page) => page.data.values), + getNextPageParam: (lastPage) => + lastPage.data.hasNext + ? lastPage.data.values[params.limit - 1].id + : undefined, }); } diff --git a/apps/time/src/widgets/time-table/ui/TimeTableLectureTable.tsx b/apps/time/src/widgets/time-table/ui/TimeTableLectureTable.tsx index 5b9e7ff5..07ce67b1 100644 --- a/apps/time/src/widgets/time-table/ui/TimeTableLectureTable.tsx +++ b/apps/time/src/widgets/time-table/ui/TimeTableLectureTable.tsx @@ -1,10 +1,19 @@ +'use client'; + import { Suspense, memo } from 'react'; +import { MODAL_KEY } from '@/shared/constants'; +import { + useEditableSearchParams, + useInfiniteScroll, + useModalAction, +} from '@/shared/hooks'; import { type GetLectureListParams, type GetLectureListResponseValue, useLectureList, } from '@/widgets/time-table'; +import { useRouter } from 'next/navigation'; interface TimeTableLectureTableProps { selectedValues: GetLectureListParams; @@ -29,19 +38,39 @@ const LECTURE_TABLE_ROW_HEADER = [ ] as const; function TimeTableLectureItem({ lecture }: TimeTableLectureTableItemProps) { + const searchParams = useEditableSearchParams(); + const router = useRouter(); + const { close } = useModalAction({ key: MODAL_KEY.timeTable }); + + const handleTimeTableLectureItem = (id: number) => { + const selectedId = searchParams.getAll('id'); + + if (!selectedId.includes(String(id))) { + searchParams.append('id', id.toString()); + router.push(`/timetable?${searchParams.toString()}`); + } + + close(); + }; + return ( - - {lecture.campus} - {lecture.category} - {lecture.code} - {lecture.credit} - {lecture.grade ?? '-'} - {lecture.major !== 'None' ? lecture.major : '-'} - {lecture.name} - {lecture.professor} - {lecture.semester} - {lecture.time} - {lecture.groupName} + handleTimeTableLectureItem(lecture.id)} + > + {lecture.campus} + {lecture.category} + {lecture.code} + {lecture.credit} + {lecture.grade ?? '-'} + + {lecture.major !== 'None' ? lecture.major : '-'} + + {lecture.name} + {lecture.professor} + {lecture.semester} + {lecture.time} + {lecture.groupName} ); } @@ -49,7 +78,7 @@ function TimeTableLectureItem({ lecture }: TimeTableLectureTableItemProps) { function TimeTableLectureContent({ selectedValues, }: TimeTableLectureTableProps) { - const { data } = useLectureList({ + const { data, hasNextPage, fetchNextPage } = useLectureList({ campus: selectedValues.campus, type: selectedValues.type, grade: selectedValues.grade, @@ -57,46 +86,67 @@ function TimeTableLectureContent({ time: selectedValues.time, major: selectedValues.major, lectureName: selectedValues.lectureName, - cursor: 0, - limit: 10, + limit: 12, + }); + const scrollRef = useInfiniteScroll(() => { + if (hasNextPage) { + fetchNextPage(); + } }); - const lectureList = data.data.values; return ( - <> - {lectureList.length ? ( + + {data && ( <> - {...lectureList.map((lecture) => ( - - ))} + {data.length ? ( + <> + {...data.map((lecture) => ( + + ))} + + ) : ( + + + 검색 결과가 없습니다 + + + )} - ) : ( - - - 검색 결과가 없습니다 - + )} + {hasNextPage && ( + + { + scrollRef.current = node; + }} + colSpan={LECTURE_TABLE_ROW_HEADER.length} + className="h-1" + /> )} - + ); } function TimeTableLectureTable({ selectedValues }: TimeTableLectureTableProps) { return ( - - - - {LECTURE_TABLE_ROW_HEADER.map((header) => ( - - ))} - - - +
+
- {header} -
+ + + {LECTURE_TABLE_ROW_HEADER.map((header) => ( + + ))} + + @@ -111,8 +161,8 @@ function TimeTableLectureTable({ selectedValues }: TimeTableLectureTableProps) { > - -
+ {header} +
+ + ); } diff --git a/apps/time/src/widgets/time-table/ui/TimeTableModal.tsx b/apps/time/src/widgets/time-table/ui/TimeTableModal.tsx index d2f8495d..d238a04b 100644 --- a/apps/time/src/widgets/time-table/ui/TimeTableModal.tsx +++ b/apps/time/src/widgets/time-table/ui/TimeTableModal.tsx @@ -229,7 +229,7 @@ const TimeTableModalMajorInput = memo(function TimeTableModalMajorInput({ selectedMajor, handleMajorInputChange, }: TimeTableModalMajorInputProps) { - const [inputValue, setInputValue] = useState(''); + const [inputValue, setInputValue] = useState(''); const [open, setOpen] = useState(false); const debouncedValue = useDebounce({ value: inputValue, @@ -247,7 +247,10 @@ const TimeTableModalMajorInput = memo(function TimeTableModalMajorInput({ {...selectedMajor.map((major) => ( handleMajorInputChange(major)} + onClick={() => { + handleMajorInputChange(major); + setInputValue(''); + }} value={major} /> ))} diff --git a/apps/time/tailwind.config.ts b/apps/time/tailwind.config.ts index 539b5211..3d6cfbd2 100644 --- a/apps/time/tailwind.config.ts +++ b/apps/time/tailwind.config.ts @@ -1,4 +1,5 @@ import type { Config } from 'tailwindcss'; +import defaultTheme from 'tailwindcss/defaultTheme'; const config: Config = { content: ['./src/**/*.{js,ts,jsx,tsx,mdx}'], @@ -8,6 +9,9 @@ const config: Config = { center: true, padding: '2rem', }, + fontFamily: { + sans: ['"Pretendard"', ...defaultTheme.fontFamily.sans], + }, }, }, plugins: [],