aboutsummaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
authornsfisis <nsfisis@gmail.com>2026-02-13 22:01:12 +0900
committernsfisis <nsfisis@gmail.com>2026-02-13 22:01:12 +0900
commite216c3bc97994b4172d15d52b46d5f6b75f35ea4 (patch)
tree3ffbd74f4cb2d90846931c8dcbb97ec07f2b91f1
parentc863e64c0521926e785f4aa7ecf4cf15bb9defa7 (diff)
downloadfeedaka-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>
-rw-r--r--backend/db/articles.sql.go192
-rw-r--r--backend/db/feeds.sql.go36
-rw-r--r--backend/db/migrations/006_add_composite_index.sql1
-rw-r--r--backend/db/queries/articles.sql32
-rw-r--r--backend/db/queries/feeds.sql7
-rw-r--r--backend/db/schema.sql2
-rw-r--r--backend/graphql/generated.go754
-rw-r--r--backend/graphql/model/generated.go18
-rw-r--r--backend/graphql/resolver/pagination.go177
-rw-r--r--backend/graphql/resolver/schema.resolvers.go75
-rw-r--r--frontend/src/components/ArticleItem.tsx7
-rw-r--r--frontend/src/components/ArticleList.tsx71
-rw-r--r--frontend/src/components/FeedSidebar.tsx75
-rw-r--r--frontend/src/components/index.ts1
-rw-r--r--frontend/src/graphql/generated/gql.ts6
-rw-r--r--frontend/src/graphql/generated/graphql.ts66
-rw-r--r--frontend/src/graphql/queries.graphql57
-rw-r--r--frontend/src/hooks/usePaginatedArticles.ts106
-rw-r--r--frontend/src/pages/ReadArticles.tsx73
-rw-r--r--frontend/src/pages/UnreadArticles.tsx73
-rw-r--r--graphql/schema.graphql43
21 files changed, 1620 insertions, 252 deletions
diff --git a/backend/db/articles.sql.go b/backend/db/articles.sql.go
index 7f6400b..329cb18 100644
--- a/backend/db/articles.sql.go
+++ b/backend/db/articles.sql.go
@@ -192,18 +192,96 @@ func (q *Queries) GetArticlesByFeed(ctx context.Context, feedID int64) ([]Articl
return items, nil
}
-const getReadArticles = `-- name: GetReadArticles :many
+const getArticlesByFeedPaginated = `-- name: GetArticlesByFeedPaginated :many
SELECT
a.id, a.feed_id, a.guid, a.title, a.url, a.is_read,
f.id as feed_id_2, f.url as feed_url, f.title as feed_title, f.is_subscribed as feed_is_subscribed
FROM articles AS a
INNER JOIN feeds AS f ON a.feed_id = f.id
-WHERE a.is_read = 1 AND f.is_subscribed = 1 AND f.user_id = ?
+WHERE a.is_read = ? AND f.is_subscribed = 1 AND f.user_id = ? AND a.feed_id = ?
ORDER BY a.id DESC
-LIMIT 100
+LIMIT ?
`
-type GetReadArticlesRow struct {
+type GetArticlesByFeedPaginatedParams struct {
+ IsRead int64
+ UserID int64
+ FeedID int64
+ Limit int64
+}
+
+type GetArticlesByFeedPaginatedRow struct {
+ ID int64
+ FeedID int64
+ Guid string
+ Title string
+ Url string
+ IsRead int64
+ FeedID2 int64
+ FeedUrl string
+ FeedTitle string
+ FeedIsSubscribed int64
+}
+
+func (q *Queries) GetArticlesByFeedPaginated(ctx context.Context, arg GetArticlesByFeedPaginatedParams) ([]GetArticlesByFeedPaginatedRow, error) {
+ rows, err := q.db.QueryContext(ctx, getArticlesByFeedPaginated,
+ arg.IsRead,
+ arg.UserID,
+ arg.FeedID,
+ arg.Limit,
+ )
+ if err != nil {
+ return nil, err
+ }
+ defer rows.Close()
+ items := []GetArticlesByFeedPaginatedRow{}
+ for rows.Next() {
+ var i GetArticlesByFeedPaginatedRow
+ if err := rows.Scan(
+ &i.ID,
+ &i.FeedID,
+ &i.Guid,
+ &i.Title,
+ &i.Url,
+ &i.IsRead,
+ &i.FeedID2,
+ &i.FeedUrl,
+ &i.FeedTitle,
+ &i.FeedIsSubscribed,
+ ); err != nil {
+ return nil, err
+ }
+ items = append(items, i)
+ }
+ if err := rows.Close(); err != nil {
+ return nil, err
+ }
+ if err := rows.Err(); err != nil {
+ return nil, err
+ }
+ return items, nil
+}
+
+const getArticlesByFeedPaginatedAfter = `-- name: GetArticlesByFeedPaginatedAfter :many
+SELECT
+ a.id, a.feed_id, a.guid, a.title, a.url, a.is_read,
+ f.id as feed_id_2, f.url as feed_url, f.title as feed_title, f.is_subscribed as feed_is_subscribed
+FROM articles AS a
+INNER JOIN feeds AS f ON a.feed_id = f.id
+WHERE a.is_read = ? AND f.is_subscribed = 1 AND f.user_id = ? AND a.feed_id = ? AND a.id < ?
+ORDER BY a.id DESC
+LIMIT ?
+`
+
+type GetArticlesByFeedPaginatedAfterParams struct {
+ IsRead int64
+ UserID int64
+ FeedID int64
+ ID int64
+ Limit int64
+}
+
+type GetArticlesByFeedPaginatedAfterRow struct {
ID int64
FeedID int64
Guid string
@@ -216,15 +294,85 @@ type GetReadArticlesRow struct {
FeedIsSubscribed int64
}
-func (q *Queries) GetReadArticles(ctx context.Context, userID int64) ([]GetReadArticlesRow, error) {
- rows, err := q.db.QueryContext(ctx, getReadArticles, userID)
+func (q *Queries) GetArticlesByFeedPaginatedAfter(ctx context.Context, arg GetArticlesByFeedPaginatedAfterParams) ([]GetArticlesByFeedPaginatedAfterRow, error) {
+ rows, err := q.db.QueryContext(ctx, getArticlesByFeedPaginatedAfter,
+ arg.IsRead,
+ arg.UserID,
+ arg.FeedID,
+ arg.ID,
+ arg.Limit,
+ )
+ if err != nil {
+ return nil, err
+ }
+ defer rows.Close()
+ items := []GetArticlesByFeedPaginatedAfterRow{}
+ for rows.Next() {
+ var i GetArticlesByFeedPaginatedAfterRow
+ if err := rows.Scan(
+ &i.ID,
+ &i.FeedID,
+ &i.Guid,
+ &i.Title,
+ &i.Url,
+ &i.IsRead,
+ &i.FeedID2,
+ &i.FeedUrl,
+ &i.FeedTitle,
+ &i.FeedIsSubscribed,
+ ); err != nil {
+ return nil, err
+ }
+ items = append(items, i)
+ }
+ if err := rows.Close(); err != nil {
+ return nil, err
+ }
+ if err := rows.Err(); err != nil {
+ return nil, err
+ }
+ return items, nil
+}
+
+const getArticlesPaginated = `-- name: GetArticlesPaginated :many
+SELECT
+ a.id, a.feed_id, a.guid, a.title, a.url, a.is_read,
+ f.id as feed_id_2, f.url as feed_url, f.title as feed_title, f.is_subscribed as feed_is_subscribed
+FROM articles AS a
+INNER JOIN feeds AS f ON a.feed_id = f.id
+WHERE a.is_read = ? AND f.is_subscribed = 1 AND f.user_id = ?
+ORDER BY a.id DESC
+LIMIT ?
+`
+
+type GetArticlesPaginatedParams struct {
+ IsRead int64
+ UserID int64
+ Limit int64
+}
+
+type GetArticlesPaginatedRow struct {
+ ID int64
+ FeedID int64
+ Guid string
+ Title string
+ Url string
+ IsRead int64
+ FeedID2 int64
+ FeedUrl string
+ FeedTitle string
+ FeedIsSubscribed int64
+}
+
+func (q *Queries) GetArticlesPaginated(ctx context.Context, arg GetArticlesPaginatedParams) ([]GetArticlesPaginatedRow, error) {
+ rows, err := q.db.QueryContext(ctx, getArticlesPaginated, arg.IsRead, arg.UserID, arg.Limit)
if err != nil {
return nil, err
}
defer rows.Close()
- items := []GetReadArticlesRow{}
+ items := []GetArticlesPaginatedRow{}
for rows.Next() {
- var i GetReadArticlesRow
+ var i GetArticlesPaginatedRow
if err := rows.Scan(
&i.ID,
&i.FeedID,
@@ -250,18 +398,25 @@ func (q *Queries) GetReadArticles(ctx context.Context, userID int64) ([]GetReadA
return items, nil
}
-const getUnreadArticles = `-- name: GetUnreadArticles :many
+const getArticlesPaginatedAfter = `-- name: GetArticlesPaginatedAfter :many
SELECT
a.id, a.feed_id, a.guid, a.title, a.url, a.is_read,
f.id as feed_id_2, f.url as feed_url, f.title as feed_title, f.is_subscribed as feed_is_subscribed
FROM articles AS a
INNER JOIN feeds AS f ON a.feed_id = f.id
-WHERE a.is_read = 0 AND f.is_subscribed = 1 AND f.user_id = ?
+WHERE a.is_read = ? AND f.is_subscribed = 1 AND f.user_id = ? AND a.id < ?
ORDER BY a.id DESC
-LIMIT 100
+LIMIT ?
`
-type GetUnreadArticlesRow struct {
+type GetArticlesPaginatedAfterParams struct {
+ IsRead int64
+ UserID int64
+ ID int64
+ Limit int64
+}
+
+type GetArticlesPaginatedAfterRow struct {
ID int64
FeedID int64
Guid string
@@ -274,15 +429,20 @@ type GetUnreadArticlesRow struct {
FeedIsSubscribed int64
}
-func (q *Queries) GetUnreadArticles(ctx context.Context, userID int64) ([]GetUnreadArticlesRow, error) {
- rows, err := q.db.QueryContext(ctx, getUnreadArticles, userID)
+func (q *Queries) GetArticlesPaginatedAfter(ctx context.Context, arg GetArticlesPaginatedAfterParams) ([]GetArticlesPaginatedAfterRow, error) {
+ rows, err := q.db.QueryContext(ctx, getArticlesPaginatedAfter,
+ arg.IsRead,
+ arg.UserID,
+ arg.ID,
+ arg.Limit,
+ )
if err != nil {
return nil, err
}
defer rows.Close()
- items := []GetUnreadArticlesRow{}
+ items := []GetArticlesPaginatedAfterRow{}
for rows.Next() {
- var i GetUnreadArticlesRow
+ var i GetArticlesPaginatedAfterRow
if err := rows.Scan(
&i.ID,
&i.FeedID,
diff --git a/backend/db/feeds.sql.go b/backend/db/feeds.sql.go
index cec228a..0226a7d 100644
--- a/backend/db/feeds.sql.go
+++ b/backend/db/feeds.sql.go
@@ -96,6 +96,42 @@ func (q *Queries) GetFeedByURL(ctx context.Context, arg GetFeedByURLParams) (Fee
return i, err
}
+const getFeedUnreadCounts = `-- name: GetFeedUnreadCounts :many
+SELECT f.id as feed_id, COUNT(a.id) as unread_count
+FROM feeds AS f
+LEFT JOIN articles AS a ON f.id = a.feed_id AND a.is_read = 0
+WHERE f.is_subscribed = 1 AND f.user_id = ?
+GROUP BY f.id
+`
+
+type GetFeedUnreadCountsRow struct {
+ FeedID int64
+ UnreadCount int64
+}
+
+func (q *Queries) GetFeedUnreadCounts(ctx context.Context, userID int64) ([]GetFeedUnreadCountsRow, error) {
+ rows, err := q.db.QueryContext(ctx, getFeedUnreadCounts, userID)
+ if err != nil {
+ return nil, err
+ }
+ defer rows.Close()
+ items := []GetFeedUnreadCountsRow{}
+ for rows.Next() {
+ var i GetFeedUnreadCountsRow
+ if err := rows.Scan(&i.FeedID, &i.UnreadCount); err != nil {
+ return nil, err
+ }
+ items = append(items, i)
+ }
+ if err := rows.Close(); err != nil {
+ return nil, err
+ }
+ if err := rows.Err(); err != nil {
+ return nil, err
+ }
+ return items, nil
+}
+
const getFeeds = `-- name: GetFeeds :many
SELECT id, url, title, fetched_at, is_subscribed, user_id
FROM feeds
diff --git a/backend/db/migrations/006_add_composite_index.sql b/backend/db/migrations/006_add_composite_index.sql
new file mode 100644
index 0000000..d3d378b
--- /dev/null
+++ b/backend/db/migrations/006_add_composite_index.sql
@@ -0,0 +1 @@
+CREATE INDEX IF NOT EXISTS idx_articles_feed_read_id ON articles(feed_id, is_read, id DESC);
diff --git a/backend/db/queries/articles.sql b/backend/db/queries/articles.sql
index 2c00678..1554530 100644
--- a/backend/db/queries/articles.sql
+++ b/backend/db/queries/articles.sql
@@ -6,25 +6,45 @@ FROM articles AS a
INNER JOIN feeds AS f ON a.feed_id = f.id
WHERE a.id = ?;
--- name: GetUnreadArticles :many
+-- name: GetArticlesPaginated :many
SELECT
a.id, a.feed_id, a.guid, a.title, a.url, a.is_read,
f.id as feed_id_2, f.url as feed_url, f.title as feed_title, f.is_subscribed as feed_is_subscribed
FROM articles AS a
INNER JOIN feeds AS f ON a.feed_id = f.id
-WHERE a.is_read = 0 AND f.is_subscribed = 1 AND f.user_id = ?
+WHERE a.is_read = ? AND f.is_subscribed = 1 AND f.user_id = ?
ORDER BY a.id DESC
-LIMIT 100;
+LIMIT ?;
--- name: GetReadArticles :many
+-- name: GetArticlesPaginatedAfter :many
SELECT
a.id, a.feed_id, a.guid, a.title, a.url, a.is_read,
f.id as feed_id_2, f.url as feed_url, f.title as feed_title, f.is_subscribed as feed_is_subscribed
FROM articles AS a
INNER JOIN feeds AS f ON a.feed_id = f.id
-WHERE a.is_read = 1 AND f.is_subscribed = 1 AND f.user_id = ?
+WHERE a.is_read = ? AND f.is_subscribed = 1 AND f.user_id = ? AND a.id < ?
ORDER BY a.id DESC
-LIMIT 100;
+LIMIT ?;
+
+-- name: GetArticlesByFeedPaginated :many
+SELECT
+ a.id, a.feed_id, a.guid, a.title, a.url, a.is_read,
+ f.id as feed_id_2, f.url as feed_url, f.title as feed_title, f.is_subscribed as feed_is_subscribed
+FROM articles AS a
+INNER JOIN feeds AS f ON a.feed_id = f.id
+WHERE a.is_read = ? AND f.is_subscribed = 1 AND f.user_id = ? AND a.feed_id = ?
+ORDER BY a.id DESC
+LIMIT ?;
+
+-- name: GetArticlesByFeedPaginatedAfter :many
+SELECT
+ a.id, a.feed_id, a.guid, a.title, a.url, a.is_read,
+ f.id as feed_id_2, f.url as feed_url, f.title as feed_title, f.is_subscribed as feed_is_subscribed
+FROM articles AS a
+INNER JOIN feeds AS f ON a.feed_id = f.id
+WHERE a.is_read = ? AND f.is_subscribed = 1 AND f.user_id = ? AND a.feed_id = ? AND a.id < ?
+ORDER BY a.id DESC
+LIMIT ?;
-- name: GetArticlesByFeed :many
SELECT id, feed_id, guid, title, url, is_read
diff --git a/backend/db/queries/feeds.sql b/backend/db/queries/feeds.sql
index acf36d2..094a0f8 100644
--- a/backend/db/queries/feeds.sql
+++ b/backend/db/queries/feeds.sql
@@ -37,3 +37,10 @@ WHERE is_subscribed = 1;
UPDATE feeds
SET is_subscribed = 0
WHERE id = ?;
+
+-- name: GetFeedUnreadCounts :many
+SELECT f.id as feed_id, COUNT(a.id) as unread_count
+FROM feeds AS f
+LEFT JOIN articles AS a ON f.id = a.feed_id AND a.is_read = 0
+WHERE f.is_subscribed = 1 AND f.user_id = ?
+GROUP BY f.id;
diff --git a/backend/db/schema.sql b/backend/db/schema.sql
index 07ac72d..2596c84 100644
--- a/backend/db/schema.sql
+++ b/backend/db/schema.sql
@@ -37,3 +37,5 @@ CREATE INDEX IF NOT EXISTS idx_articles_is_read ON articles(is_read);
CREATE INDEX IF NOT EXISTS idx_articles_guid ON articles(guid);
CREATE INDEX IF NOT EXISTS idx_feeds_user_id ON feeds(user_id);
+
+CREATE INDEX IF NOT EXISTS idx_articles_feed_read_id ON articles(feed_id, is_read, id DESC);
diff --git a/backend/graphql/generated.go b/backend/graphql/generated.go
index 72379bc..ebc130a 100644
--- a/backend/graphql/generated.go
+++ b/backend/graphql/generated.go
@@ -56,6 +56,11 @@ type ComplexityRoot struct {
URL func(childComplexity int) int
}
+ ArticleConnection struct {
+ Articles func(childComplexity int) int
+ PageInfo func(childComplexity int) int
+ }
+
AuthPayload struct {
User func(childComplexity int) int
}
@@ -67,6 +72,7 @@ type ComplexityRoot struct {
IsSubscribed func(childComplexity int) int
Title func(childComplexity int) int
URL func(childComplexity int) int
+ UnreadCount func(childComplexity int) int
}
Mutation struct {
@@ -80,13 +86,18 @@ type ComplexityRoot struct {
UnsubscribeFeed func(childComplexity int, id string) int
}
+ PageInfo struct {
+ EndCursor func(childComplexity int) int
+ HasNextPage func(childComplexity int) int
+ }
+
Query struct {
Article func(childComplexity int, id string) int
CurrentUser func(childComplexity int) int
Feed func(childComplexity int, id string) int
Feeds func(childComplexity int) int
- ReadArticles func(childComplexity int) int
- UnreadArticles func(childComplexity int) int
+ ReadArticles func(childComplexity int, feedID *string, after *string, first *int32) int
+ UnreadArticles func(childComplexity int, feedID *string, after *string, first *int32) int
}
User struct {
@@ -107,8 +118,8 @@ type MutationResolver interface {
}
type QueryResolver interface {
Feeds(ctx context.Context) ([]*model.Feed, error)
- UnreadArticles(ctx context.Context) ([]*model.Article, error)
- ReadArticles(ctx context.Context) ([]*model.Article, error)
+ UnreadArticles(ctx context.Context, feedID *string, after *string, first *int32) (*model.ArticleConnection, error)
+ ReadArticles(ctx context.Context, feedID *string, after *string, first *int32) (*model.ArticleConnection, error)
Feed(ctx context.Context, id string) (*model.Feed, error)
Article(ctx context.Context, id string) (*model.Article, error)
CurrentUser(ctx context.Context) (*model.User, error)
@@ -182,6 +193,20 @@ func (e *executableSchema) Complexity(ctx context.Context, typeName, field strin
return e.complexity.Article.URL(childComplexity), true
+ case "ArticleConnection.articles":
+ if e.complexity.ArticleConnection.Articles == nil {
+ break
+ }
+
+ return e.complexity.ArticleConnection.Articles(childComplexity), true
+
+ case "ArticleConnection.pageInfo":
+ if e.complexity.ArticleConnection.PageInfo == nil {
+ break
+ }
+
+ return e.complexity.ArticleConnection.PageInfo(childComplexity), true
+
case "AuthPayload.user":
if e.complexity.AuthPayload.User == nil {
break
@@ -231,6 +256,13 @@ func (e *executableSchema) Complexity(ctx context.Context, typeName, field strin
return e.complexity.Feed.URL(childComplexity), true
+ case "Feed.unreadCount":
+ if e.complexity.Feed.UnreadCount == nil {
+ break
+ }
+
+ return e.complexity.Feed.UnreadCount(childComplexity), true
+
case "Mutation.addFeed":
if e.complexity.Mutation.AddFeed == nil {
break
@@ -322,6 +354,20 @@ func (e *executableSchema) Complexity(ctx context.Context, typeName, field strin
return e.complexity.Mutation.UnsubscribeFeed(childComplexity, args["id"].(string)), true
+ case "PageInfo.endCursor":
+ if e.complexity.PageInfo.EndCursor == nil {
+ break
+ }
+
+ return e.complexity.PageInfo.EndCursor(childComplexity), true
+
+ case "PageInfo.hasNextPage":
+ if e.complexity.PageInfo.HasNextPage == nil {
+ break
+ }
+
+ return e.complexity.PageInfo.HasNextPage(childComplexity), true
+
case "Query.article":
if e.complexity.Query.Article == nil {
break
@@ -365,14 +411,24 @@ func (e *executableSchema) Complexity(ctx context.Context, typeName, field strin
break
}
- return e.complexity.Query.ReadArticles(childComplexity), true
+ args, err := ec.field_Query_readArticles_args(ctx, rawArgs)
+ if err != nil {
+ return 0, false
+ }
+
+ return e.complexity.Query.ReadArticles(childComplexity, args["feedId"].(*string), args["after"].(*string), args["first"].(*int32)), true
case "Query.unreadArticles":
if e.complexity.Query.UnreadArticles == nil {
break
}
- return e.complexity.Query.UnreadArticles(childComplexity), true
+ args, err := ec.field_Query_unreadArticles_args(ctx, rawArgs)
+ if err != nil {
+ return 0, false
+ }
+
+ return e.complexity.Query.UnreadArticles(childComplexity, args["feedId"].(*string), args["after"].(*string), args["first"].(*int32)), true
case "User.id":
if e.complexity.User.ID == nil {
@@ -524,6 +580,11 @@ type Feed {
isSubscribed: Boolean!
"""
+ Number of unread articles in this feed
+ """
+ unreadCount: Int!
+
+ """
Articles belonging to this feed
"""
articles: [Article!]!
@@ -585,6 +646,36 @@ type User {
}
"""
+Pagination information for cursor-based pagination
+"""
+type PageInfo {
+ """
+ Whether there are more items after the last item in this page
+ """
+ hasNextPage: Boolean!
+
+ """
+ Cursor of the last item in this page
+ """
+ endCursor: ID
+}
+
+"""
+A paginated list of articles
+"""
+type ArticleConnection {
+ """
+ The list of articles
+ """
+ articles: [Article!]!
+
+ """
+ Pagination information
+ """
+ pageInfo: PageInfo!
+}
+
+"""
Authentication payload returned from login mutation
"""
type AuthPayload {
@@ -604,14 +695,14 @@ type Query {
feeds: [Feed!]!
"""
- Get all unread articles across all feeds
+ Get unread articles with optional feed filter and cursor-based pagination
"""
- unreadArticles: [Article!]!
+ unreadArticles(feedId: ID, after: ID, first: Int): ArticleConnection!
"""
- Get all read articles across all feeds
+ Get read articles with optional feed filter and cursor-based pagination
"""
- readArticles: [Article!]!
+ readArticles(feedId: ID, after: ID, first: Int): ArticleConnection!
"""
Get a specific feed by ID
@@ -984,6 +1075,154 @@ func (ec *executionContext) field_Query_feed_argsID(
return zeroVal, nil
}
+func (ec *executionContext) field_Query_readArticles_args(ctx context.Context, rawArgs map[string]any) (map[string]any, error) {
+ var err error
+ args := map[string]any{}
+ arg0, err := ec.field_Query_readArticles_argsFeedID(ctx, rawArgs)
+ if err != nil {
+ return nil, err
+ }
+ args["feedId"] = arg0
+ arg1, err := ec.field_Query_readArticles_argsAfter(ctx, rawArgs)
+ if err != nil {
+ return nil, err
+ }
+ args["after"] = arg1
+ arg2, err := ec.field_Query_readArticles_argsFirst(ctx, rawArgs)
+ if err != nil {
+ return nil, err
+ }
+ args["first"] = arg2
+ return args, nil
+}
+func (ec *executionContext) field_Query_readArticles_argsFeedID(
+ ctx context.Context,
+ rawArgs map[string]any,
+) (*string, error) {
+ if _, ok := rawArgs["feedId"]; !ok {
+ var zeroVal *string
+ return zeroVal, nil
+ }
+
+ ctx = graphql.WithPathContext(ctx, graphql.NewPathWithField("feedId"))
+ if tmp, ok := rawArgs["feedId"]; ok {
+ return ec.unmarshalOID2ᚖstring(ctx, tmp)
+ }
+
+ var zeroVal *string
+ return zeroVal, nil
+}
+
+func (ec *executionContext) field_Query_readArticles_argsAfter(
+ ctx context.Context,
+ rawArgs map[string]any,
+) (*string, error) {
+ if _, ok := rawArgs["after"]; !ok {
+ var zeroVal *string
+ return zeroVal, nil
+ }
+
+ ctx = graphql.WithPathContext(ctx, graphql.NewPathWithField("after"))
+ if tmp, ok := rawArgs["after"]; ok {
+ return ec.unmarshalOID2ᚖstring(ctx, tmp)
+ }
+
+ var zeroVal *string
+ return zeroVal, nil
+}
+
+func (ec *executionContext) field_Query_readArticles_argsFirst(
+ ctx context.Context,
+ rawArgs map[string]any,
+) (*int32, error) {
+ if _, ok := rawArgs["first"]; !ok {
+ var zeroVal *int32
+ return zeroVal, nil
+ }
+
+ ctx = graphql.WithPathContext(ctx, graphql.NewPathWithField("first"))
+ if tmp, ok := rawArgs["first"]; ok {
+ return ec.unmarshalOInt2ᚖint32(ctx, tmp)
+ }
+
+ var zeroVal *int32
+ return zeroVal, nil
+}
+
+func (ec *executionContext) field_Query_unreadArticles_args(ctx context.Context, rawArgs map[string]any) (map[string]any, error) {
+ var err error
+ args := map[string]any{}
+ arg0, err := ec.field_Query_unreadArticles_argsFeedID(ctx, rawArgs)
+ if err != nil {
+ return nil, err
+ }
+ args["feedId"] = arg0
+ arg1, err := ec.field_Query_unreadArticles_argsAfter(ctx, rawArgs)
+ if err != nil {
+ return nil, err
+ }
+ args["after"] = arg1
+ arg2, err := ec.field_Query_unreadArticles_argsFirst(ctx, rawArgs)
+ if err != nil {
+ return nil, err
+ }
+ args["first"] = arg2
+ return args, nil
+}
+func (ec *executionContext) field_Query_unreadArticles_argsFeedID(
+ ctx context.Context,
+ rawArgs map[string]any,
+) (*string, error) {
+ if _, ok := rawArgs["feedId"]; !ok {
+ var zeroVal *string
+ return zeroVal, nil
+ }
+
+ ctx = graphql.WithPathContext(ctx, graphql.NewPathWithField("feedId"))
+ if tmp, ok := rawArgs["feedId"]; ok {
+ return ec.unmarshalOID2ᚖstring(ctx, tmp)
+ }
+
+ var zeroVal *string
+ return zeroVal, nil
+}
+
+func (ec *executionContext) field_Query_unreadArticles_argsAfter(
+ ctx context.Context,
+ rawArgs map[string]any,
+) (*string, error) {
+ if _, ok := rawArgs["after"]; !ok {
+ var zeroVal *string
+ return zeroVal, nil
+ }
+
+ ctx = graphql.WithPathContext(ctx, graphql.NewPathWithField("after"))
+ if tmp, ok := rawArgs["after"]; ok {
+ return ec.unmarshalOID2ᚖstring(ctx, tmp)
+ }
+
+ var zeroVal *string
+ return zeroVal, nil
+}
+
+func (ec *executionContext) field_Query_unreadArticles_argsFirst(
+ ctx context.Context,
+ rawArgs map[string]any,
+) (*int32, error) {
+ if _, ok := rawArgs["first"]; !ok {
+ var zeroVal *int32
+ return zeroVal, nil
+ }
+
+ ctx = graphql.WithPathContext(ctx, graphql.NewPathWithField("first"))
+ if tmp, ok := rawArgs["first"]; ok {
+ return ec.unmarshalOInt2ᚖint32(ctx, tmp)
+ }
+
+ var zeroVal *int32
+ return zeroVal, nil
+}
+
func (ec *executionContext) field___Directive_args_args(ctx context.Context, rawArgs map[string]any) (map[string]any, error) {
var err error
args := map[string]any{}
@@ -1417,6 +1656,8 @@ func (ec *executionContext) fieldContext_Article_feed(_ context.Context, field g
return ec.fieldContext_Feed_fetchedAt(ctx, field)
case "isSubscribed":
return ec.fieldContext_Feed_isSubscribed(ctx, field)
+ case "unreadCount":
+ return ec.fieldContext_Feed_unreadCount(ctx, field)
case "articles":
return ec.fieldContext_Feed_articles(ctx, field)
}
@@ -1426,6 +1667,116 @@ func (ec *executionContext) fieldContext_Article_feed(_ context.Context, field g
return fc, nil
}
+func (ec *executionContext) _ArticleConnection_articles(ctx context.Context, field graphql.CollectedField, obj *model.ArticleConnection) (ret graphql.Marshaler) {
+ fc, err := ec.fieldContext_ArticleConnection_articles(ctx, field)
+ if err != nil {
+ return graphql.Null
+ }
+ ctx = graphql.WithFieldContext(ctx, fc)
+ defer func() {
+ if r := recover(); r != nil {
+ ec.Error(ctx, ec.Recover(ctx, r))
+ ret = graphql.Null
+ }
+ }()
+ resTmp, err := ec.ResolverMiddleware(ctx, func(rctx context.Context) (any, error) {
+ ctx = rctx // use context from middleware stack in children
+ return obj.Articles, nil
+ })
+ if err != nil {
+ ec.Error(ctx, err)
+ return graphql.Null
+ }
+ if resTmp == nil {
+ if !graphql.HasFieldError(ctx, fc) {
+ ec.Errorf(ctx, "must not be null")
+ }
+ return graphql.Null
+ }
+ res := resTmp.([]*model.Article)
+ fc.Result = res
+ return ec.marshalNArticle2ᚕᚖundefᚗninjaᚋxᚋfeedakaᚋgraphqlᚋmodelᚐArticleᚄ(ctx, field.Selections, res)
+}
+
+func (ec *executionContext) fieldContext_ArticleConnection_articles(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) {
+ fc = &graphql.FieldContext{
+ Object: "ArticleConnection",
+ Field: field,
+ IsMethod: false,
+ IsResolver: false,
+ Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) {
+ switch field.Name {
+ case "id":
+ return ec.fieldContext_Article_id(ctx, field)
+ case "feedId":
+ return ec.fieldContext_Article_feedId(ctx, field)
+ case "guid":
+ return ec.fieldContext_Article_guid(ctx, field)
+ case "title":
+ return ec.fieldContext_Article_title(ctx, field)
+ case "url":
+ return ec.fieldContext_Article_url(ctx, field)
+ case "isRead":
+ return ec.fieldContext_Article_isRead(ctx, field)
+ case "feed":
+ return ec.fieldContext_Article_feed(ctx, field)
+ }
+ return nil, fmt.Errorf("no field named %q was found under type Article", field.Name)
+ },
+ }
+ return fc, nil
+}
+
+func (ec *executionContext) _ArticleConnection_pageInfo(ctx context.Context, field graphql.CollectedField, obj *model.ArticleConnection) (ret graphql.Marshaler) {
+ fc, err := ec.fieldContext_ArticleConnection_pageInfo(ctx, field)
+ if err != nil {
+ return graphql.Null
+ }
+ ctx = graphql.WithFieldContext(ctx, fc)
+ defer func() {
+ if r := recover(); r != nil {
+ ec.Error(ctx, ec.Recover(ctx, r))
+ ret = graphql.Null
+ }
+ }()
+ resTmp, err := ec.ResolverMiddleware(ctx, func(rctx context.Context) (any, error) {
+ ctx = rctx // use context from middleware stack in children
+ return obj.PageInfo, nil
+ })
+ if err != nil {
+ ec.Error(ctx, err)
+ return graphql.Null
+ }
+ if resTmp == nil {
+ if !graphql.HasFieldError(ctx, fc) {
+ ec.Errorf(ctx, "must not be null")
+ }
+ return graphql.Null
+ }
+ res := resTmp.(*model.PageInfo)
+ fc.Result = res
+ return ec.marshalNPageInfo2ᚖundefᚗninjaᚋxᚋfeedakaᚋgraphqlᚋmodelᚐPageInfo(ctx, field.Selections, res)
+}
+
+func (ec *executionContext) fieldContext_ArticleConnection_pageInfo(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) {
+ fc = &graphql.FieldContext{
+ Object: "ArticleConnection",
+ Field: field,
+ IsMethod: false,
+ IsResolver: false,
+ Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) {
+ switch field.Name {
+ case "hasNextPage":
+ return ec.fieldContext_PageInfo_hasNextPage(ctx, field)
+ case "endCursor":
+ return ec.fieldContext_PageInfo_endCursor(ctx, field)
+ }
+ return nil, fmt.Errorf("no field named %q was found under type PageInfo", field.Name)
+ },
+ }
+ return fc, nil
+}
+
func (ec *executionContext) _AuthPayload_user(ctx context.Context, field graphql.CollectedField, obj *model.AuthPayload) (ret graphql.Marshaler) {
fc, err := ec.fieldContext_AuthPayload_user(ctx, field)
if err != nil {
@@ -1696,6 +2047,50 @@ func (ec *executionContext) fieldContext_Feed_isSubscribed(_ context.Context, fi
return fc, nil
}
+func (ec *executionContext) _Feed_unreadCount(ctx context.Context, field graphql.CollectedField, obj *model.Feed) (ret graphql.Marshaler) {
+ fc, err := ec.fieldContext_Feed_unreadCount(ctx, field)
+ if err != nil {
+ return graphql.Null
+ }
+ ctx = graphql.WithFieldContext(ctx, fc)
+ defer func() {
+ if r := recover(); r != nil {
+ ec.Error(ctx, ec.Recover(ctx, r))
+ ret = graphql.Null
+ }
+ }()
+ resTmp, err := ec.ResolverMiddleware(ctx, func(rctx context.Context) (any, error) {
+ ctx = rctx // use context from middleware stack in children
+ return obj.UnreadCount, nil
+ })
+ if err != nil {
+ ec.Error(ctx, err)
+ return graphql.Null
+ }
+ if resTmp == nil {
+ if !graphql.HasFieldError(ctx, fc) {
+ ec.Errorf(ctx, "must not be null")
+ }
+ return graphql.Null
+ }
+ res := resTmp.(int32)
+ fc.Result = res
+ return ec.marshalNInt2int32(ctx, field.Selections, res)
+}
+
+func (ec *executionContext) fieldContext_Feed_unreadCount(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) {
+ fc = &graphql.FieldContext{
+ Object: "Feed",
+ Field: field,
+ IsMethod: false,
+ IsResolver: false,
+ Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) {
+ return nil, errors.New("field of type Int does not have child fields")
+ },
+ }
+ return fc, nil
+}
+
func (ec *executionContext) _Feed_articles(ctx context.Context, field graphql.CollectedField, obj *model.Feed) (ret graphql.Marshaler) {
fc, err := ec.fieldContext_Feed_articles(ctx, field)
if err != nil {
@@ -1805,6 +2200,8 @@ func (ec *executionContext) fieldContext_Mutation_addFeed(ctx context.Context, f
return ec.fieldContext_Feed_fetchedAt(ctx, field)
case "isSubscribed":
return ec.fieldContext_Feed_isSubscribed(ctx, field)
+ case "unreadCount":
+ return ec.fieldContext_Feed_unreadCount(ctx, field)
case "articles":
return ec.fieldContext_Feed_articles(ctx, field)
}
@@ -2071,6 +2468,8 @@ func (ec *executionContext) fieldContext_Mutation_markFeedRead(ctx context.Conte
return ec.fieldContext_Feed_fetchedAt(ctx, field)
case "isSubscribed":
return ec.fieldContext_Feed_isSubscribed(ctx, field)
+ case "unreadCount":
+ return ec.fieldContext_Feed_unreadCount(ctx, field)
case "articles":
return ec.fieldContext_Feed_articles(ctx, field)
}
@@ -2140,6 +2539,8 @@ func (ec *executionContext) fieldContext_Mutation_markFeedUnread(ctx context.Con
return ec.fieldContext_Feed_fetchedAt(ctx, field)
case "isSubscribed":
return ec.fieldContext_Feed_isSubscribed(ctx, field)
+ case "unreadCount":
+ return ec.fieldContext_Feed_unreadCount(ctx, field)
case "articles":
return ec.fieldContext_Feed_articles(ctx, field)
}
@@ -2263,6 +2664,91 @@ func (ec *executionContext) fieldContext_Mutation_logout(_ context.Context, fiel
return fc, nil
}
+func (ec *executionContext) _PageInfo_hasNextPage(ctx context.Context, field graphql.CollectedField, obj *model.PageInfo) (ret graphql.Marshaler) {
+ fc, err := ec.fieldContext_PageInfo_hasNextPage(ctx, field)
+ if err != nil {
+ return graphql.Null
+ }
+ ctx = graphql.WithFieldContext(ctx, fc)
+ defer func() {
+ if r := recover(); r != nil {
+ ec.Error(ctx, ec.Recover(ctx, r))
+ ret = graphql.Null
+ }
+ }()
+ resTmp, err := ec.ResolverMiddleware(ctx, func(rctx context.Context) (any, error) {
+ ctx = rctx // use context from middleware stack in children
+ return obj.HasNextPage, nil
+ })
+ if err != nil {
+ ec.Error(ctx, err)
+ return graphql.Null
+ }
+ if resTmp == nil {
+ if !graphql.HasFieldError(ctx, fc) {
+ ec.Errorf(ctx, "must not be null")
+ }
+ return graphql.Null
+ }
+ res := resTmp.(bool)
+ fc.Result = res
+ return ec.marshalNBoolean2bool(ctx, field.Selections, res)
+}
+
+func (ec *executionContext) fieldContext_PageInfo_hasNextPage(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) {
+ fc = &graphql.FieldContext{
+ Object: "PageInfo",
+ Field: field,
+ IsMethod: false,
+ IsResolver: false,
+ Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) {
+ return nil, errors.New("field of type Boolean does not have child fields")
+ },
+ }
+ return fc, nil
+}
+
+func (ec *executionContext) _PageInfo_endCursor(ctx context.Context, field graphql.CollectedField, obj *model.PageInfo) (ret graphql.Marshaler) {
+ fc, err := ec.fieldContext_PageInfo_endCursor(ctx, field)
+ if err != nil {
+ return graphql.Null
+ }
+ ctx = graphql.WithFieldContext(ctx, fc)
+ defer func() {
+ if r := recover(); r != nil {
+ ec.Error(ctx, ec.Recover(ctx, r))
+ ret = graphql.Null
+ }
+ }()
+ resTmp, err := ec.ResolverMiddleware(ctx, func(rctx context.Context) (any, error) {
+ ctx = rctx // use context from middleware stack in children
+ return obj.EndCursor, nil
+ })
+ if err != nil {
+ ec.Error(ctx, err)
+ return graphql.Null
+ }
+ if resTmp == nil {
+ return graphql.Null
+ }
+ res := resTmp.(*string)
+ fc.Result = res
+ return ec.marshalOID2ᚖstring(ctx, field.Selections, res)
+}
+
+func (ec *executionContext) fieldContext_PageInfo_endCursor(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) {
+ fc = &graphql.FieldContext{
+ Object: "PageInfo",
+ Field: field,
+ IsMethod: false,
+ IsResolver: false,
+ Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) {
+ return nil, errors.New("field of type ID does not have child fields")
+ },
+ }
+ return fc, nil
+}
+
func (ec *executionContext) _Query_feeds(ctx context.Context, field graphql.CollectedField) (ret graphql.Marshaler) {
fc, err := ec.fieldContext_Query_feeds(ctx, field)
if err != nil {
@@ -2312,6 +2798,8 @@ func (ec *executionContext) fieldContext_Query_feeds(_ context.Context, field gr
return ec.fieldContext_Feed_fetchedAt(ctx, field)
case "isSubscribed":
return ec.fieldContext_Feed_isSubscribed(ctx, field)
+ case "unreadCount":
+ return ec.fieldContext_Feed_unreadCount(ctx, field)
case "articles":
return ec.fieldContext_Feed_articles(ctx, field)
}
@@ -2335,7 +2823,7 @@ func (ec *executionContext) _Query_unreadArticles(ctx context.Context, field gra
}()
resTmp, err := ec.ResolverMiddleware(ctx, func(rctx context.Context) (any, error) {
ctx = rctx // use context from middleware stack in children
- return ec.resolvers.Query().UnreadArticles(rctx)
+ return ec.resolvers.Query().UnreadArticles(rctx, fc.Args["feedId"].(*string), fc.Args["after"].(*string), fc.Args["first"].(*int32))
})
if err != nil {
ec.Error(ctx, err)
@@ -2347,12 +2835,12 @@ func (ec *executionContext) _Query_unreadArticles(ctx context.Context, field gra
}
return graphql.Null
}
- res := resTmp.([]*model.Article)
+ res := resTmp.(*model.ArticleConnection)
fc.Result = res
- return ec.marshalNArticle2ᚕᚖundefᚗninjaᚋxᚋfeedakaᚋgraphqlᚋmodelᚐArticleᚄ(ctx, field.Selections, res)
+ return ec.marshalNArticleConnection2ᚖundefᚗninjaᚋxᚋfeedakaᚋgraphqlᚋmodelᚐArticleConnection(ctx, field.Selections, res)
}
-func (ec *executionContext) fieldContext_Query_unreadArticles(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) {
+func (ec *executionContext) fieldContext_Query_unreadArticles(ctx context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) {
fc = &graphql.FieldContext{
Object: "Query",
Field: field,
@@ -2360,24 +2848,25 @@ func (ec *executionContext) fieldContext_Query_unreadArticles(_ context.Context,
IsResolver: true,
Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) {
switch field.Name {
- case "id":
- return ec.fieldContext_Article_id(ctx, field)
- case "feedId":
- return ec.fieldContext_Article_feedId(ctx, field)
- case "guid":
- return ec.fieldContext_Article_guid(ctx, field)
- case "title":
- return ec.fieldContext_Article_title(ctx, field)
- case "url":
- return ec.fieldContext_Article_url(ctx, field)
- case "isRead":
- return ec.fieldContext_Article_isRead(ctx, field)
- case "feed":
- return ec.fieldContext_Article_feed(ctx, field)
+ case "articles":
+ return ec.fieldContext_ArticleConnection_articles(ctx, field)
+ case "pageInfo":
+ return ec.fieldContext_ArticleConnection_pageInfo(ctx, field)
}
- return nil, fmt.Errorf("no field named %q was found under type Article", field.Name)
+ return nil, fmt.Errorf("no field named %q was found under type ArticleConnection", field.Name)
},
}
+ defer func() {
+ if r := recover(); r != nil {
+ err = ec.Recover(ctx, r)
+ ec.Error(ctx, err)
+ }
+ }()
+ ctx = graphql.WithFieldContext(ctx, fc)
+ if fc.Args, err = ec.field_Query_unreadArticles_args(ctx, field.ArgumentMap(ec.Variables)); err != nil {
+ ec.Error(ctx, err)
+ return fc, err
+ }
return fc, nil
}
@@ -2395,7 +2884,7 @@ func (ec *executionContext) _Query_readArticles(ctx context.Context, field graph
}()
resTmp, err := ec.ResolverMiddleware(ctx, func(rctx context.Context) (any, error) {
ctx = rctx // use context from middleware stack in children
- return ec.resolvers.Query().ReadArticles(rctx)
+ return ec.resolvers.Query().ReadArticles(rctx, fc.Args["feedId"].(*string), fc.Args["after"].(*string), fc.Args["first"].(*int32))
})
if err != nil {
ec.Error(ctx, err)
@@ -2407,12 +2896,12 @@ func (ec *executionContext) _Query_readArticles(ctx context.Context, field graph
}
return graphql.Null
}
- res := resTmp.([]*model.Article)
+ res := resTmp.(*model.ArticleConnection)
fc.Result = res
- return ec.marshalNArticle2ᚕᚖundefᚗninjaᚋxᚋfeedakaᚋgraphqlᚋmodelᚐArticleᚄ(ctx, field.Selections, res)
+ return ec.marshalNArticleConnection2ᚖundefᚗninjaᚋxᚋfeedakaᚋgraphqlᚋmodelᚐArticleConnection(ctx, field.Selections, res)
}
-func (ec *executionContext) fieldContext_Query_readArticles(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) {
+func (ec *executionContext) fieldContext_Query_readArticles(ctx context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) {
fc = &graphql.FieldContext{
Object: "Query",
Field: field,
@@ -2420,24 +2909,25 @@ func (ec *executionContext) fieldContext_Query_readArticles(_ context.Context, f
IsResolver: true,
Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) {
switch field.Name {
- case "id":
- return ec.fieldContext_Article_id(ctx, field)
- case "feedId":
- return ec.fieldContext_Article_feedId(ctx, field)
- case "guid":
- return ec.fieldContext_Article_guid(ctx, field)
- case "title":
- return ec.fieldContext_Article_title(ctx, field)
- case "url":
- return ec.fieldContext_Article_url(ctx, field)
- case "isRead":
- return ec.fieldContext_Article_isRead(ctx, field)
- case "feed":
- return ec.fieldContext_Article_feed(ctx, field)
+ case "articles":
+ return ec.fieldContext_ArticleConnection_articles(ctx, field)
+ case "pageInfo":
+ return ec.fieldContext_ArticleConnection_pageInfo(ctx, field)
}
- return nil, fmt.Errorf("no field named %q was found under type Article", field.Name)
+ return nil, fmt.Errorf("no field named %q was found under type ArticleConnection", field.Name)
},
}
+ defer func() {
+ if r := recover(); r != nil {
+ err = ec.Recover(ctx, r)
+ ec.Error(ctx, err)
+ }
+ }()
+ ctx = graphql.WithFieldContext(ctx, fc)
+ if fc.Args, err = ec.field_Query_readArticles_args(ctx, field.ArgumentMap(ec.Variables)); err != nil {
+ ec.Error(ctx, err)
+ return fc, err
+ }
return fc, nil
}
@@ -2487,6 +2977,8 @@ func (ec *executionContext) fieldContext_Query_feed(ctx context.Context, field g
return ec.fieldContext_Feed_fetchedAt(ctx, field)
case "isSubscribed":
return ec.fieldContext_Feed_isSubscribed(ctx, field)
+ case "unreadCount":
+ return ec.fieldContext_Feed_unreadCount(ctx, field)
case "articles":
return ec.fieldContext_Feed_articles(ctx, field)
}
@@ -4869,6 +5361,50 @@ func (ec *executionContext) _Article(ctx context.Context, sel ast.SelectionSet,
return out
}
+var articleConnectionImplementors = []string{"ArticleConnection"}
+
+func (ec *executionContext) _ArticleConnection(ctx context.Context, sel ast.SelectionSet, obj *model.ArticleConnection) graphql.Marshaler {
+ fields := graphql.CollectFields(ec.OperationContext, sel, articleConnectionImplementors)
+
+ out := graphql.NewFieldSet(fields)
+ deferred := make(map[string]*graphql.FieldSet)
+ for i, field := range fields {
+ switch field.Name {
+ case "__typename":
+ out.Values[i] = graphql.MarshalString("ArticleConnection")
+ case "articles":
+ out.Values[i] = ec._ArticleConnection_articles(ctx, field, obj)
+ if out.Values[i] == graphql.Null {
+ out.Invalids++
+ }
+ case "pageInfo":
+ out.Values[i] = ec._ArticleConnection_pageInfo(ctx, field, obj)
+ if out.Values[i] == graphql.Null {
+ out.Invalids++
+ }
+ default:
+ panic("unknown field " + strconv.Quote(field.Name))
+ }
+ }
+ out.Dispatch(ctx)
+ if out.Invalids > 0 {
+ return graphql.Null
+ }
+
+ atomic.AddInt32(&ec.deferred, int32(len(deferred)))
+
+ for label, dfs := range deferred {
+ ec.processDeferredGroup(graphql.DeferredGroup{
+ Label: label,
+ Path: graphql.GetPath(ctx),
+ FieldSet: dfs,
+ Context: ctx,
+ })
+ }
+
+ return out
+}
+
var authPayloadImplementors = []string{"AuthPayload"}
func (ec *executionContext) _AuthPayload(ctx context.Context, sel ast.SelectionSet, obj *model.AuthPayload) graphql.Marshaler {
@@ -4944,6 +5480,11 @@ func (ec *executionContext) _Feed(ctx context.Context, sel ast.SelectionSet, obj
if out.Values[i] == graphql.Null {
out.Invalids++
}
+ case "unreadCount":
+ out.Values[i] = ec._Feed_unreadCount(ctx, field, obj)
+ if out.Values[i] == graphql.Null {
+ out.Invalids++
+ }
case "articles":
out.Values[i] = ec._Feed_articles(ctx, field, obj)
if out.Values[i] == graphql.Null {
@@ -5070,6 +5611,47 @@ func (ec *executionContext) _Mutation(ctx context.Context, sel ast.SelectionSet)
return out
}
+var pageInfoImplementors = []string{"PageInfo"}
+
+func (ec *executionContext) _PageInfo(ctx context.Context, sel ast.SelectionSet, obj *model.PageInfo) graphql.Marshaler {
+ fields := graphql.CollectFields(ec.OperationContext, sel, pageInfoImplementors)
+
+ out := graphql.NewFieldSet(fields)
+ deferred := make(map[string]*graphql.FieldSet)
+ for i, field := range fields {
+ switch field.Name {
+ case "__typename":
+ out.Values[i] = graphql.MarshalString("PageInfo")
+ case "hasNextPage":
+ out.Values[i] = ec._PageInfo_hasNextPage(ctx, field, obj)
+ if out.Values[i] == graphql.Null {
+ out.Invalids++
+ }
+ case "endCursor":
+ out.Values[i] = ec._PageInfo_endCursor(ctx, field, obj)
+ default:
+ panic("unknown field " + strconv.Quote(field.Name))
+ }
+ }
+ out.Dispatch(ctx)
+ if out.Invalids > 0 {
+ return graphql.Null
+ }
+
+ atomic.AddInt32(&ec.deferred, int32(len(deferred)))
+
+ for label, dfs := range deferred {
+ ec.processDeferredGroup(graphql.DeferredGroup{
+ Label: label,
+ Path: graphql.GetPath(ctx),
+ FieldSet: dfs,
+ Context: ctx,
+ })
+ }
+
+ return out
+}
+
var queryImplementors = []string{"Query"}
func (ec *executionContext) _Query(ctx context.Context, sel ast.SelectionSet) graphql.Marshaler {
@@ -5680,6 +6262,20 @@ func (ec *executionContext) marshalNArticle2ᚖundefᚗninjaᚋxᚋfeedakaᚋgra
return ec._Article(ctx, sel, v)
}
+func (ec *executionContext) marshalNArticleConnection2undefᚗninjaᚋxᚋfeedakaᚋgraphqlᚋmodelᚐArticleConnection(ctx context.Context, sel ast.SelectionSet, v model.ArticleConnection) graphql.Marshaler {
+ return ec._ArticleConnection(ctx, sel, &v)
+}
+
+func (ec *executionContext) marshalNArticleConnection2ᚖundefᚗninjaᚋxᚋfeedakaᚋgraphqlᚋmodelᚐArticleConnection(ctx context.Context, sel ast.SelectionSet, v *model.ArticleConnection) graphql.Marshaler {
+ if v == nil {
+ if !graphql.HasFieldError(ctx, graphql.GetFieldContext(ctx)) {
+ ec.Errorf(ctx, "the requested element is null which the schema does not allow")
+ }
+ return graphql.Null
+ }
+ return ec._ArticleConnection(ctx, sel, v)
+}
+
func (ec *executionContext) marshalNAuthPayload2undefᚗninjaᚋxᚋfeedakaᚋgraphqlᚋmodelᚐAuthPayload(ctx context.Context, sel ast.SelectionSet, v model.AuthPayload) graphql.Marshaler {
return ec._AuthPayload(ctx, sel, &v)
}
@@ -5800,6 +6396,32 @@ func (ec *executionContext) marshalNID2string(ctx context.Context, sel ast.Selec
return res
}
+func (ec *executionContext) unmarshalNInt2int32(ctx context.Context, v any) (int32, error) {
+ res, err := graphql.UnmarshalInt32(v)
+ return res, graphql.ErrorOnPath(ctx, err)
+}
+
+func (ec *executionContext) marshalNInt2int32(ctx context.Context, sel ast.SelectionSet, v int32) graphql.Marshaler {
+ _ = sel
+ res := graphql.MarshalInt32(v)
+ if res == graphql.Null {
+ if !graphql.HasFieldError(ctx, graphql.GetFieldContext(ctx)) {
+ ec.Errorf(ctx, "the requested element is null which the schema does not allow")
+ }
+ }
+ return res
+}
+
+func (ec *executionContext) marshalNPageInfo2ᚖundefᚗninjaᚋxᚋfeedakaᚋgraphqlᚋmodelᚐPageInfo(ctx context.Context, sel ast.SelectionSet, v *model.PageInfo) graphql.Marshaler {
+ if v == nil {
+ if !graphql.HasFieldError(ctx, graphql.GetFieldContext(ctx)) {
+ ec.Errorf(ctx, "the requested element is null which the schema does not allow")
+ }
+ return graphql.Null
+ }
+ return ec._PageInfo(ctx, sel, v)
+}
+
func (ec *executionContext) unmarshalNString2string(ctx context.Context, v any) (string, error) {
res, err := graphql.UnmarshalString(v)
return res, graphql.ErrorOnPath(ctx, err)
@@ -6123,6 +6745,42 @@ func (ec *executionContext) marshalOFeed2ᚖundefᚗninjaᚋxᚋfeedakaᚋgraphq
return ec._Feed(ctx, sel, v)
}
+func (ec *executionContext) unmarshalOID2ᚖstring(ctx context.Context, v any) (*string, error) {
+ if v == nil {
+ return nil, nil
+ }
+ res, err := graphql.UnmarshalID(v)
+ return &res, graphql.ErrorOnPath(ctx, err)
+}
+
+func (ec *executionContext) marshalOID2ᚖstring(ctx context.Context, sel ast.SelectionSet, v *string) graphql.Marshaler {
+ if v == nil {
+ return graphql.Null
+ }
+ _ = sel
+ _ = ctx
+ res := graphql.MarshalID(*v)
+ return res
+}
+
+func (ec *executionContext) unmarshalOInt2ᚖint32(ctx context.Context, v any) (*int32, error) {
+ if v == nil {
+ return nil, nil
+ }
+ res, err := graphql.UnmarshalInt32(v)
+ return &res, graphql.ErrorOnPath(ctx, err)
+}
+
+func (ec *executionContext) marshalOInt2ᚖint32(ctx context.Context, sel ast.SelectionSet, v *int32) graphql.Marshaler {
+ if v == nil {
+ return graphql.Null
+ }
+ _ = sel
+ _ = ctx
+ res := graphql.MarshalInt32(*v)
+ return res
+}
+
func (ec *executionContext) unmarshalOString2ᚖstring(ctx context.Context, v any) (*string, error) {
if v == nil {
return nil, nil
diff --git a/backend/graphql/model/generated.go b/backend/graphql/model/generated.go
index 11f9692..a305535 100644
--- a/backend/graphql/model/generated.go
+++ b/backend/graphql/model/generated.go
@@ -20,6 +20,14 @@ type Article struct {
Feed *Feed `json:"feed"`
}
+// A paginated list of articles
+type ArticleConnection struct {
+ // The list of articles
+ Articles []*Article `json:"articles"`
+ // Pagination information
+ PageInfo *PageInfo `json:"pageInfo"`
+}
+
// Authentication payload returned from login mutation
type AuthPayload struct {
// The authenticated user
@@ -38,6 +46,8 @@ type Feed struct {
FetchedAt string `json:"fetchedAt"`
// Whether the user is currently subscribed to this feed
IsSubscribed bool `json:"isSubscribed"`
+ // Number of unread articles in this feed
+ UnreadCount int32 `json:"unreadCount"`
// Articles belonging to this feed
Articles []*Article `json:"articles"`
}
@@ -46,6 +56,14 @@ type Feed struct {
type Mutation struct {
}
+// Pagination information for cursor-based pagination
+type PageInfo struct {
+ // Whether there are more items after the last item in this page
+ HasNextPage bool `json:"hasNextPage"`
+ // Cursor of the last item in this page
+ EndCursor *string `json:"endCursor,omitempty"`
+}
+
// Root query type for reading data
type Query struct {
}
diff --git a/backend/graphql/resolver/pagination.go b/backend/graphql/resolver/pagination.go
new file mode 100644
index 0000000..1a14650
--- /dev/null
+++ b/backend/graphql/resolver/pagination.go
@@ -0,0 +1,177 @@
+package resolver
+
+import (
+ "context"
+ "fmt"
+ "strconv"
+
+ "undef.ninja/x/feedaka/db"
+ "undef.ninja/x/feedaka/graphql/model"
+)
+
+const defaultPageSize = 30
+const maxPageSize = 100
+
+// articleRow is a common interface for all paginated article query rows.
+type articleRow struct {
+ ID int64
+ FeedID int64
+ Guid string
+ Title string
+ Url string
+ IsRead int64
+ FeedID2 int64
+ FeedUrl string
+ FeedTitle string
+ FeedIsSubscribed int64
+}
+
+func toArticleRow(r any) articleRow {
+ switch v := r.(type) {
+ case db.GetArticlesPaginatedRow:
+ return articleRow{v.ID, v.FeedID, v.Guid, v.Title, v.Url, v.IsRead, v.FeedID2, v.FeedUrl, v.FeedTitle, v.FeedIsSubscribed}
+ case db.GetArticlesPaginatedAfterRow:
+ return articleRow{v.ID, v.FeedID, v.Guid, v.Title, v.Url, v.IsRead, v.FeedID2, v.FeedUrl, v.FeedTitle, v.FeedIsSubscribed}
+ case db.GetArticlesByFeedPaginatedRow:
+ return articleRow{v.ID, v.FeedID, v.Guid, v.Title, v.Url, v.IsRead, v.FeedID2, v.FeedUrl, v.FeedTitle, v.FeedIsSubscribed}
+ case db.GetArticlesByFeedPaginatedAfterRow:
+ return articleRow{v.ID, v.FeedID, v.Guid, v.Title, v.Url, v.IsRead, v.FeedID2, v.FeedUrl, v.FeedTitle, v.FeedIsSubscribed}
+ default:
+ panic("unexpected row type")
+ }
+}
+
+func rowToArticle(row articleRow) *model.Article {
+ return &model.Article{
+ ID: strconv.FormatInt(row.ID, 10),
+ FeedID: strconv.FormatInt(row.FeedID, 10),
+ GUID: row.Guid,
+ Title: row.Title,
+ URL: row.Url,
+ IsRead: row.IsRead == 1,
+ Feed: &model.Feed{
+ ID: strconv.FormatInt(row.FeedID2, 10),
+ URL: row.FeedUrl,
+ Title: row.FeedTitle,
+ IsSubscribed: row.FeedIsSubscribed == 1,
+ },
+ }
+}
+
+func (r *queryResolver) paginatedArticles(ctx context.Context, isRead int64, feedID *string, after *string, first *int32) (*model.ArticleConnection, error) {
+ userID, err := getUserIDFromContext(ctx)
+ if err != nil {
+ return nil, err
+ }
+
+ limit := int64(defaultPageSize)
+ if first != nil {
+ limit = int64(*first)
+ if limit <= 0 {
+ limit = int64(defaultPageSize)
+ }
+ if limit > maxPageSize {
+ limit = maxPageSize
+ }
+ }
+
+ // Fetch limit+1 to determine hasNextPage
+ fetchLimit := limit + 1
+
+ var rawRows []any
+
+ if feedID != nil {
+ parsedFeedID, err := strconv.ParseInt(*feedID, 10, 64)
+ if err != nil {
+ return nil, fmt.Errorf("invalid feed ID: %w", err)
+ }
+
+ if after != nil {
+ cursor, err := strconv.ParseInt(*after, 10, 64)
+ if err != nil {
+ return nil, fmt.Errorf("invalid cursor: %w", err)
+ }
+ rows, err := r.Queries.GetArticlesByFeedPaginatedAfter(ctx, db.GetArticlesByFeedPaginatedAfterParams{
+ IsRead: isRead,
+ UserID: userID,
+ FeedID: parsedFeedID,
+ ID: cursor,
+ Limit: fetchLimit,
+ })
+ if err != nil {
+ return nil, fmt.Errorf("failed to query articles: %w", err)
+ }
+ for _, row := range rows {
+ rawRows = append(rawRows, row)
+ }
+ } else {
+ rows, err := r.Queries.GetArticlesByFeedPaginated(ctx, db.GetArticlesByFeedPaginatedParams{
+ IsRead: isRead,
+ UserID: userID,
+ FeedID: parsedFeedID,
+ Limit: fetchLimit,
+ })
+ if err != nil {
+ return nil, fmt.Errorf("failed to query articles: %w", err)
+ }
+ for _, row := range rows {
+ rawRows = append(rawRows, row)
+ }
+ }
+ } else {
+ if after != nil {
+ cursor, err := strconv.ParseInt(*after, 10, 64)
+ if err != nil {
+ return nil, fmt.Errorf("invalid cursor: %w", err)
+ }
+ rows, err := r.Queries.GetArticlesPaginatedAfter(ctx, db.GetArticlesPaginatedAfterParams{
+ IsRead: isRead,
+ UserID: userID,
+ ID: cursor,
+ Limit: fetchLimit,
+ })
+ if err != nil {
+ return nil, fmt.Errorf("failed to query articles: %w", err)
+ }
+ for _, row := range rows {
+ rawRows = append(rawRows, row)
+ }
+ } else {
+ rows, err := r.Queries.GetArticlesPaginated(ctx, db.GetArticlesPaginatedParams{
+ IsRead: isRead,
+ UserID: userID,
+ Limit: fetchLimit,
+ })
+ if err != nil {
+ return nil, fmt.Errorf("failed to query articles: %w", err)
+ }
+ for _, row := range rows {
+ rawRows = append(rawRows, row)
+ }
+ }
+ }
+
+ hasNextPage := int64(len(rawRows)) > limit
+ if hasNextPage {
+ rawRows = rawRows[:limit]
+ }
+
+ articles := make([]*model.Article, 0, len(rawRows))
+ for _, raw := range rawRows {
+ articles = append(articles, rowToArticle(toArticleRow(raw)))
+ }
+
+ var endCursor *string
+ if len(articles) > 0 {
+ lastID := articles[len(articles)-1].ID
+ endCursor = &lastID
+ }
+
+ return &model.ArticleConnection{
+ Articles: articles,
+ PageInfo: &model.PageInfo{
+ HasNextPage: hasNextPage,
+ EndCursor: endCursor,
+ },
+ }, nil
+}
diff --git a/backend/graphql/resolver/schema.resolvers.go b/backend/graphql/resolver/schema.resolvers.go
index 10f892f..0392945 100644
--- a/backend/graphql/resolver/schema.resolvers.go
+++ b/backend/graphql/resolver/schema.resolvers.go
@@ -311,6 +311,16 @@ func (r *queryResolver) Feeds(ctx context.Context) ([]*model.Feed, error) {
return nil, fmt.Errorf("failed to query feeds: %w", err)
}
+ // Fetch unread counts for all feeds
+ unreadCounts, err := r.Queries.GetFeedUnreadCounts(ctx, userID)
+ if err != nil {
+ return nil, fmt.Errorf("failed to query unread counts: %w", err)
+ }
+ countMap := make(map[int64]int64, len(unreadCounts))
+ for _, uc := range unreadCounts {
+ countMap[uc.FeedID] = uc.UnreadCount
+ }
+
var feeds []*model.Feed
for _, dbFeed := range dbFeeds {
feeds = append(feeds, &model.Feed{
@@ -319,6 +329,7 @@ func (r *queryResolver) Feeds(ctx context.Context) ([]*model.Feed, error) {
Title: dbFeed.Title,
FetchedAt: dbFeed.FetchedAt,
IsSubscribed: dbFeed.IsSubscribed == 1,
+ UnreadCount: int32(countMap[dbFeed.ID]),
})
}
@@ -326,69 +337,13 @@ func (r *queryResolver) Feeds(ctx context.Context) ([]*model.Feed, error) {
}
// UnreadArticles is the resolver for the unreadArticles field.
-func (r *queryResolver) UnreadArticles(ctx context.Context) ([]*model.Article, error) {
- userID, err := getUserIDFromContext(ctx)
- if err != nil {
- return nil, err
- }
-
- rows, err := r.Queries.GetUnreadArticles(ctx, userID)
- if err != nil {
- return nil, fmt.Errorf("failed to query unread articles: %w", err)
- }
-
- var articles []*model.Article
- for _, row := range rows {
- articles = append(articles, &model.Article{
- ID: strconv.FormatInt(row.ID, 10),
- FeedID: strconv.FormatInt(row.FeedID, 10),
- GUID: row.Guid,
- Title: row.Title,
- URL: row.Url,
- IsRead: row.IsRead == 1,
- Feed: &model.Feed{
- ID: strconv.FormatInt(row.FeedID2, 10),
- URL: row.FeedUrl,
- Title: row.FeedTitle,
- IsSubscribed: row.FeedIsSubscribed == 1,
- },
- })
- }
-
- return articles, nil
+func (r *queryResolver) UnreadArticles(ctx context.Context, feedID *string, after *string, first *int32) (*model.ArticleConnection, error) {
+ return r.paginatedArticles(ctx, 0, feedID, after, first)
}
// ReadArticles is the resolver for the readArticles field.
-func (r *queryResolver) ReadArticles(ctx context.Context) ([]*model.Article, error) {
- userID, err := getUserIDFromContext(ctx)
- if err != nil {
- return nil, err
- }
-
- rows, err := r.Queries.GetReadArticles(ctx, userID)
- if err != nil {
- return nil, fmt.Errorf("failed to query read articles: %w", err)
- }
-
- var articles []*model.Article
- for _, row := range rows {
- articles = append(articles, &model.Article{
- ID: strconv.FormatInt(row.ID, 10),
- FeedID: strconv.FormatInt(row.FeedID, 10),
- GUID: row.Guid,
- Title: row.Title,
- URL: row.Url,
- IsRead: row.IsRead == 1,
- Feed: &model.Feed{
- ID: strconv.FormatInt(row.FeedID2, 10),
- URL: row.FeedUrl,
- Title: row.FeedTitle,
- IsSubscribed: row.FeedIsSubscribed == 1,
- },
- })
- }
-
- return articles, nil
+func (r *queryResolver) ReadArticles(ctx context.Context, feedID *string, after *string, first *int32) (*model.ArticleConnection, error) {
+ return r.paginatedArticles(ctx, 1, feedID, after, first)
}
// Feed is the resolver for the feed field.
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>
);
}
diff --git a/graphql/schema.graphql b/graphql/schema.graphql
index e37d729..3f9eddb 100644
--- a/graphql/schema.graphql
+++ b/graphql/schema.graphql
@@ -30,6 +30,11 @@ type Feed {
isSubscribed: Boolean!
"""
+ Number of unread articles in this feed
+ """
+ unreadCount: Int!
+
+ """
Articles belonging to this feed
"""
articles: [Article!]!
@@ -91,6 +96,36 @@ type User {
}
"""
+Pagination information for cursor-based pagination
+"""
+type PageInfo {
+ """
+ Whether there are more items after the last item in this page
+ """
+ hasNextPage: Boolean!
+
+ """
+ Cursor of the last item in this page
+ """
+ endCursor: ID
+}
+
+"""
+A paginated list of articles
+"""
+type ArticleConnection {
+ """
+ The list of articles
+ """
+ articles: [Article!]!
+
+ """
+ Pagination information
+ """
+ pageInfo: PageInfo!
+}
+
+"""
Authentication payload returned from login mutation
"""
type AuthPayload {
@@ -110,14 +145,14 @@ type Query {
feeds: [Feed!]!
"""
- Get all unread articles across all feeds
+ Get unread articles with optional feed filter and cursor-based pagination
"""
- unreadArticles: [Article!]!
+ unreadArticles(feedId: ID, after: ID, first: Int): ArticleConnection!
"""
- Get all read articles across all feeds
+ Get read articles with optional feed filter and cursor-based pagination
"""
- readArticles: [Article!]!
+ readArticles(feedId: ID, after: ID, first: Int): ArticleConnection!
"""
Get a specific feed by ID