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 + frontend/src/graphql/generated/gql.ts | 6 +- frontend/src/graphql/generated/graphql.ts | 66 ++++++++++++++---- frontend/src/graphql/queries.graphql | 57 +++++++++------- frontend/src/hooks/usePaginatedArticles.ts | 106 +++++++++++++++++++++++++++++ frontend/src/pages/ReadArticles.tsx | 73 ++++++++++---------- frontend/src/pages/UnreadArticles.tsx | 73 ++++++++++---------- 10 files changed, 417 insertions(+), 118 deletions(-) create mode 100644 frontend/src/components/FeedSidebar.tsx create mode 100644 frontend/src/hooks/usePaginatedArticles.ts (limited to 'frontend/src') 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"; diff --git a/frontend/src/graphql/generated/gql.ts b/frontend/src/graphql/generated/gql.ts index d844c60..40e292f 100644 --- a/frontend/src/graphql/generated/gql.ts +++ b/frontend/src/graphql/generated/gql.ts @@ -15,11 +15,11 @@ import type { TypedDocumentNode as DocumentNode } from '@graphql-typed-document- */ type Documents = { "mutation AddFeed($url: String!) {\n addFeed(url: $url) {\n id\n url\n title\n fetchedAt\n }\n}\n\nmutation UnsubscribeFeed($id: ID!) {\n unsubscribeFeed(id: $id)\n}\n\nmutation MarkArticleRead($id: ID!) {\n markArticleRead(id: $id) {\n id\n feedId\n guid\n title\n url\n isRead\n }\n}\n\nmutation MarkArticleUnread($id: ID!) {\n markArticleUnread(id: $id) {\n id\n feedId\n guid\n title\n url\n isRead\n }\n}\n\nmutation MarkFeedRead($id: ID!) {\n markFeedRead(id: $id) {\n id\n url\n title\n fetchedAt\n }\n}\n\nmutation MarkFeedUnread($id: ID!) {\n markFeedUnread(id: $id) {\n id\n url\n title\n fetchedAt\n }\n}\n\nmutation Login($username: String!, $password: String!) {\n login(username: $username, password: $password) {\n user {\n id\n username\n }\n }\n}\n\nmutation Logout {\n logout\n}": typeof types.AddFeedDocument, - "query GetFeeds {\n feeds {\n id\n url\n title\n fetchedAt\n isSubscribed\n articles {\n id\n isRead\n }\n }\n}\n\nquery GetUnreadArticles {\n unreadArticles {\n id\n feedId\n guid\n title\n url\n isRead\n feed {\n id\n title\n isSubscribed\n }\n }\n}\n\nquery GetReadArticles {\n readArticles {\n id\n feedId\n guid\n title\n url\n isRead\n feed {\n id\n title\n isSubscribed\n }\n }\n}\n\nquery GetFeed($id: ID!) {\n feed(id: $id) {\n id\n url\n title\n fetchedAt\n isSubscribed\n articles {\n id\n guid\n title\n url\n isRead\n }\n }\n}\n\nquery GetArticle($id: ID!) {\n article(id: $id) {\n id\n feedId\n guid\n title\n url\n isRead\n feed {\n id\n title\n isSubscribed\n }\n }\n}\n\nquery GetCurrentUser {\n currentUser {\n id\n username\n }\n}": typeof types.GetFeedsDocument, + "query GetFeeds {\n feeds {\n id\n url\n title\n fetchedAt\n isSubscribed\n unreadCount\n }\n}\n\nquery GetUnreadArticles($feedId: ID, $after: ID, $first: Int) {\n unreadArticles(feedId: $feedId, after: $after, first: $first) {\n articles {\n id\n feedId\n guid\n title\n url\n isRead\n feed {\n id\n title\n isSubscribed\n }\n }\n pageInfo {\n hasNextPage\n endCursor\n }\n }\n}\n\nquery GetReadArticles($feedId: ID, $after: ID, $first: Int) {\n readArticles(feedId: $feedId, after: $after, first: $first) {\n articles {\n id\n feedId\n guid\n title\n url\n isRead\n feed {\n id\n title\n isSubscribed\n }\n }\n pageInfo {\n hasNextPage\n endCursor\n }\n }\n}\n\nquery GetFeed($id: ID!) {\n feed(id: $id) {\n id\n url\n title\n fetchedAt\n isSubscribed\n articles {\n id\n guid\n title\n url\n isRead\n }\n }\n}\n\nquery GetArticle($id: ID!) {\n article(id: $id) {\n id\n feedId\n guid\n title\n url\n isRead\n feed {\n id\n title\n isSubscribed\n }\n }\n}\n\nquery GetCurrentUser {\n currentUser {\n id\n username\n }\n}": typeof types.GetFeedsDocument, }; const documents: Documents = { "mutation AddFeed($url: String!) {\n addFeed(url: $url) {\n id\n url\n title\n fetchedAt\n }\n}\n\nmutation UnsubscribeFeed($id: ID!) {\n unsubscribeFeed(id: $id)\n}\n\nmutation MarkArticleRead($id: ID!) {\n markArticleRead(id: $id) {\n id\n feedId\n guid\n title\n url\n isRead\n }\n}\n\nmutation MarkArticleUnread($id: ID!) {\n markArticleUnread(id: $id) {\n id\n feedId\n guid\n title\n url\n isRead\n }\n}\n\nmutation MarkFeedRead($id: ID!) {\n markFeedRead(id: $id) {\n id\n url\n title\n fetchedAt\n }\n}\n\nmutation MarkFeedUnread($id: ID!) {\n markFeedUnread(id: $id) {\n id\n url\n title\n fetchedAt\n }\n}\n\nmutation Login($username: String!, $password: String!) {\n login(username: $username, password: $password) {\n user {\n id\n username\n }\n }\n}\n\nmutation Logout {\n logout\n}": types.AddFeedDocument, - "query GetFeeds {\n feeds {\n id\n url\n title\n fetchedAt\n isSubscribed\n articles {\n id\n isRead\n }\n }\n}\n\nquery GetUnreadArticles {\n unreadArticles {\n id\n feedId\n guid\n title\n url\n isRead\n feed {\n id\n title\n isSubscribed\n }\n }\n}\n\nquery GetReadArticles {\n readArticles {\n id\n feedId\n guid\n title\n url\n isRead\n feed {\n id\n title\n isSubscribed\n }\n }\n}\n\nquery GetFeed($id: ID!) {\n feed(id: $id) {\n id\n url\n title\n fetchedAt\n isSubscribed\n articles {\n id\n guid\n title\n url\n isRead\n }\n }\n}\n\nquery GetArticle($id: ID!) {\n article(id: $id) {\n id\n feedId\n guid\n title\n url\n isRead\n feed {\n id\n title\n isSubscribed\n }\n }\n}\n\nquery GetCurrentUser {\n currentUser {\n id\n username\n }\n}": types.GetFeedsDocument, + "query GetFeeds {\n feeds {\n id\n url\n title\n fetchedAt\n isSubscribed\n unreadCount\n }\n}\n\nquery GetUnreadArticles($feedId: ID, $after: ID, $first: Int) {\n unreadArticles(feedId: $feedId, after: $after, first: $first) {\n articles {\n id\n feedId\n guid\n title\n url\n isRead\n feed {\n id\n title\n isSubscribed\n }\n }\n pageInfo {\n hasNextPage\n endCursor\n }\n }\n}\n\nquery GetReadArticles($feedId: ID, $after: ID, $first: Int) {\n readArticles(feedId: $feedId, after: $after, first: $first) {\n articles {\n id\n feedId\n guid\n title\n url\n isRead\n feed {\n id\n title\n isSubscribed\n }\n }\n pageInfo {\n hasNextPage\n endCursor\n }\n }\n}\n\nquery GetFeed($id: ID!) {\n feed(id: $id) {\n id\n url\n title\n fetchedAt\n isSubscribed\n articles {\n id\n guid\n title\n url\n isRead\n }\n }\n}\n\nquery GetArticle($id: ID!) {\n article(id: $id) {\n id\n feedId\n guid\n title\n url\n isRead\n feed {\n id\n title\n isSubscribed\n }\n }\n}\n\nquery GetCurrentUser {\n currentUser {\n id\n username\n }\n}": types.GetFeedsDocument, }; /** @@ -43,7 +43,7 @@ export function graphql(source: "mutation AddFeed($url: String!) {\n addFeed(ur /** * The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients. */ -export function graphql(source: "query GetFeeds {\n feeds {\n id\n url\n title\n fetchedAt\n isSubscribed\n articles {\n id\n isRead\n }\n }\n}\n\nquery GetUnreadArticles {\n unreadArticles {\n id\n feedId\n guid\n title\n url\n isRead\n feed {\n id\n title\n isSubscribed\n }\n }\n}\n\nquery GetReadArticles {\n readArticles {\n id\n feedId\n guid\n title\n url\n isRead\n feed {\n id\n title\n isSubscribed\n }\n }\n}\n\nquery GetFeed($id: ID!) {\n feed(id: $id) {\n id\n url\n title\n fetchedAt\n isSubscribed\n articles {\n id\n guid\n title\n url\n isRead\n }\n }\n}\n\nquery GetArticle($id: ID!) {\n article(id: $id) {\n id\n feedId\n guid\n title\n url\n isRead\n feed {\n id\n title\n isSubscribed\n }\n }\n}\n\nquery GetCurrentUser {\n currentUser {\n id\n username\n }\n}"): (typeof documents)["query GetFeeds {\n feeds {\n id\n url\n title\n fetchedAt\n isSubscribed\n articles {\n id\n isRead\n }\n }\n}\n\nquery GetUnreadArticles {\n unreadArticles {\n id\n feedId\n guid\n title\n url\n isRead\n feed {\n id\n title\n isSubscribed\n }\n }\n}\n\nquery GetReadArticles {\n readArticles {\n id\n feedId\n guid\n title\n url\n isRead\n feed {\n id\n title\n isSubscribed\n }\n }\n}\n\nquery GetFeed($id: ID!) {\n feed(id: $id) {\n id\n url\n title\n fetchedAt\n isSubscribed\n articles {\n id\n guid\n title\n url\n isRead\n }\n }\n}\n\nquery GetArticle($id: ID!) {\n article(id: $id) {\n id\n feedId\n guid\n title\n url\n isRead\n feed {\n id\n title\n isSubscribed\n }\n }\n}\n\nquery GetCurrentUser {\n currentUser {\n id\n username\n }\n}"]; +export function graphql(source: "query GetFeeds {\n feeds {\n id\n url\n title\n fetchedAt\n isSubscribed\n unreadCount\n }\n}\n\nquery GetUnreadArticles($feedId: ID, $after: ID, $first: Int) {\n unreadArticles(feedId: $feedId, after: $after, first: $first) {\n articles {\n id\n feedId\n guid\n title\n url\n isRead\n feed {\n id\n title\n isSubscribed\n }\n }\n pageInfo {\n hasNextPage\n endCursor\n }\n }\n}\n\nquery GetReadArticles($feedId: ID, $after: ID, $first: Int) {\n readArticles(feedId: $feedId, after: $after, first: $first) {\n articles {\n id\n feedId\n guid\n title\n url\n isRead\n feed {\n id\n title\n isSubscribed\n }\n }\n pageInfo {\n hasNextPage\n endCursor\n }\n }\n}\n\nquery GetFeed($id: ID!) {\n feed(id: $id) {\n id\n url\n title\n fetchedAt\n isSubscribed\n articles {\n id\n guid\n title\n url\n isRead\n }\n }\n}\n\nquery GetArticle($id: ID!) {\n article(id: $id) {\n id\n feedId\n guid\n title\n url\n isRead\n feed {\n id\n title\n isSubscribed\n }\n }\n}\n\nquery GetCurrentUser {\n currentUser {\n id\n username\n }\n}"): (typeof documents)["query GetFeeds {\n feeds {\n id\n url\n title\n fetchedAt\n isSubscribed\n unreadCount\n }\n}\n\nquery GetUnreadArticles($feedId: ID, $after: ID, $first: Int) {\n unreadArticles(feedId: $feedId, after: $after, first: $first) {\n articles {\n id\n feedId\n guid\n title\n url\n isRead\n feed {\n id\n title\n isSubscribed\n }\n }\n pageInfo {\n hasNextPage\n endCursor\n }\n }\n}\n\nquery GetReadArticles($feedId: ID, $after: ID, $first: Int) {\n readArticles(feedId: $feedId, after: $after, first: $first) {\n articles {\n id\n feedId\n guid\n title\n url\n isRead\n feed {\n id\n title\n isSubscribed\n }\n }\n pageInfo {\n hasNextPage\n endCursor\n }\n }\n}\n\nquery GetFeed($id: ID!) {\n feed(id: $id) {\n id\n url\n title\n fetchedAt\n isSubscribed\n articles {\n id\n guid\n title\n url\n isRead\n }\n }\n}\n\nquery GetArticle($id: ID!) {\n article(id: $id) {\n id\n feedId\n guid\n title\n url\n isRead\n feed {\n id\n title\n isSubscribed\n }\n }\n}\n\nquery GetCurrentUser {\n currentUser {\n id\n username\n }\n}"]; export function graphql(source: string) { return (documents as any)[source] ?? {}; diff --git a/frontend/src/graphql/generated/graphql.ts b/frontend/src/graphql/generated/graphql.ts index 8ef7034..9d7c168 100644 --- a/frontend/src/graphql/generated/graphql.ts +++ b/frontend/src/graphql/generated/graphql.ts @@ -35,6 +35,14 @@ export type Article = { url: Scalars['String']['output']; }; +/** A paginated list of articles */ +export type ArticleConnection = { + /** The list of articles */ + articles: Array
; + /** Pagination information */ + pageInfo: PageInfo; +}; + /** Authentication payload returned from login mutation */ export type AuthPayload = { /** The authenticated user */ @@ -53,6 +61,8 @@ export type Feed = { isSubscribed: Scalars['Boolean']['output']; /** Title of the feed (extracted from feed metadata) */ title: Scalars['String']['output']; + /** Number of unread articles in this feed */ + unreadCount: Scalars['Int']['output']; /** URL of the RSS/Atom feed */ url: Scalars['String']['output']; }; @@ -120,6 +130,14 @@ export type MutationUnsubscribeFeedArgs = { id: Scalars['ID']['input']; }; +/** Pagination information for cursor-based pagination */ +export type PageInfo = { + /** Cursor of the last item in this page */ + endCursor?: Maybe; + /** Whether there are more items after the last item in this page */ + hasNextPage: Scalars['Boolean']['output']; +}; + /** Root query type for reading data */ export type Query = { /** Get a specific article by ID */ @@ -130,10 +148,10 @@ export type Query = { feed?: Maybe; /** Get all feeds with their metadata */ feeds: Array; - /** Get all read articles across all feeds */ - readArticles: Array
; - /** Get all unread articles across all feeds */ - unreadArticles: Array
; + /** Get read articles with optional feed filter and cursor-based pagination */ + readArticles: ArticleConnection; + /** Get unread articles with optional feed filter and cursor-based pagination */ + unreadArticles: ArticleConnection; }; @@ -148,6 +166,22 @@ export type QueryFeedArgs = { id: Scalars['ID']['input']; }; + +/** Root query type for reading data */ +export type QueryReadArticlesArgs = { + after?: InputMaybe; + feedId?: InputMaybe; + first?: InputMaybe; +}; + + +/** Root query type for reading data */ +export type QueryUnreadArticlesArgs = { + after?: InputMaybe; + feedId?: InputMaybe; + first?: InputMaybe; +}; + /** Represents a user in the system */ export type User = { /** Unique identifier for the user */ @@ -214,17 +248,25 @@ export type LogoutMutation = { logout: boolean }; export type GetFeedsQueryVariables = Exact<{ [key: string]: never; }>; -export type GetFeedsQuery = { feeds: Array<{ id: string, url: string, title: string, fetchedAt: string, isSubscribed: boolean, articles: Array<{ id: string, isRead: boolean }> }> }; +export type GetFeedsQuery = { feeds: Array<{ id: string, url: string, title: string, fetchedAt: string, isSubscribed: boolean, unreadCount: number }> }; -export type GetUnreadArticlesQueryVariables = Exact<{ [key: string]: never; }>; +export type GetUnreadArticlesQueryVariables = Exact<{ + feedId?: InputMaybe; + after?: InputMaybe; + first?: InputMaybe; +}>; -export type GetUnreadArticlesQuery = { unreadArticles: Array<{ id: string, feedId: string, guid: string, title: string, url: string, isRead: boolean, feed: { id: string, title: string, isSubscribed: boolean } }> }; +export type GetUnreadArticlesQuery = { unreadArticles: { articles: Array<{ id: string, feedId: string, guid: string, title: string, url: string, isRead: boolean, feed: { id: string, title: string, isSubscribed: boolean } }>, pageInfo: { hasNextPage: boolean, endCursor?: string | null } } }; -export type GetReadArticlesQueryVariables = Exact<{ [key: string]: never; }>; +export type GetReadArticlesQueryVariables = Exact<{ + feedId?: InputMaybe; + after?: InputMaybe; + first?: InputMaybe; +}>; -export type GetReadArticlesQuery = { readArticles: Array<{ id: string, feedId: string, guid: string, title: string, url: string, isRead: boolean, feed: { id: string, title: string, isSubscribed: boolean } }> }; +export type GetReadArticlesQuery = { readArticles: { articles: Array<{ id: string, feedId: string, guid: string, title: string, url: string, isRead: boolean, feed: { id: string, title: string, isSubscribed: boolean } }>, pageInfo: { hasNextPage: boolean, endCursor?: string | null } } }; export type GetFeedQueryVariables = Exact<{ id: Scalars['ID']['input']; @@ -254,9 +296,9 @@ export const MarkFeedReadDocument = {"kind":"Document","definitions":[{"kind":"O export const MarkFeedUnreadDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"MarkFeedUnread"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"id"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"ID"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"markFeedUnread"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"id"},"value":{"kind":"Variable","name":{"kind":"Name","value":"id"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"url"}},{"kind":"Field","name":{"kind":"Name","value":"title"}},{"kind":"Field","name":{"kind":"Name","value":"fetchedAt"}}]}}]}}]} as unknown as DocumentNode; export const LoginDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"Login"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"username"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"password"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"login"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"username"},"value":{"kind":"Variable","name":{"kind":"Name","value":"username"}}},{"kind":"Argument","name":{"kind":"Name","value":"password"},"value":{"kind":"Variable","name":{"kind":"Name","value":"password"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"user"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"username"}}]}}]}}]}}]} as unknown as DocumentNode; export const LogoutDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"Logout"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"logout"}}]}}]} as unknown as DocumentNode; -export const GetFeedsDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"GetFeeds"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"feeds"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"url"}},{"kind":"Field","name":{"kind":"Name","value":"title"}},{"kind":"Field","name":{"kind":"Name","value":"fetchedAt"}},{"kind":"Field","name":{"kind":"Name","value":"isSubscribed"}},{"kind":"Field","name":{"kind":"Name","value":"articles"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"isRead"}}]}}]}}]}}]} as unknown as DocumentNode; -export const GetUnreadArticlesDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"GetUnreadArticles"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"unreadArticles"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"feedId"}},{"kind":"Field","name":{"kind":"Name","value":"guid"}},{"kind":"Field","name":{"kind":"Name","value":"title"}},{"kind":"Field","name":{"kind":"Name","value":"url"}},{"kind":"Field","name":{"kind":"Name","value":"isRead"}},{"kind":"Field","name":{"kind":"Name","value":"feed"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"title"}},{"kind":"Field","name":{"kind":"Name","value":"isSubscribed"}}]}}]}}]}}]} as unknown as DocumentNode; -export const GetReadArticlesDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"GetReadArticles"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"readArticles"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"feedId"}},{"kind":"Field","name":{"kind":"Name","value":"guid"}},{"kind":"Field","name":{"kind":"Name","value":"title"}},{"kind":"Field","name":{"kind":"Name","value":"url"}},{"kind":"Field","name":{"kind":"Name","value":"isRead"}},{"kind":"Field","name":{"kind":"Name","value":"feed"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"title"}},{"kind":"Field","name":{"kind":"Name","value":"isSubscribed"}}]}}]}}]}}]} as unknown as DocumentNode; +export const GetFeedsDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"GetFeeds"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"feeds"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"url"}},{"kind":"Field","name":{"kind":"Name","value":"title"}},{"kind":"Field","name":{"kind":"Name","value":"fetchedAt"}},{"kind":"Field","name":{"kind":"Name","value":"isSubscribed"}},{"kind":"Field","name":{"kind":"Name","value":"unreadCount"}}]}}]}}]} as unknown as DocumentNode; +export const GetUnreadArticlesDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"GetUnreadArticles"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"feedId"}},"type":{"kind":"NamedType","name":{"kind":"Name","value":"ID"}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"after"}},"type":{"kind":"NamedType","name":{"kind":"Name","value":"ID"}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"first"}},"type":{"kind":"NamedType","name":{"kind":"Name","value":"Int"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"unreadArticles"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"feedId"},"value":{"kind":"Variable","name":{"kind":"Name","value":"feedId"}}},{"kind":"Argument","name":{"kind":"Name","value":"after"},"value":{"kind":"Variable","name":{"kind":"Name","value":"after"}}},{"kind":"Argument","name":{"kind":"Name","value":"first"},"value":{"kind":"Variable","name":{"kind":"Name","value":"first"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"articles"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"feedId"}},{"kind":"Field","name":{"kind":"Name","value":"guid"}},{"kind":"Field","name":{"kind":"Name","value":"title"}},{"kind":"Field","name":{"kind":"Name","value":"url"}},{"kind":"Field","name":{"kind":"Name","value":"isRead"}},{"kind":"Field","name":{"kind":"Name","value":"feed"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"title"}},{"kind":"Field","name":{"kind":"Name","value":"isSubscribed"}}]}}]}},{"kind":"Field","name":{"kind":"Name","value":"pageInfo"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"hasNextPage"}},{"kind":"Field","name":{"kind":"Name","value":"endCursor"}}]}}]}}]}}]} as unknown as DocumentNode; +export const GetReadArticlesDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"GetReadArticles"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"feedId"}},"type":{"kind":"NamedType","name":{"kind":"Name","value":"ID"}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"after"}},"type":{"kind":"NamedType","name":{"kind":"Name","value":"ID"}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"first"}},"type":{"kind":"NamedType","name":{"kind":"Name","value":"Int"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"readArticles"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"feedId"},"value":{"kind":"Variable","name":{"kind":"Name","value":"feedId"}}},{"kind":"Argument","name":{"kind":"Name","value":"after"},"value":{"kind":"Variable","name":{"kind":"Name","value":"after"}}},{"kind":"Argument","name":{"kind":"Name","value":"first"},"value":{"kind":"Variable","name":{"kind":"Name","value":"first"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"articles"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"feedId"}},{"kind":"Field","name":{"kind":"Name","value":"guid"}},{"kind":"Field","name":{"kind":"Name","value":"title"}},{"kind":"Field","name":{"kind":"Name","value":"url"}},{"kind":"Field","name":{"kind":"Name","value":"isRead"}},{"kind":"Field","name":{"kind":"Name","value":"feed"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"title"}},{"kind":"Field","name":{"kind":"Name","value":"isSubscribed"}}]}}]}},{"kind":"Field","name":{"kind":"Name","value":"pageInfo"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"hasNextPage"}},{"kind":"Field","name":{"kind":"Name","value":"endCursor"}}]}}]}}]}}]} as unknown as DocumentNode; export const GetFeedDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"GetFeed"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"id"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"ID"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"feed"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"id"},"value":{"kind":"Variable","name":{"kind":"Name","value":"id"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"url"}},{"kind":"Field","name":{"kind":"Name","value":"title"}},{"kind":"Field","name":{"kind":"Name","value":"fetchedAt"}},{"kind":"Field","name":{"kind":"Name","value":"isSubscribed"}},{"kind":"Field","name":{"kind":"Name","value":"articles"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"guid"}},{"kind":"Field","name":{"kind":"Name","value":"title"}},{"kind":"Field","name":{"kind":"Name","value":"url"}},{"kind":"Field","name":{"kind":"Name","value":"isRead"}}]}}]}}]}}]} as unknown as DocumentNode; export const GetArticleDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"GetArticle"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"id"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"ID"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"article"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"id"},"value":{"kind":"Variable","name":{"kind":"Name","value":"id"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"feedId"}},{"kind":"Field","name":{"kind":"Name","value":"guid"}},{"kind":"Field","name":{"kind":"Name","value":"title"}},{"kind":"Field","name":{"kind":"Name","value":"url"}},{"kind":"Field","name":{"kind":"Name","value":"isRead"}},{"kind":"Field","name":{"kind":"Name","value":"feed"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"title"}},{"kind":"Field","name":{"kind":"Name","value":"isSubscribed"}}]}}]}}]}}]} as unknown as DocumentNode; export const GetCurrentUserDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"GetCurrentUser"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"currentUser"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"username"}}]}}]}}]} as unknown as DocumentNode; \ No newline at end of file diff --git a/frontend/src/graphql/queries.graphql b/frontend/src/graphql/queries.graphql index d08140b..0b0e25c 100644 --- a/frontend/src/graphql/queries.graphql +++ b/frontend/src/graphql/queries.graphql @@ -5,41 +5,50 @@ query GetFeeds { title fetchedAt isSubscribed - articles { - id - isRead - } + unreadCount } } -query GetUnreadArticles { - unreadArticles { - id - feedId - guid - title - url - isRead - feed { +query GetUnreadArticles($feedId: ID, $after: ID, $first: Int) { + unreadArticles(feedId: $feedId, after: $after, first: $first) { + articles { id + feedId + guid title - isSubscribed + url + isRead + feed { + id + title + isSubscribed + } + } + pageInfo { + hasNextPage + endCursor } } } -query GetReadArticles { - readArticles { - id - feedId - guid - title - url - isRead - feed { +query GetReadArticles($feedId: ID, $after: ID, $first: Int) { + readArticles(feedId: $feedId, after: $after, first: $first) { + articles { id + feedId + guid title - isSubscribed + url + isRead + feed { + id + title + isSubscribed + } + } + pageInfo { + hasNextPage + endCursor } } } diff --git a/frontend/src/hooks/usePaginatedArticles.ts b/frontend/src/hooks/usePaginatedArticles.ts new file mode 100644 index 0000000..56098d7 --- /dev/null +++ b/frontend/src/hooks/usePaginatedArticles.ts @@ -0,0 +1,106 @@ +import { useCallback, useEffect, useState } from "react"; +import { useClient } from "urql"; +import type { + GetReadArticlesQuery, + GetUnreadArticlesQuery, +} from "../graphql/generated/graphql"; +import { + GetReadArticlesDocument, + GetUnreadArticlesDocument, +} from "../graphql/generated/graphql"; + +type ArticleType = + | GetUnreadArticlesQuery["unreadArticles"]["articles"][number] + | GetReadArticlesQuery["readArticles"]["articles"][number]; + +interface UsePaginatedArticlesOptions { + isReadView: boolean; + feedId: string | null; +} + +interface UsePaginatedArticlesResult { + articles: ArticleType[]; + hasNextPage: boolean; + loading: boolean; + loadingMore: boolean; + loadMore: () => void; + error: Error | null; +} + +export function usePaginatedArticles({ + isReadView, + feedId, +}: UsePaginatedArticlesOptions): UsePaginatedArticlesResult { + const client = useClient(); + const [articles, setArticles] = useState([]); + const [hasNextPage, setHasNextPage] = useState(false); + const [endCursor, setEndCursor] = useState(null); + const [loading, setLoading] = useState(true); + const [loadingMore, setLoadingMore] = useState(false); + const [error, setError] = useState(null); + + const fetchArticles = useCallback( + async (after: string | null, append: boolean) => { + const variables: Record = {}; + if (feedId) variables.feedId = feedId; + if (after) variables.after = after; + + let connection: { + articles: ArticleType[]; + pageInfo: { hasNextPage: boolean; endCursor?: string | null }; + } | null = null; + + if (isReadView) { + const result = await client + .query(GetReadArticlesDocument, variables, { + additionalTypenames: ["Article"], + }) + .toPromise(); + if (result.error) { + setError(new Error(result.error.message)); + return; + } + connection = result.data?.readArticles ?? null; + } else { + const result = await client + .query(GetUnreadArticlesDocument, variables, { + additionalTypenames: ["Article"], + }) + .toPromise(); + if (result.error) { + setError(new Error(result.error.message)); + return; + } + connection = result.data?.unreadArticles ?? null; + } + + if (connection) { + setArticles((prev) => + append ? [...prev, ...connection.articles] : connection.articles, + ); + setHasNextPage(connection.pageInfo.hasNextPage); + setEndCursor(connection.pageInfo.endCursor ?? null); + setError(null); + } + }, + [client, isReadView, feedId], + ); + + // Reset and fetch on feedId or view change + useEffect(() => { + setArticles([]); + setEndCursor(null); + setHasNextPage(false); + setLoading(true); + setError(null); + fetchArticles(null, false).finally(() => setLoading(false)); + }, [fetchArticles]); + + const loadMore = useCallback(() => { + if (!hasNextPage || loadingMore) return; + setLoadingMore(true); + fetchArticles(endCursor, true).finally(() => setLoadingMore(false)); + }, [fetchArticles, endCursor, hasNextPage, loadingMore]); + + return { articles, hasNextPage, loading, loadingMore, loadMore, error }; +} diff --git a/frontend/src/pages/ReadArticles.tsx b/frontend/src/pages/ReadArticles.tsx index f90c3c9..2538446 100644 --- a/frontend/src/pages/ReadArticles.tsx +++ b/frontend/src/pages/ReadArticles.tsx @@ -1,45 +1,48 @@ -import { useQuery } from "urql"; -import { ArticleList } from "../components"; -import { GetReadArticlesDocument } from "../graphql/generated/graphql"; - -const urqlContextArticle = { additionalTypenames: ["Article"] }; +import { useSearch } from "wouter"; +import { ArticleList, FeedSidebar } from "../components"; +import { usePaginatedArticles } from "../hooks/usePaginatedArticles"; export function ReadArticles() { - const [{ data, fetching, error }] = useQuery({ - query: GetReadArticlesDocument, - context: urqlContextArticle, - }); - - if (fetching) { - return ( -
-

Loading read articles...

-
- ); - } + const search = useSearch(); + const params = new URLSearchParams(search); + const feedId = params.get("feed"); - if (error) { - return ( -
- Error: {error.message} -
- ); - } + const { articles, hasNextPage, loading, loadingMore, loadMore, error } = + usePaginatedArticles({ isReadView: true, feedId }); return ( -
-
-

Read

- {data?.readArticles && ( -

- {data.readArticles.length} article - {data.readArticles.length !== 1 ? "s" : ""} -

+
+ +
+
+

Read

+ {!loading && articles.length > 0 && ( +

+ {articles.length} + {hasNextPage ? "+" : ""} article + {articles.length !== 1 ? "s" : ""} +

+ )} +
+ {loading ? ( +
+

Loading read articles...

+
+ ) : error ? ( +
+ Error: {error.message} +
+ ) : ( + )}
- {data?.readArticles && ( - - )}
); } diff --git a/frontend/src/pages/UnreadArticles.tsx b/frontend/src/pages/UnreadArticles.tsx index 28cc8b5..eade6fc 100644 --- a/frontend/src/pages/UnreadArticles.tsx +++ b/frontend/src/pages/UnreadArticles.tsx @@ -1,45 +1,48 @@ -import { useQuery } from "urql"; -import { ArticleList } from "../components"; -import { GetUnreadArticlesDocument } from "../graphql/generated/graphql"; - -const urqlContextArticle = { additionalTypenames: ["Article"] }; +import { useSearch } from "wouter"; +import { ArticleList, FeedSidebar } from "../components"; +import { usePaginatedArticles } from "../hooks/usePaginatedArticles"; export function UnreadArticles() { - const [{ data, fetching, error }] = useQuery({ - query: GetUnreadArticlesDocument, - context: urqlContextArticle, - }); - - if (fetching) { - return ( -
-

Loading unread articles...

-
- ); - } + const search = useSearch(); + const params = new URLSearchParams(search); + const feedId = params.get("feed"); - if (error) { - return ( -
- Error: {error.message} -
- ); - } + const { articles, hasNextPage, loading, loadingMore, loadMore, error } = + usePaginatedArticles({ isReadView: false, feedId }); return ( -
-
-

Unread

- {data?.unreadArticles && ( -

- {data.unreadArticles.length} article - {data.unreadArticles.length !== 1 ? "s" : ""} to read -

+
+ +
+
+

Unread

+ {!loading && articles.length > 0 && ( +

+ {articles.length} + {hasNextPage ? "+" : ""} article + {articles.length !== 1 ? "s" : ""} to read +

+ )} +
+ {loading ? ( +
+

Loading unread articles...

+
+ ) : error ? ( +
+ Error: {error.message} +
+ ) : ( + )}
- {data?.unreadArticles && ( - - )}
); } -- cgit v1.3-1-g0d28