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/FeedSidebar.tsx | 75 +++++++++++++++++++++++++++++++++
 1 file changed, 75 insertions(+)
 create mode 100644 frontend/src/components/FeedSidebar.tsx

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

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 (
+		<nav className="w-56 shrink-0">
+			<h2 className="mb-3 text-xs font-semibold uppercase tracking-wide text-stone-400">
+				Feeds
+			</h2>
+			<ul className="space-y-0.5">
+				<li>
+					<button
+						type="button"
+						onClick={() => handleSelect(null)}
+						className={`w-full rounded-md px-3 py-1.5 text-left text-sm transition-colors ${
+							!selectedFeedId
+								? "bg-stone-200 font-medium text-stone-900"
+								: "text-stone-600 hover:bg-stone-100"
+						}`}
+					>
+						All feeds
+					</button>
+				</li>
+				{fetching && (
+					<li className="px-3 py-1.5 text-xs text-stone-400">Loading...</li>
+				)}
+				{data?.feeds.map((feed) => (
+					<li key={feed.id}>
+						<button
+							type="button"
+							onClick={() => handleSelect(feed.id)}
+							className={`flex w-full items-center justify-between rounded-md px-3 py-1.5 text-left text-sm transition-colors ${
+								selectedFeedId === feed.id
+									? "bg-stone-200 font-medium text-stone-900"
+									: "text-stone-600 hover:bg-stone-100"
+							}`}
+						>
+							<span className="min-w-0 truncate">{feed.title}</span>
+							{feed.unreadCount > 0 && (
+								<span className="ml-2 shrink-0 rounded-full bg-sky-100 px-1.5 py-0.5 text-xs font-medium text-sky-700">
+									{feed.unreadCount}
+								</span>
+							)}
+						</button>
+					</li>
+				))}
+			</ul>
+		</nav>
+	);
+}
-- 
cgit v1.3-1-g0d28

