diff --git a/src/app/features/dashboard_Id/Card/Card.tsx b/src/app/features/dashboard_Id/Card/Card.tsx index 948bbb8..efb94f8 100644 --- a/src/app/features/dashboard_Id/Card/Card.tsx +++ b/src/app/features/dashboard_Id/Card/Card.tsx @@ -101,7 +101,6 @@ export default function Card({ setOpenModifyCard(false)} - // columnId={column.id} currentColumn={currentColumn} card={card} /> diff --git a/src/app/features/dashboard_Id/Card/cardFormModals/CreateCardForm.tsx b/src/app/features/dashboard_Id/Card/cardFormModals/CreateCardForm.tsx index 2ab4290..d6f95fc 100644 --- a/src/app/features/dashboard_Id/Card/cardFormModals/CreateCardForm.tsx +++ b/src/app/features/dashboard_Id/Card/cardFormModals/CreateCardForm.tsx @@ -126,7 +126,7 @@ export default function CreateCardForm({ width={26} height={24} className={cn( - 'pointer-events-none absolute right-0 top-1/2 -translate-y-1/2 transition-transform duration-300', + 'pointer-events-none absolute right-0 top-1/2 -translate-y-1/2 transition-transform duration-300 mobile:right-8', isOpen && 'rotate-180', )} /> diff --git a/src/app/features/dashboard_Id/Card/cardFormModals/CreateCardModal.tsx b/src/app/features/dashboard_Id/Card/cardFormModals/CreateCardModal.tsx index 66ffc66..affa568 100644 --- a/src/app/features/dashboard_Id/Card/cardFormModals/CreateCardModal.tsx +++ b/src/app/features/dashboard_Id/Card/cardFormModals/CreateCardModal.tsx @@ -14,7 +14,7 @@ export default function CreateCardModal({ children }: ModalProps) { return createPortal(
-
+
{children}
, diff --git a/src/app/features/dashboard_Id/Card/cardFormModals/ModifyCardForm.tsx b/src/app/features/dashboard_Id/Card/cardFormModals/ModifyCardForm.tsx index 14dbcd3..eb7b032 100644 --- a/src/app/features/dashboard_Id/Card/cardFormModals/ModifyCardForm.tsx +++ b/src/app/features/dashboard_Id/Card/cardFormModals/ModifyCardForm.tsx @@ -131,7 +131,7 @@ export default function ModifyCardForm({ >

할 일 수정

-
+
{/* 컬럼 선택 */} setIsOpenColumn((prev) => !prev)} value={selectedColumn?.columnTitle ?? ''} readOnly - className="Input-readOnly w-217" + className="Input-readOnly-217" id="columnId" type="text" placeholder={currentColumn.columnTitle} /> {/* 인풋에 보이는 선택된 컬럼 & 오른쪽 화살표 */} -
+
setIsOpen((prev) => !prev)} // value={selectedAssignee?.nickname ?? ''} readOnly - className="Input-readOnly w-217" + className="Input-readOnly-217" id="assigneeUserId" type="text" /> diff --git a/src/app/features/dashboard_Id/Card/cardModal/CardContent.tsx b/src/app/features/dashboard_Id/Card/cardModal/CardContent.tsx index 3d56ae0..20115e2 100644 --- a/src/app/features/dashboard_Id/Card/cardModal/CardContent.tsx +++ b/src/app/features/dashboard_Id/Card/cardModal/CardContent.tsx @@ -1,5 +1,5 @@ import Image from 'next/image' -import { useState } from 'react' +import { useRef, useState } from 'react' import { Avatar } from '@/app/shared/components/common/Avatar' import Dropdown from '@/app/shared/components/common/Dropdown/Dropdown' @@ -8,8 +8,6 @@ import { useIsMobile } from '@/app/shared/hooks/useIsmobile' import { useDeleteCardMutation } from '../../api/useDeleteCardMutation' import { Card } from '../../type/Card.type' import { Column } from '../../type/Column.type' -import CreateCardModal from '../cardFormModals/CreateCardModal' -import ModifyCardForm from '../cardFormModals/ModifyCardForm' import ColumnTitle from '../ColumnTitle' import Tags from '../Tags' import CommentForm from './CommentForm' @@ -26,16 +24,16 @@ export default function CardContent({ card: Card column: Column }) { - // const { id, imageUrl, title, tags, dueDate, assignee, description } = card - const [openModifyCard, setOpenModifyCard] = useState(false) - // const { title: columnTitle, id: columnId } = column - // const currentColumn = { columnTitle, columnId } const isMobile = useIsMobile() const { mutate: deleteCard, isPending } = useDeleteCardMutation() + const modalScrollRef = useRef(null) + return ( - //
-
+

{card.title}

@@ -166,7 +164,7 @@ export default function CardContent({ columnId={card.columnId} dashboardId={card.dashboardId} /> - +
) } diff --git a/src/app/features/dashboard_Id/Card/cardModal/CardModal.tsx b/src/app/features/dashboard_Id/Card/cardModal/CardModal.tsx index 11b3353..c494164 100644 --- a/src/app/features/dashboard_Id/Card/cardModal/CardModal.tsx +++ b/src/app/features/dashboard_Id/Card/cardModal/CardModal.tsx @@ -14,9 +14,7 @@ export default function CardModal({ children }: ModalProps) { return createPortal(
-
- {children} -
+
{children}
, modalRoot, ) diff --git a/src/app/features/dashboard_Id/Card/cardModal/Comments.tsx b/src/app/features/dashboard_Id/Card/cardModal/Comments.tsx index 5456354..ed8ae50 100644 --- a/src/app/features/dashboard_Id/Card/cardModal/Comments.tsx +++ b/src/app/features/dashboard_Id/Card/cardModal/Comments.tsx @@ -1,25 +1,46 @@ -import { format } from 'date-fns' -import { useState } from 'react' import { toast } from 'sonner' -import { Avatar } from '@/app/shared/components/common/Avatar' - -import useCommentsQuery from '../../api/useCommentsQuery' +import { useInfiniteComments } from '../../api/useInfiniteComments' +import { useInfiniteScroll } from '../../hooks/useInfiniteScroll' import { Comment as CommentType } from '../../type/Comment.type' import Comment from './Comment' -export default function Comments({ cardId }: { cardId: number }) { - const { data, isLoading, error } = useCommentsQuery(cardId) - const comments = data?.comments +export default function Comments({ + cardId, + scrollRef, +}: { + cardId: number + scrollRef: React.RefObject +}) { + const { + data, + fetchNextPage, + hasNextPage, + isFetchingNextPage, + isLoading, + isError, + } = useInfiniteComments(cardId) + + useInfiniteScroll(fetchNextPage, hasNextPage, isFetchingNextPage, scrollRef) - if (error) { - toast.error(error?.message || '문제가 발생했습니다.') + if (isError) { + toast.error('댓글 불러오기 실패') } return ( -
- {comments?.map((comment: CommentType) => ( - - ))} -
+ <> +
+ {data?.pages.map((page) => + page?.comments.map((comment: CommentType) => ( + + )), + )} +
+ {/* 무한 스크롤 관련 */} + {isFetchingNextPage && ( +

+ 댓글을 불러오는 중... +

+ )} + ) } diff --git a/src/app/features/dashboard_Id/Column/Column.tsx b/src/app/features/dashboard_Id/Column/Column.tsx index 5e45ef7..6f4076c 100644 --- a/src/app/features/dashboard_Id/Column/Column.tsx +++ b/src/app/features/dashboard_Id/Column/Column.tsx @@ -1,18 +1,31 @@ import Image from 'next/image' import { useState } from 'react' +import { toast } from 'sonner' import { cn } from '@/app/shared/lib/cn' import { useCardMutation } from '../api/useCardMutation' -import useCards from '../api/useCards' +import { useInfiniteCards } from '../api/useInfiniteCards' import Card from '../Card/Card' import CreateCardForm from '../Card/cardFormModals/CreateCardForm' import CreateCardModal from '../Card/cardFormModals/CreateCardModal' +import { useInfiniteScroll } from '../hooks/useInfiniteScroll' import { useDragStore } from '../store/useDragStore' import type { Column as ColumnType } from '../type/Column.type' export default function Column({ column }: { column: ColumnType }) { const { id, title }: { id: number; title: string } = column - const { data, isLoading, error } = useCards(id) + + const { + data, + fetchNextPage, + hasNextPage, + isFetchingNextPage, + isLoading, + isError, + } = useInfiniteCards(id) + + useInfiniteScroll(fetchNextPage, hasNextPage, isFetchingNextPage) + const [isDraggingover, setDraggingover] = useState(false) const { draggingCard, clearDraggingCard } = useDragStore() const cardMutation = useCardMutation() @@ -21,8 +34,8 @@ export default function Column({ column }: { column: ColumnType }) { const [openCreateColumn, setOpenCreateColumn] = useState(false) //page.tsx const [oepnConfigColumn, setConfigColumn] = useState(false) - if (isLoading) return

loading...

- if (error) return

error...{error.message}

+ if (isLoading) return

loading...

// 스켈레톤 적용???⭐️ + if (isError) return toast.error('할 일 불러오기 실패') return (

{title}

- {data?.totalCount} + {data?.pages[0]?.totalCount ?? 0}
- {data?.cards.map((card) => ( - - ))} - + {data?.pages.map((page) => + page.cards.map((card) => ( + + )), + )} {/* 카드 생성 모달 */} {openCreateCard && ( @@ -101,6 +115,19 @@ export default function Column({ column }: { column: ColumnType }) { /> )} + + {/* 무한 스크롤 관련 */} + {isFetchingNextPage && ( +

+ 카드를 불러오는 중... +

+ )} + + {!hasNextPage && ( +

+ 모든 카드를 불러왔습니다 +

+ )}
) } diff --git a/src/app/features/dashboard_Id/api/fetchCards.ts b/src/app/features/dashboard_Id/api/fetchCards.ts index ab717ad..fe04c9c 100644 --- a/src/app/features/dashboard_Id/api/fetchCards.ts +++ b/src/app/features/dashboard_Id/api/fetchCards.ts @@ -3,12 +3,24 @@ import api from '@/app/shared/lib/testAxios' // import api from '@/app/shared/lib/axios' import { CardResponse } from '../type/Card.type' -export async function fetchCards( - columnId: number, - size: number = 10, -): Promise { +export async function fetchCards({ + columnId, + size = 6, + cursorId, +}: { + columnId: number + size?: number + cursorId?: number | null +}): Promise { const res = await api.get( - `/${process.env.NEXT_PUBLIC_TEAM_ID}/cards?size=${size}&columnId=${columnId}`, + `/${process.env.NEXT_PUBLIC_TEAM_ID}/cards`, + { + params: { + columnId, + size, + ...(cursorId ? { cursorId } : {}), // 첫 페이지는 cursor 생략 + }, + }, ) return res.data } diff --git a/src/app/features/dashboard_Id/api/fetchComments.ts b/src/app/features/dashboard_Id/api/fetchComments.ts index dfb5499..dfa2e39 100644 --- a/src/app/features/dashboard_Id/api/fetchComments.ts +++ b/src/app/features/dashboard_Id/api/fetchComments.ts @@ -3,10 +3,24 @@ import api from '@/app/shared/lib/testAxios' // import api from '@/app/shared/lib/axios' import { CommentsResponse } from '../type/Comment.type' -export async function fetchComments(cardId: number): Promise { +export async function fetchComments({ + cardId, + size = 5, + cursorId, +}: { + cardId: number + size?: number + cursorId?: number | null +}): Promise { const res = await api.get( - `/${process.env.NEXT_PUBLIC_TEAM_ID}/comments?size=10&cardId=${cardId}`, + `/${process.env.NEXT_PUBLIC_TEAM_ID}/comments`, + { + params: { + size, + ...(cursorId ? { cursorId } : {}), // 첫 페이지는 cursor 생략 + cardId, + }, + }, ) - return res.data } diff --git a/src/app/features/dashboard_Id/api/useCards.ts b/src/app/features/dashboard_Id/api/useCards.ts deleted file mode 100644 index 1edb746..0000000 --- a/src/app/features/dashboard_Id/api/useCards.ts +++ /dev/null @@ -1,12 +0,0 @@ -//size일단 10으로 하고, 나중에 커서아이디 받아서 무한 스크롤 구현해야 함. -import { useQuery } from '@tanstack/react-query' - -import { CardResponse } from '../type/Card.type' -import { fetchCards } from './fetchCards' - -export default function useCards(columnId: number) { - return useQuery({ - queryKey: ['columnId', columnId], - queryFn: () => fetchCards(columnId), - }) -} diff --git a/src/app/features/dashboard_Id/api/useCommentsQuery.ts b/src/app/features/dashboard_Id/api/useCommentsQuery.ts deleted file mode 100644 index 2a1c28f..0000000 --- a/src/app/features/dashboard_Id/api/useCommentsQuery.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { useQuery } from '@tanstack/react-query' - -import { CommentsResponse } from '../type/Comment.type' -import { fetchComments } from './fetchComments' - -export default function useCommentsQuery(cardId: number) { - return useQuery({ - queryKey: ['comments', cardId], - queryFn: () => fetchComments(cardId), - }) -} diff --git a/src/app/features/dashboard_Id/api/useInfiniteCards.ts b/src/app/features/dashboard_Id/api/useInfiniteCards.ts new file mode 100644 index 0000000..2c14bd3 --- /dev/null +++ b/src/app/features/dashboard_Id/api/useInfiniteCards.ts @@ -0,0 +1,17 @@ +import { useInfiniteQuery } from '@tanstack/react-query' + +import { CardResponse } from '../type/Card.type' +import { fetchCards } from './fetchCards' + +export function useInfiniteCards(columnId: number) { + return useInfiniteQuery({ + queryKey: ['columnId', columnId], + queryFn: ({ pageParam = null }) => + fetchCards({ columnId, cursorId: pageParam as number }), + getNextPageParam: (lastPage) => { + // 마지막 카드의 id를 다음 cursor로 사용 + return lastPage.cards.length === 0 ? undefined : lastPage.cursorId + }, + initialPageParam: null, + }) +} diff --git a/src/app/features/dashboard_Id/api/useInfiniteComments.ts b/src/app/features/dashboard_Id/api/useInfiniteComments.ts new file mode 100644 index 0000000..2efef41 --- /dev/null +++ b/src/app/features/dashboard_Id/api/useInfiniteComments.ts @@ -0,0 +1,17 @@ +import { useInfiniteQuery } from '@tanstack/react-query' + +import { CommentsResponse } from '../type/Comment.type' +import { fetchComments } from './fetchComments' + +export function useInfiniteComments(cardId: number) { + return useInfiniteQuery({ + queryKey: ['comments', cardId], + queryFn: ({ pageParam = null }) => + fetchComments({ cardId, cursorId: pageParam as number }), + getNextPageParam: (lastPage) => { + // 마지막 카드의 id를 다음 cursor로 사용 + return lastPage.comments.length === 0 ? undefined : lastPage.cursorId + }, + initialPageParam: null, + }) +} diff --git a/src/app/features/dashboard_Id/api/usePutCardMutation.ts b/src/app/features/dashboard_Id/api/usePutCardMutation.ts index cd1ec3f..cf50064 100644 --- a/src/app/features/dashboard_Id/api/usePutCardMutation.ts +++ b/src/app/features/dashboard_Id/api/usePutCardMutation.ts @@ -3,7 +3,6 @@ import axios from 'axios' import { toast } from 'sonner' import { CardModifyFormData } from '../type/CardFormData.type' -import { postCard } from './postCard' import { putCard } from './putCard' // ✅ 카드 수정 모달에서 사용 (CreateCardForm.tsx) diff --git a/src/app/features/dashboard_Id/api/useUploadCardImage.ts b/src/app/features/dashboard_Id/api/useUploadCardImage.ts index 4c1074f..ebd1480 100644 --- a/src/app/features/dashboard_Id/api/useUploadCardImage.ts +++ b/src/app/features/dashboard_Id/api/useUploadCardImage.ts @@ -13,9 +13,8 @@ export function useUploadCardImage() { toast.error( '이미지 업로드 중 오류가 발생했습니다. 크기가 작은 파일을 시도해주세요.', ) - const message = - error?.response?.data?.message ?? // 서버가 준 에러메세지 - '이미지 업로드 중 오류가 발생했습니다. 크기가 작은 파일을 시도해주세요.' // 없으면 이 내용으로 + const message = error?.response?.data?.message // 서버가 준 에러메세지 + console.error('이미지 업로드 실패:', message ?? '알 수 없는 에러') }, }) } diff --git a/src/app/features/dashboard_Id/hooks/useInfiniteScroll.ts b/src/app/features/dashboard_Id/hooks/useInfiniteScroll.ts index e69de29..e86b9b2 100644 --- a/src/app/features/dashboard_Id/hooks/useInfiniteScroll.ts +++ b/src/app/features/dashboard_Id/hooks/useInfiniteScroll.ts @@ -0,0 +1,39 @@ +import { useCallback, useEffect } from 'react' + +/** + * 범용 무한스크롤 훅 + * + * @param fetchNextPage - 다음 페이지를 가져오는 함수 + * @param hasNextPage - 다음 페이지가 있는지 여부 + * @param isFetchingNextPage - 다음 페이지를 가져오는 중인지 여부 + * @param targetRef - (선택) 스크롤 대상이 되는 ref (모달 등 특정 영역에만 작동하고 싶을 때) + */ +export const useInfiniteScroll = ( + fetchNextPage: () => void, + hasNextPage: boolean, + isFetchingNextPage: boolean, + targetRef?: React.RefObject, // optional +) => { + const handleScroll = useCallback(() => { + const el = targetRef?.current ?? window + const scrollTop = targetRef?.current?.scrollTop ?? window.scrollY + const scrollHeight = + targetRef?.current?.scrollHeight ?? document.documentElement.scrollHeight + const clientHeight = targetRef?.current?.clientHeight ?? window.innerHeight + + const scrollPercentage = (scrollTop + clientHeight) / scrollHeight + const isNearBottom = scrollPercentage >= 0.8 + + if (isNearBottom && hasNextPage && !isFetchingNextPage) { + fetchNextPage() + } + }, [fetchNextPage, hasNextPage, isFetchingNextPage, targetRef]) + + useEffect(() => { + const el = targetRef?.current ?? window + el.addEventListener('scroll', handleScroll, { passive: true }) + return () => { + el.removeEventListener('scroll', handleScroll) + } + }, [handleScroll, targetRef]) +} diff --git a/src/app/globals.css b/src/app/globals.css index e4d5a47..678f151 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -104,8 +104,11 @@ body { } .Input { - @apply w-520 rounded-6 border border-[#D9D9D9] px-16 py-11 pt-14 text-14 text-[#333236] placeholder-gray-400 focus:border-[#44aeff] focus:outline-none dark:border-[#747474] dark:text-[#FFFFFF] dark:focus:border-[#3474a5]; + @apply w-520 rounded-6 border border-[#D9D9D9] px-16 py-11 pt-14 text-14 text-[#333236] placeholder-gray-400 focus:border-[#44aeff] focus:outline-none dark:border-[#747474] dark:text-[#FFFFFF] dark:focus:border-[#3474a5] mobile:w-280; } .Input-readOnly { - @apply w-520 cursor-pointer rounded-6 border border-[#D9D9D9] px-16 py-11 pt-14 text-14 text-[#333236] placeholder-gray-400 caret-transparent focus:border-[#44aeff] focus:outline-none dark:border-[#747474] dark:text-[#FFFFFF] dark:focus:border-[#3474a5]; + @apply w-520 cursor-pointer rounded-6 border border-[#D9D9D9] px-16 py-11 pt-14 text-14 text-[#333236] placeholder-gray-400 caret-transparent focus:border-[#44aeff] focus:outline-none dark:border-[#747474] dark:text-[#FFFFFF] dark:focus:border-[#3474a5] mobile:w-280; +} +.Input-readOnly-217 { + @apply w-217 cursor-pointer rounded-6 border border-[#D9D9D9] px-16 py-11 pt-14 text-14 text-[#333236] placeholder-gray-400 caret-transparent focus:border-[#44aeff] focus:outline-none dark:border-[#747474] dark:text-[#FFFFFF] dark:focus:border-[#3474a5] mobile:w-280; }