Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions apps/client/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
"framer-motion": "^12.23.12",
"react": "^19.1.1",
"react-dom": "^19.1.1",
"react-error-boundary": "^6.0.0",
"react-router-dom": "^7.8.2"
},
"devDependencies": {
Expand Down
9 changes: 9 additions & 0 deletions apps/client/src/assets/chippi_error.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
159 changes: 21 additions & 138 deletions apps/client/src/pages/myBookmark/MyBookmark.tsx
Original file line number Diff line number Diff line change
@@ -1,28 +1,23 @@
import { Badge, PopupContainer } from '@pinback/design-system/ui';
import { useState, useRef } from 'react';
import {
useGetBookmarkArticles,
useGetBookmarkUnreadArticles,
useGetCategoryBookmarkArticles,
} from '@pages/myBookmark/apis/queries';
import { PopupContainer } from '@pinback/design-system/ui';
import { useState, useRef, Suspense } from 'react';
import { useSearchParams } from 'react-router-dom';
import { REMIND_MOCK_DATA } from '@pages/remind/constants';
import CardEditModal from '@shared/components/cardEditModal/CardEditModal';
import OptionsMenuPortal from '@shared/components/sidebar/OptionsMenuPortal';
import { useAnchoredMenu } from '@shared/hooks/useAnchoredMenu';
import { belowOf } from '@shared/utils/anchorPosition';
import NoArticles from '@pages/myBookmark/components/NoArticles/NoArticles';
import { Icon } from '@pinback/design-system/icons';
import { useQueryClient } from '@tanstack/react-query';
import {
useGetArticleDetail,
useDeleteRemindArticle,
usePutArticleReadStatus,
} from '@shared/apis/queries';
import NoUnreadArticles from '@pages/myBookmark/components/noUnreadArticles/NoUnreadArticles';
import FetchCard from '@pages/myBookmark/components/fetchCard/FetchCard';
import { useInfiniteScroll } from '@shared/hooks/useInfiniteScroll';
import Tooltip from '@shared/components/tooltip/Tooltip';
import ArticlesLoadingBoundary from '@shared/components/articlesLoadingBoundary/ArticlesLoadingBoundary';
import ArticlesErrorBoundary from '@shared/components/articlesErrorBoundary/ArticlesErrorBoundary';
import { ErrorBoundary } from 'react-error-boundary';
import MyBookmarkContent from '@pages/myBookmark/components/myBookmarkContent/MyBookmarkContent';

const MyBookmark = () => {
const [activeBadge, setActiveBadge] = useState<'all' | 'notRead'>('all');
Expand All @@ -32,35 +27,14 @@ const MyBookmark = () => {

const [searchParams] = useSearchParams();
const queryClient = useQueryClient();

const category = searchParams.get('category');
const categoryId = searchParams.get('id');

const scrollContainerRef = useRef<HTMLDivElement>(null);

const { mutate: updateToReadStatus } = usePutArticleReadStatus();
const { mutate: deleteArticle } = useDeleteRemindArticle();

const {
data: articlesData,
fetchNextPage: fetchNextArticles,
hasNextPage: hasNextArticles,
} = useGetBookmarkArticles();

const {
data: unreadArticlesData,
fetchNextPage: fetchNextUnreadArticles,
hasNextPage: hasNextUnreadArticles,
} = useGetBookmarkUnreadArticles();

const {
data: categoryArticlesData,
fetchNextPage: fetchNextCategoryArticles,
hasNextPage: hasNextCategoryArticles,
} = useGetCategoryBookmarkArticles(
categoryId,
activeBadge === 'notRead' ? false : null
);

const { mutate: getArticleDetail, data: articleDetail } =
useGetArticleDetail();

Expand All @@ -72,34 +46,9 @@ const MyBookmark = () => {
containerRef,
} = useAnchoredMenu((anchor) => belowOf(anchor, 8));

const articlesToDisplay = category
? (categoryArticlesData?.pages.flatMap((page) => page.articles) ?? [])
: activeBadge === 'all'
? (articlesData?.pages.flatMap((page) => page.articles) ?? [])
: (unreadArticlesData?.pages.flatMap((page) => page.articles) ?? []);

const hasNextPage = category
? hasNextCategoryArticles
: activeBadge === 'all'
? hasNextArticles
: hasNextUnreadArticles;

const fetchNextPage = category
? fetchNextCategoryArticles
: activeBadge === 'all'
? fetchNextArticles
: fetchNextUnreadArticles;

const observerRef = useInfiniteScroll({
fetchNextPage,
hasNextPage,
root: scrollContainerRef,
});

const handleDeleteArticle = (id: number) => {
deleteArticle(id, {
onSuccess: () => {
// TODO: 쿼리키 팩토리 패턴 적용
queryClient.invalidateQueries({ queryKey: ['bookmarkReadArticles'] });
queryClient.invalidateQueries({ queryKey: ['bookmarkUnreadArticles'] });
queryClient.invalidateQueries({
Expand All @@ -119,31 +68,6 @@ const MyBookmark = () => {
const getBookmarkTitle = (id: number | null) =>
id == null ? '' : (REMIND_MOCK_DATA.find((d) => d.id === id)?.title ?? '');

const handleBadgeClick = (badgeType: 'all' | 'notRead') => {
setActiveBadge(badgeType);
};

const EmptyStateComponent = () => {
if (articlesToDisplay.length === 0) {
const totalArticlesInAllView = articlesData?.pages[0]?.totalArticle;
if (totalArticlesInAllView === 0) {
return <NoArticles />;
}
return <NoUnreadArticles />;
}
return null;
};

const totalArticleCount =
(category
? categoryArticlesData?.pages[0]?.totalArticle
: articlesData?.pages[0]?.totalArticle) ?? 0;

const totalUnreadArticleCount =
(category
? categoryArticlesData?.pages[0]?.totalUnreadArticle
: articlesData?.pages[0]?.totalUnreadArticle) ?? 0;

return (
<div className="flex h-screen flex-col py-[5.2rem] pl-[8rem] pr-[5rem]">
<div className="flex items-center gap-[0.4rem]">
Expand All @@ -162,63 +86,22 @@ const MyBookmark = () => {
<p className="head3 text-main500">{category || ''}</p>
</div>

<div className="mt-[3rem] flex gap-[2.4rem]">
<Badge
text="전체보기"
countNum={totalArticleCount}
onClick={() => handleBadgeClick('all')}
isActive={activeBadge === 'all'}
/>
<Badge
text="안 읽음"
countNum={totalUnreadArticleCount}
onClick={() => handleBadgeClick('notRead')}
isActive={activeBadge === 'notRead'}
/>
</div>
<Tooltip />

{articlesToDisplay.length > 0 ? (
<div
ref={scrollContainerRef}
className="scrollbar-hide mt-[2.6rem] flex h-screen flex-wrap content-start gap-[1.6rem] overflow-y-auto scroll-smooth"
>
{articlesToDisplay.map((article) => (
<FetchCard
key={article.articleId}
article={article}
onClick={() => {
window.open(article.url, '_blank');
updateToReadStatus(article.articleId, {
onSuccess: () => {
// TODO: 쿼리키 팩토리 패턴 적용
queryClient.invalidateQueries({
queryKey: ['bookmarkReadArticles'],
});
queryClient.invalidateQueries({
queryKey: ['bookmarkUnreadArticles'],
});
queryClient.invalidateQueries({
queryKey: ['categoryBookmarkArticles'],
});
queryClient.invalidateQueries({ queryKey: ['arcons'] });
},
onError: (error) => {
console.error(error);
},
});
}}
onOptionsClick={(e) => {
e.stopPropagation();
openMenu(article.articleId, e.currentTarget);
}}
/>
))}
<div ref={observerRef} style={{ height: '1px', width: '100%' }} />
</div>
) : (
<EmptyStateComponent />
)}
<Suspense fallback={<ArticlesLoadingBoundary />}>
<ErrorBoundary FallbackComponent={ArticlesErrorBoundary}>
<MyBookmarkContent
category={category}
categoryId={categoryId}
activeBadge={activeBadge}
onBadgeChange={setActiveBadge}
updateToReadStatus={updateToReadStatus}
openMenu={openMenu}
queryClient={queryClient}
scrollContainerRef={scrollContainerRef}
/>
</ErrorBoundary>
</Suspense>

<OptionsMenuPortal
open={menu.open}
Expand Down
37 changes: 15 additions & 22 deletions apps/client/src/pages/myBookmark/apis/queries.ts
Original file line number Diff line number Diff line change
@@ -1,53 +1,46 @@
import { useInfiniteQuery } from '@tanstack/react-query';
import { useSuspenseInfiniteQuery } from '@tanstack/react-query';
import {
getBookmarkArticles,
getBookmarkUnreadArticles,
getCategoryBookmarkArticles,
} from './axios';

export const useGetBookmarkArticles = () => {
return useInfiniteQuery({
return useSuspenseInfiniteQuery({
queryKey: ['bookmarkReadArticles'],
queryFn: ({ pageParam = 0 }) => getBookmarkArticles(pageParam, 20),
initialPageParam: 0,
getNextPageParam: (lastPage, allPages) => {
if (lastPage.articles.length === 0) {
return undefined;
}
return allPages.length;
},
getNextPageParam: (lastPage, allPages) =>
lastPage.articles.length === 0 ? undefined : allPages.length,
Comment on lines +9 to +14
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

suspenseInfiniteQuery는 진심으로 처음 봤는데,, tanstack이 지원해주는게 참 많구만요

});
};

export const useGetBookmarkUnreadArticles = () => {
return useInfiniteQuery({
return useSuspenseInfiniteQuery({
queryKey: ['bookmarkUnreadArticles'],
queryFn: ({ pageParam = 0 }) => getBookmarkUnreadArticles(pageParam, 20),
initialPageParam: 0,
getNextPageParam: (lastPage, allPages) => {
if (lastPage.articles.length === 0) {
return undefined;
}
return allPages.length;
},
getNextPageParam: (lastPage, allPages) =>
lastPage.articles.length === 0 ? undefined : allPages.length,
});
};

export const useGetCategoryBookmarkArticles = (
categoryId: string | null,
readStatus: boolean | null
) => {
return useInfiniteQuery({
return useSuspenseInfiniteQuery({
queryKey: ['categoryBookmarkArticles', readStatus, categoryId],
queryFn: ({ pageParam = 0 }) =>
getCategoryBookmarkArticles(categoryId, readStatus, pageParam, 20),

queryFn: ({ pageParam = 0 }) => {
if (!categoryId) return null;
return getCategoryBookmarkArticles(categoryId, readStatus, pageParam, 20);
},

initialPageParam: 0,
getNextPageParam: (lastPage, allPages) => {
if (lastPage.articles.length === 0) {
return undefined;
}
if (!lastPage || lastPage.articles.length === 0) return undefined;
return allPages.length;
},
enabled: !!categoryId,
});
Comment on lines +32 to 45
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

useSuspenseInfiniteQuery에서 categoryId 부재 시 skipToken 사용

TypeScript 사용자는 enabled: false의 대안으로 skipToken을 사용할 수 있으며, 이는 조건에 따라 쿼리를 비활성화하면서도 타입 안전성을 유지하는 데 유용합니다. TanStack Query v5에서 useSuspenseInfiniteQueryskipToken을 queryFn으로 지원합니다.

현재 코드에서 categoryId가 없을 때 null을 반환하는 것보다 queryFn에 조건부 연산자를 사용하여 categoryId ? () => getCategoryBookmarkArticles(...) : skipToken 패턴을 적용하면, 쿼리가 실행되지 않아 더 안전합니다. 이를 통해 getNextPageParam에서 null 값 처리 가드를 제거할 수 있습니다.

+import { useSuspenseInfiniteQuery, skipToken } from '@tanstack/react-query';

 export const useGetCategoryBookmarkArticles = (
   categoryId: string | null,
   readStatus: boolean | null
 ) => {
   return useSuspenseInfiniteQuery({
     queryKey: ['categoryBookmarkArticles', readStatus, categoryId],
-    queryFn: ({ pageParam = 0 }) => {
-      if (!categoryId) return null;
-      return getCategoryBookmarkArticles(categoryId, readStatus, pageParam, 20);
-    },
+    queryFn: categoryId
+      ? ({ pageParam = 0 }) =>
+          getCategoryBookmarkArticles(categoryId, readStatus, pageParam, 20)
+      : skipToken,
     initialPageParam: 0,
     getNextPageParam: (lastPage, allPages) => {
-      if (!lastPage || lastPage.articles.length === 0) return undefined;
+      if (lastPage.articles.length === 0) return undefined;
       return allPages.length;
     },
   });
 };
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
return useSuspenseInfiniteQuery({
queryKey: ['categoryBookmarkArticles', readStatus, categoryId],
queryFn: ({ pageParam = 0 }) =>
getCategoryBookmarkArticles(categoryId, readStatus, pageParam, 20),
queryFn: ({ pageParam = 0 }) => {
if (!categoryId) return null;
return getCategoryBookmarkArticles(categoryId, readStatus, pageParam, 20);
},
initialPageParam: 0,
getNextPageParam: (lastPage, allPages) => {
if (lastPage.articles.length === 0) {
return undefined;
}
if (!lastPage || lastPage.articles.length === 0) return undefined;
return allPages.length;
},
enabled: !!categoryId,
});
return useSuspenseInfiniteQuery({
queryKey: ['categoryBookmarkArticles', readStatus, categoryId],
queryFn: categoryId
? ({ pageParam = 0 }) =>
getCategoryBookmarkArticles(categoryId, readStatus, pageParam, 20)
: skipToken,
initialPageParam: 0,
getNextPageParam: (lastPage, allPages) => {
if (lastPage.articles.length === 0) return undefined;
return allPages.length;
},
});

};
Loading
Loading