diff options
Diffstat (limited to 'frontend')
| -rw-r--r-- | frontend/src/components/ArticleItem.tsx | 7 | ||||
| -rw-r--r-- | frontend/src/components/ArticleList.tsx | 71 | ||||
| -rw-r--r-- | frontend/src/components/FeedSidebar.tsx | 75 | ||||
| -rw-r--r-- | frontend/src/components/index.ts | 1 | ||||
| -rw-r--r-- | frontend/src/graphql/generated/gql.ts | 6 | ||||
| -rw-r--r-- | frontend/src/graphql/generated/graphql.ts | 66 | ||||
| -rw-r--r-- | frontend/src/graphql/queries.graphql | 57 | ||||
| -rw-r--r-- | frontend/src/hooks/usePaginatedArticles.ts | 106 | ||||
| -rw-r--r-- | frontend/src/pages/ReadArticles.tsx | 73 | ||||
| -rw-r--r-- | frontend/src/pages/UnreadArticles.tsx | 73 |
10 files changed, 417 insertions, 118 deletions
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<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> ); } 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> + ); +} 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<Article>; + /** 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<Scalars['ID']['output']>; + /** 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<Feed>; /** Get all feeds with their metadata */ feeds: Array<Feed>; - /** Get all read articles across all feeds */ - readArticles: Array<Article>; - /** Get all unread articles across all feeds */ - unreadArticles: Array<Article>; + /** 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<Scalars['ID']['input']>; + feedId?: InputMaybe<Scalars['ID']['input']>; + first?: InputMaybe<Scalars['Int']['input']>; +}; + + +/** Root query type for reading data */ +export type QueryUnreadArticlesArgs = { + after?: InputMaybe<Scalars['ID']['input']>; + feedId?: InputMaybe<Scalars['ID']['input']>; + first?: InputMaybe<Scalars['Int']['input']>; +}; + /** 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<Scalars['ID']['input']>; + after?: InputMaybe<Scalars['ID']['input']>; + first?: InputMaybe<Scalars['Int']['input']>; +}>; -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<Scalars['ID']['input']>; + after?: InputMaybe<Scalars['ID']['input']>; + first?: InputMaybe<Scalars['Int']['input']>; +}>; -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<MarkFeedUnreadMutation, MarkFeedUnreadMutationVariables>; 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<LoginMutation, LoginMutationVariables>; 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<LogoutMutation, LogoutMutationVariables>; -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<GetFeedsQuery, GetFeedsQueryVariables>; -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<GetUnreadArticlesQuery, GetUnreadArticlesQueryVariables>; -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<GetReadArticlesQuery, GetReadArticlesQueryVariables>; +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<GetFeedsQuery, GetFeedsQueryVariables>; +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<GetUnreadArticlesQuery, GetUnreadArticlesQueryVariables>; +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<GetReadArticlesQuery, GetReadArticlesQueryVariables>; 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<GetFeedQuery, GetFeedQueryVariables>; 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<GetArticleQuery, GetArticleQueryVariables>; 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<GetCurrentUserQuery, GetCurrentUserQueryVariables>;
\ 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<ArticleType[]>([]); + const [hasNextPage, setHasNextPage] = useState(false); + const [endCursor, setEndCursor] = useState<string | null>(null); + const [loading, setLoading] = useState(true); + const [loadingMore, setLoadingMore] = useState(false); + const [error, setError] = useState<Error | null>(null); + + const fetchArticles = useCallback( + async (after: string | null, append: boolean) => { + const variables: Record<string, unknown> = {}; + 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 ( - <div className="py-8 text-center"> - <p className="text-sm text-stone-400">Loading read articles...</p> - </div> - ); - } + const search = useSearch(); + const params = new URLSearchParams(search); + const feedId = params.get("feed"); - if (error) { - return ( - <div className="rounded-lg bg-red-50 p-4 text-sm text-red-600"> - Error: {error.message} - </div> - ); - } + const { articles, hasNextPage, loading, loadingMore, loadMore, error } = + usePaginatedArticles({ isReadView: true, feedId }); return ( - <div> - <div className="mb-6"> - <h1 className="text-xl font-semibold text-stone-900">Read</h1> - {data?.readArticles && ( - <p className="mt-1 text-sm text-stone-400"> - {data.readArticles.length} article - {data.readArticles.length !== 1 ? "s" : ""} - </p> + <div className="flex gap-8"> + <FeedSidebar basePath="/read" /> + <div className="min-w-0 flex-1"> + <div className="mb-6"> + <h1 className="text-xl font-semibold text-stone-900">Read</h1> + {!loading && articles.length > 0 && ( + <p className="mt-1 text-sm text-stone-400"> + {articles.length} + {hasNextPage ? "+" : ""} article + {articles.length !== 1 ? "s" : ""} + </p> + )} + </div> + {loading ? ( + <div className="py-8 text-center"> + <p className="text-sm text-stone-400">Loading read articles...</p> + </div> + ) : error ? ( + <div className="rounded-lg bg-red-50 p-4 text-sm text-red-600"> + Error: {error.message} + </div> + ) : ( + <ArticleList + articles={articles} + isReadView={true} + isSingleFeed={!!feedId} + hasNextPage={hasNextPage} + loadingMore={loadingMore} + onLoadMore={loadMore} + /> )} </div> - {data?.readArticles && ( - <ArticleList articles={data.readArticles} isReadView={true} /> - )} </div> ); } 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 ( - <div className="py-8 text-center"> - <p className="text-sm text-stone-400">Loading unread articles...</p> - </div> - ); - } + const search = useSearch(); + const params = new URLSearchParams(search); + const feedId = params.get("feed"); - if (error) { - return ( - <div className="rounded-lg bg-red-50 p-4 text-sm text-red-600"> - Error: {error.message} - </div> - ); - } + const { articles, hasNextPage, loading, loadingMore, loadMore, error } = + usePaginatedArticles({ isReadView: false, feedId }); return ( - <div> - <div className="mb-6"> - <h1 className="text-xl font-semibold text-stone-900">Unread</h1> - {data?.unreadArticles && ( - <p className="mt-1 text-sm text-stone-400"> - {data.unreadArticles.length} article - {data.unreadArticles.length !== 1 ? "s" : ""} to read - </p> + <div className="flex gap-8"> + <FeedSidebar basePath="/unread" /> + <div className="min-w-0 flex-1"> + <div className="mb-6"> + <h1 className="text-xl font-semibold text-stone-900">Unread</h1> + {!loading && articles.length > 0 && ( + <p className="mt-1 text-sm text-stone-400"> + {articles.length} + {hasNextPage ? "+" : ""} article + {articles.length !== 1 ? "s" : ""} to read + </p> + )} + </div> + {loading ? ( + <div className="py-8 text-center"> + <p className="text-sm text-stone-400">Loading unread articles...</p> + </div> + ) : error ? ( + <div className="rounded-lg bg-red-50 p-4 text-sm text-red-600"> + Error: {error.message} + </div> + ) : ( + <ArticleList + articles={articles} + isReadView={false} + isSingleFeed={!!feedId} + hasNextPage={hasNextPage} + loadingMore={loadingMore} + onLoadMore={loadMore} + /> )} </div> - {data?.unreadArticles && ( - <ArticleList articles={data.unreadArticles} isReadView={false} /> - )} </div> ); } |
