diff --git a/apps/client/src/pages/memo/components/memo-list/memo-list.css.ts b/apps/client/src/pages/memo/components/memo-list/memo-list.css.ts new file mode 100644 index 00000000..c53d4458 --- /dev/null +++ b/apps/client/src/pages/memo/components/memo-list/memo-list.css.ts @@ -0,0 +1,25 @@ +import { style } from '@vanilla-extract/css'; + +const THREE_COLUMN_MIN_WIDTH = '1060px'; +const MEMO_LIST_CONTAINER = 'memoList'; + +const columns = (count: number) => `repeat(${count}, minmax(34rem, 38rem))`; + +export const memoListContainer = style({ + width: '100%', + containerType: 'inline-size', + containerName: MEMO_LIST_CONTAINER, +}); + +export const memoListGrid = style({ + display: 'grid', + gridTemplateColumns: columns(2), + justifyContent: 'safe center', + gap: '2rem', + + '@container': { + [`${MEMO_LIST_CONTAINER} (min-width: ${THREE_COLUMN_MIN_WIDTH})`]: { + gridTemplateColumns: columns(3), + }, + }, +}); diff --git a/apps/client/src/pages/memo/components/memo-list/memo-list.tsx b/apps/client/src/pages/memo/components/memo-list/memo-list.tsx new file mode 100644 index 00000000..295928e7 --- /dev/null +++ b/apps/client/src/pages/memo/components/memo-list/memo-list.tsx @@ -0,0 +1,45 @@ +import Card from '@shared/components/card/card'; +import { components } from '@shared/types/schema'; + +import * as styles from './memo-list.css'; + +type CardType = components['schemas']['MemoDashboardResponse']; + +interface MemoListProps { + cards: CardType[]; + isSelected: boolean; + isDragging: boolean; + onClickCard: () => void; +} + +const MemoList = ({ + cards, + isSelected, + isDragging, + onClickCard, +}: MemoListProps) => { + return ( +
+
+ {cards.map((card) => ( + + ))} +
+
+ ); +}; + +export default MemoList; diff --git a/apps/client/src/pages/memo/hooks/use-infinite-scroll.ts b/apps/client/src/pages/memo/hooks/use-infinite-scroll.ts new file mode 100644 index 00000000..32a156e5 --- /dev/null +++ b/apps/client/src/pages/memo/hooks/use-infinite-scroll.ts @@ -0,0 +1,47 @@ +import { useEffect, useRef } from 'react'; +import { type FetchNextPageOptions } from '@tanstack/react-query'; + +// 바닥에 닿기 전에 미리 불러와 로딩을 기다리는 구간을 없앤다 +const LOAD_MORE_ROOT_MARGIN = '200px'; + +interface UseInfiniteScrollProps { + hasNextPage: boolean; + isFetchingNextPage: boolean; + fetchNextPage: (options?: FetchNextPageOptions) => void; +} + +/** + * 목록 끝에 둔 요소가 화면에 들어오면 다음 페이지를 불러오는 무한 스크롤 훅 + * + * + * @param hasNextPage - false면 관측하지 않는다 + * @param isFetchingNextPage - 패칭 중에는 관측을 멈춘다. 패칭이 끝나면 옵저버를 + * 다시 만들어 관측 요소가 계속 보이는 상태에서도 다음 페이지가 이어진다 + * @param fetchNextPage - cancelRefetch 기본값(true)은 진행 중인 요청을 취소하고 + * 재시작하므로 false로 넘겨 호출한다 + * @returns 목록 마지막에 배치할 요소의 ref + */ +export const useInfiniteScroll = ({ + hasNextPage, + isFetchingNextPage, + fetchNextPage, +}: UseInfiniteScrollProps) => { + const loadMoreRef = useRef(null); + + useEffect(() => { + const loadMoreTarget = loadMoreRef.current; + if (!loadMoreTarget || !hasNextPage || isFetchingNextPage) return; + + const observer = new IntersectionObserver( + ([entry]) => { + if (entry?.isIntersecting) fetchNextPage({ cancelRefetch: false }); + }, + { rootMargin: LOAD_MORE_ROOT_MARGIN }, + ); + observer.observe(loadMoreTarget); + + return () => observer.disconnect(); + }, [hasNextPage, isFetchingNextPage, fetchNextPage]); + + return loadMoreRef; +}; diff --git a/apps/client/src/pages/memo/memo-page.css.ts b/apps/client/src/pages/memo/memo-page.css.ts index 094651cb..75d9bbd0 100644 --- a/apps/client/src/pages/memo/memo-page.css.ts +++ b/apps/client/src/pages/memo/memo-page.css.ts @@ -1,10 +1,8 @@ import { style } from '@vanilla-extract/css'; -import { themeVars } from '@cds/ui'; - export const container = style({ - ...themeVars.fontStyles.display_sb_36, - backgroundImage: themeVars.color.gradient02, - color: themeVars.color.grey700, - width: '30rem', + width: '100%', + paddingInline: '4rem', + display: 'flex', + justifyContent: 'center', }); diff --git a/apps/client/src/pages/memo/memo-page.tsx b/apps/client/src/pages/memo/memo-page.tsx index db23d58f..dc4f292f 100644 --- a/apps/client/src/pages/memo/memo-page.tsx +++ b/apps/client/src/pages/memo/memo-page.tsx @@ -2,9 +2,12 @@ import { PATH } from '@router/path'; import { useNavigate, useSearchParams } from 'react-router'; import EmptyView from '@shared/components/empty-view/empty-view'; -import MemoListView from '@shared/components/memo-list-view/memo-list-view'; import { useGetAllMemo, useGetMemoTotalCount } from './apis/queries'; +import MemoList from './components/memo-list/memo-list'; +import { useInfiniteScroll } from './hooks/use-infinite-scroll'; + +import * as styles from './memo-page.css'; import emptyImage from '/empty.svg'; @@ -12,33 +15,49 @@ const AllMemoPage = () => { const navigate = useNavigate(); const [searchParams] = useSearchParams(); const tagParam = searchParams.get('tag'); - const tagId = tagParam !== null ? [Number(tagParam)] : undefined; + const tagIds = tagParam !== null ? [Number(tagParam)] : undefined; const { - data: filteredMemos, + data: memosList, hasNextPage, isFetchingNextPage, fetchNextPage, - } = useGetAllMemo(tagId); - const { data: totalCount } = useGetMemoTotalCount(tagId); - - return totalCount === 0 ? ( - navigate(PATH.NEW_MEMO)} - /> - ) : ( - + } = useGetAllMemo(tagIds); + const { data: totalCount } = useGetMemoTotalCount(tagIds); + + const loadMoreRef = useInfiniteScroll({ + hasNextPage, + isFetchingNextPage, + fetchNextPage, + }); + + return ( + <> + {/* MemoHeader 컴포넌트 */} + +
+ {totalCount === 0 ? ( + navigate(PATH.NEW_MEMO)} + /> + ) : ( + <> + {/* 카드 선택/드래그, 상세 페이지 연결 전까지 임시 값 전달 */} + {}} + /> +
+ + )} +
+ ); }; diff --git a/apps/client/src/shared/components/card/card.css.ts b/apps/client/src/shared/components/card/card.css.ts index eb6a25c6..7b526b9d 100644 --- a/apps/client/src/shared/components/card/card.css.ts +++ b/apps/client/src/shared/components/card/card.css.ts @@ -12,6 +12,7 @@ const baseStyle = { padding: '2.4rem', width: '38rem', minWidth: '34rem', + maxWidth: '100%', height: '22rem', borderRadius: '12px', backgroundColor: themeVars.color.white, @@ -34,7 +35,7 @@ const draggingStyle = { cursor: 'grabbing', } as const; -const isNewAiSelectedStyle = { +const isNewSelectedStyle = { border: `1px solid ${themeVars.color.grey400}`, background: themeVars.color.grey100, selectors: { @@ -45,7 +46,7 @@ const isNewAiSelectedStyle = { }, } as const; -const isNewAiStyle = { +const isNewStyle = { border: '1px solid transparent', background: ` ${themeVars.color.gradient03} padding-box, @@ -65,12 +66,12 @@ export const cardContainer = recipe({ variants: { isSelected: { true: selectedStyle, false: {} }, isDragging: { true: draggingStyle, false: {} }, - isNewAi: { true: isNewAiStyle, false: {} }, + isNew: { true: isNewStyle, false: {} }, }, compoundVariants: [ { - variants: { isNewAi: true, isSelected: true }, - style: isNewAiSelectedStyle, + variants: { isNew: true, isSelected: true }, + style: isNewSelectedStyle, }, ], }); diff --git a/apps/client/src/shared/components/card/card.tsx b/apps/client/src/shared/components/card/card.tsx index 0456d4aa..92b00c1f 100644 --- a/apps/client/src/shared/components/card/card.tsx +++ b/apps/client/src/shared/components/card/card.tsx @@ -3,43 +3,45 @@ import { ComponentProps } from 'react'; import { Icon } from '@cds/icon'; import { Label, Title } from '@cds/ui'; +import { components } from '@shared/types/schema'; import { formatDate } from '@shared/utils/format-date'; import * as styles from './card.css'; -// TODO: Tag 백엔드 타입에 맞게 변경 -type TagType = { - tagId?: number; - name?: string; -}; - type CardInfoType = { - tagList?: TagType[]; + tagList?: components['schemas']['TagResponse'][]; title: string; content: string; fileCount: number; imageCount: number; createAt: string; + isNew?: boolean; }; interface CardProps extends ComponentProps<'article'> { card: CardInfoType; isSelected?: boolean; isDragging?: boolean; - isNewAi?: boolean; } const Card = ({ - card: { tagList = [], title, content, fileCount, imageCount, createAt }, + card: { + tagList = [], + title, + content, + fileCount, + imageCount, + createAt, + isNew = false, + }, isSelected = false, isDragging = false, - isNewAi = false, ...props }: CardProps) => { return (
diff --git a/apps/client/src/shared/components/memo-list-view/components/memo-list/memo-card-grid.css.ts b/apps/client/src/shared/components/memo-list-view/components/memo-list/memo-card-grid.css.ts deleted file mode 100644 index 175611f2..00000000 --- a/apps/client/src/shared/components/memo-list-view/components/memo-list/memo-card-grid.css.ts +++ /dev/null @@ -1,33 +0,0 @@ -import { style } from '@vanilla-extract/css'; - -export const gridContainer = style({ - display: 'grid', - gap: '2.4rem', - marginBottom: '2rem', - marginLeft: '2rem', - padding: '0 2rem', - gridTemplateColumns: 'repeat(4, 1fr)', - '@media': { - '(max-width: 1770px)': { - gridTemplateColumns: 'repeat(3, 1fr)', - }, - }, -}); - -export const gridItem = style({ - width: '100%', -}); - -export const gridItemWithImage = style([ - gridItem, - { - gridRow: 'span 2', - }, -]); - -export const scrollContainer = style({ - height: '100vh', - overflowY: 'scroll', - overflowX: 'hidden', - width: 'auto', -}); diff --git a/apps/client/src/shared/components/memo-list-view/components/memo-list/memo-card-grid.tsx b/apps/client/src/shared/components/memo-list-view/components/memo-list/memo-card-grid.tsx deleted file mode 100644 index e9b70102..00000000 --- a/apps/client/src/shared/components/memo-list-view/components/memo-list/memo-card-grid.tsx +++ /dev/null @@ -1,118 +0,0 @@ -import { useEffect, useRef, useState } from 'react'; - -import Card from '@shared/components/card/card'; -import { components } from '@shared/types/schema'; - -import * as styles from './memo-card-grid.css'; - -interface MemoCardItemProps { - memo: components['schemas']['MemoDashboardResponse']; - isSelected: boolean; - isDragging: boolean; - isNewAi: boolean; - onSelect: (id: number) => void; - onDragStart: (id: number) => void; - onDragEnd: () => void; -} - -const MemoCardItem = ({ - memo, - isSelected, - isDragging, - isNewAi, - onSelect, - onDragStart, - onDragEnd, -}: MemoCardItemProps) => { - const { memoId, tagList, title, content, fileCount, imageCount, createdAt } = - memo; - - const handleDragStart = () => { - setTimeout(() => onDragStart(memoId ?? 0), 0); - }; - - return ( -
- onSelect(memoId ?? 0)} - /> -
- ); -}; - -interface MemoCardGridProps { - memoData: components['schemas']['MemoDashboardResponse'][]; - hasNextPage?: boolean; - isFetchingNextPage?: boolean; - onLoadMore?: () => void; -} - -const MemoCardGrid = ({ - memoData, - hasNextPage = false, - isFetchingNextPage = false, - onLoadMore, -}: MemoCardGridProps) => { - const scrollContainerRef = useRef(null); - const [selectedId, setSelectedId] = useState(null); - const [draggingId, setDraggingId] = useState(null); - - useEffect(() => { - const scrollContainer = scrollContainerRef.current; - if (!scrollContainer || !onLoadMore || !hasNextPage || isFetchingNextPage) { - return; - } - - const handleScroll = () => { - const { scrollTop, scrollHeight, clientHeight } = scrollContainer; - const scrollBottom = scrollHeight - scrollTop - clientHeight; - - if (scrollBottom < 200) { - onLoadMore(); - } - }; - - scrollContainer.addEventListener('scroll', handleScroll); - - return () => { - scrollContainer.removeEventListener('scroll', handleScroll); - }; - }, [hasNextPage, isFetchingNextPage, onLoadMore]); - - return ( -
-
- {memoData.map((memo) => ( - setDraggingId(null)} - /> - ))} -
-
- ); -}; - -export default MemoCardGrid; diff --git a/apps/client/src/shared/components/memo-list-view/memo-list-view.css.ts b/apps/client/src/shared/components/memo-list-view/memo-list-view.css.ts deleted file mode 100644 index ac110bc1..00000000 --- a/apps/client/src/shared/components/memo-list-view/memo-list-view.css.ts +++ /dev/null @@ -1,16 +0,0 @@ -import { style } from '@vanilla-extract/css'; - -export const container = style({ - display: 'flex', - justifyContent: 'center', - height: '100vh', - whiteSpace: 'nowrap', - width: '100%', -}); - -export const contentWrapper = style({ - display: 'flex', - flexDirection: 'column', - alignContent: 'center', - padding: '0', -}); diff --git a/apps/client/src/shared/components/memo-list-view/memo-list-view.tsx b/apps/client/src/shared/components/memo-list-view/memo-list-view.tsx deleted file mode 100644 index 487e921f..00000000 --- a/apps/client/src/shared/components/memo-list-view/memo-list-view.tsx +++ /dev/null @@ -1,40 +0,0 @@ -import { components } from '@shared/types/schema'; - -import Header from '../header/header'; -import MemoCardGrid from './components/memo-list/memo-card-grid'; - -import * as styles from './memo-list-view.css'; - -export interface MemoListViewProps { - title?: string; - initialMemos?: components['schemas']['MemoDashboardResponse'][]; - hasNextPage?: boolean; - isFetchingNextPage?: boolean; - fetchNextPage?: () => void; - totalCount: number; -} - -const MemoListView = ({ - title = '전체 메모', - initialMemos, - hasNextPage = false, - isFetchingNextPage = false, - fetchNextPage, - totalCount, -}: MemoListViewProps) => { - return ( -
-
-
- -
-
- ); -}; - -export default MemoListView;