From e216c3bc97994b4172d15d52b46d5f6b75f35ea4 Mon Sep 17 00:00:00 2001 From: nsfisis Date: Fri, 13 Feb 2026 22:01:12 +0900 Subject: feat: add feed sidebar and cursor-based pagination Add a feed sidebar to /unread and /read pages for filtering articles by feed, and replace the fixed 100-article limit with cursor-based pagination using a "Load more" button. Backend: - Add PageInfo, ArticleConnection types and pagination args to GraphQL - Replace GetUnreadArticles/GetReadArticles with parameterized queries - Add GetFeedUnreadCounts query and composite index - Add shared pagination helper in resolver Frontend: - Add FeedSidebar component with unread count badges - Add usePaginatedArticles hook for cursor-based fetching - Update ArticleList with Load more button and single-feed mode - Use ?feed= query parameter for feed filtering Co-Authored-By: Claude Opus 4.6 --- frontend/src/components/ArticleItem.tsx | 7 ++- frontend/src/components/ArticleList.tsx | 71 ++++++++++++++++++++++++++++--- frontend/src/components/FeedSidebar.tsx | 75 +++++++++++++++++++++++++++++++++ frontend/src/components/index.ts | 1 + 4 files changed, 145 insertions(+), 9 deletions(-) create mode 100644 frontend/src/components/FeedSidebar.tsx (limited to 'frontend/src/components') diff --git a/frontend/src/components/ArticleItem.tsx b/frontend/src/components/ArticleItem.tsx index f8fac24..dbdaf44 100644 --- a/frontend/src/components/ArticleItem.tsx +++ b/frontend/src/components/ArticleItem.tsx @@ -10,10 +10,9 @@ import { MarkArticleUnreadDocument, } from "../graphql/generated/graphql"; -type Article = NonNullable< - | GetUnreadArticlesQuery["unreadArticles"] - | GetReadArticlesQuery["readArticles"] ->[0]; +type Article = + | GetUnreadArticlesQuery["unreadArticles"]["articles"][number] + | GetReadArticlesQuery["readArticles"]["articles"][number]; interface Props { article: Article; diff --git a/frontend/src/components/ArticleList.tsx b/frontend/src/components/ArticleList.tsx index afadb25..ccf7826 100644 --- a/frontend/src/components/ArticleList.tsx +++ b/frontend/src/components/ArticleList.tsx @@ -5,15 +5,27 @@ import type { } from "../graphql/generated/graphql"; import { ArticleItem } from "./ArticleItem"; +type ArticleType = + | GetUnreadArticlesQuery["unreadArticles"]["articles"] + | GetReadArticlesQuery["readArticles"]["articles"]; + interface Props { - articles: NonNullable< - | GetUnreadArticlesQuery["unreadArticles"] - | GetReadArticlesQuery["readArticles"] - >; + articles: ArticleType; isReadView?: boolean; + isSingleFeed?: boolean; + hasNextPage?: boolean; + loadingMore?: boolean; + onLoadMore?: () => void; } -export function ArticleList({ articles, isReadView }: Props) { +export function ArticleList({ + articles, + isReadView, + isSingleFeed, + hasNextPage, + loadingMore, + onLoadMore, +}: Props) { const [hiddenArticleIds, setHiddenArticleIds] = useState>( new Set(), ); @@ -36,6 +48,25 @@ export function ArticleList({ articles, isReadView }: Props) { ); } + if (isSingleFeed) { + return ( +
+ {visibleArticles.map((article) => ( + + ))} + +
+ ); + } + // Group articles by feed const articlesByFeed = visibleArticles.reduce( (acc, article) => { @@ -77,6 +108,36 @@ export function ArticleList({ articles, isReadView }: Props) { ))} + + + ); +} + +function LoadMoreButton({ + hasNextPage, + loadingMore, + onLoadMore, +}: { + hasNextPage?: boolean; + loadingMore?: boolean; + onLoadMore?: () => void; +}) { + if (!hasNextPage || !onLoadMore) return null; + + return ( +
+
); } diff --git a/frontend/src/components/FeedSidebar.tsx b/frontend/src/components/FeedSidebar.tsx new file mode 100644 index 0000000..73d9504 --- /dev/null +++ b/frontend/src/components/FeedSidebar.tsx @@ -0,0 +1,75 @@ +import { useQuery } from "urql"; +import { useLocation, useSearch } from "wouter"; +import { GetFeedsDocument } from "../graphql/generated/graphql"; + +interface Props { + basePath: string; +} + +const urqlContextFeed = { additionalTypenames: ["Feed", "Article"] }; + +export function FeedSidebar({ basePath }: Props) { + const search = useSearch(); + const [, setLocation] = useLocation(); + const params = new URLSearchParams(search); + const selectedFeedId = params.get("feed"); + + const [{ data, fetching }] = useQuery({ + query: GetFeedsDocument, + context: urqlContextFeed, + }); + + const handleSelect = (feedId: string | null) => { + if (feedId) { + setLocation(`${basePath}?feed=${feedId}`); + } else { + setLocation(basePath); + } + }; + + return ( + + ); +} diff --git a/frontend/src/components/index.ts b/frontend/src/components/index.ts index 06f4c29..c0797b4 100644 --- a/frontend/src/components/index.ts +++ b/frontend/src/components/index.ts @@ -1,6 +1,7 @@ export { AddFeedForm } from "./AddFeedForm"; export { ArticleList } from "./ArticleList"; export { FeedList } from "./FeedList"; +export { FeedSidebar } from "./FeedSidebar"; export { Layout } from "./Layout"; export { MenuItem } from "./MenuItem"; export { Navigation } from "./Navigation"; -- cgit v1.3-1-g0d28