From e216c3bc97994b4172d15d52b46d5f6b75f35ea4 Mon Sep 17 00:00:00 2001
From: nsfisis <nsfisis@gmail.com>
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=<id> query parameter for feed filtering

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
---
 frontend/src/components/ArticleList.tsx | 71 ++++++++++++++++++++++++++++++---
 1 file changed, 66 insertions(+), 5 deletions(-)

(limited to 'frontend/src/components/ArticleList.tsx')

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<Set<string>>(
 		new Set(),
 	);
@@ -36,6 +48,25 @@ export function ArticleList({ articles, isReadView }: Props) {
 		);
 	}
 
+	if (isSingleFeed) {
+		return (
+			<div className="space-y-1">
+				{visibleArticles.map((article) => (
+					<ArticleItem
+						key={article.id}
+						article={article}
+						onReadChange={handleArticleReadChange}
+					/>
+				))}
+				<LoadMoreButton
+					hasNextPage={hasNextPage}
+					loadingMore={loadingMore}
+					onLoadMore={onLoadMore}
+				/>
+			</div>
+		);
+	}
+
 	// Group articles by feed
 	const articlesByFeed = visibleArticles.reduce(
 		(acc, article) => {
@@ -77,6 +108,36 @@ export function ArticleList({ articles, isReadView }: Props) {
 					</div>
 				</div>
 			))}
+			<LoadMoreButton
+				hasNextPage={hasNextPage}
+				loadingMore={loadingMore}
+				onLoadMore={onLoadMore}
+			/>
+		</div>
+	);
+}
+
+function LoadMoreButton({
+	hasNextPage,
+	loadingMore,
+	onLoadMore,
+}: {
+	hasNextPage?: boolean;
+	loadingMore?: boolean;
+	onLoadMore?: () => void;
+}) {
+	if (!hasNextPage || !onLoadMore) return null;
+
+	return (
+		<div className="pt-4 text-center">
+			<button
+				type="button"
+				onClick={onLoadMore}
+				disabled={loadingMore}
+				className="rounded-lg bg-stone-100 px-4 py-2 text-sm font-medium text-stone-700 transition-colors hover:bg-stone-200 disabled:opacity-50"
+			>
+				{loadingMore ? "Loading..." : "Load more"}
+			</button>
 		</div>
 	);
 }
-- 
cgit v1.3-1-g0d28

