diff options
| author | nsfisis <nsfisis@gmail.com> | 2026-02-13 22:01:12 +0900 |
|---|---|---|
| committer | nsfisis <nsfisis@gmail.com> | 2026-02-13 22:01:12 +0900 |
| commit | e216c3bc97994b4172d15d52b46d5f6b75f35ea4 (patch) | |
| tree | 3ffbd74f4cb2d90846931c8dcbb97ec07f2b91f1 /frontend/src/hooks | |
| parent | c863e64c0521926e785f4aa7ecf4cf15bb9defa7 (diff) | |
| download | feedaka-e216c3bc97994b4172d15d52b46d5f6b75f35ea4.tar.gz feedaka-e216c3bc97994b4172d15d52b46d5f6b75f35ea4.tar.zst feedaka-e216c3bc97994b4172d15d52b46d5f6b75f35ea4.zip | |
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>
Diffstat (limited to 'frontend/src/hooks')
| -rw-r--r-- | frontend/src/hooks/usePaginatedArticles.ts | 106 |
1 files changed, 106 insertions, 0 deletions
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 }; +} |
